Public-flip audit: env-driven paths, scrub audit-ticket prefixes, terser README

Lucy bind paths + LAN host pins replaced with env defaults. Repository URLs
→ git.sulkta.com. Audit-changelog scaffolding stripped from inline comments
(technical reasoning preserved). README sheds marketing scaffolding. AI-speak
in load-bearing prompts/SOULs left alone — that IS the product.
This commit is contained in:
Cobb Hayes 2026-05-27 11:42:58 -07:00
parent 4402c53979
commit 346cea515d
21 changed files with 325 additions and 474 deletions

View file

@ -7,7 +7,7 @@ version = "0.0.1"
edition = "2024"
license = "MIT"
authors = ["Cobb (Jacob Hayes)"]
repository = "http://192.168.0.5:3001/cobb/skald"
repository = "https://git.sulkta.com/cobb/skald"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }

View file

@ -1,26 +1,16 @@
# Multi-stage build for skald.
# Multi-stage build for skald. Postgres ships in the same image
# until the tool stabilises; to split, swap the runtime base to
# debian:bookworm-slim, drop entrypoint.sh, point DATABASE_URL at
# an external pg.
#
# Stage 1: compile the rust binary against rust:1-bookworm.
# Stage 2: pgvector/pgvector:pg17 (debian-bookworm postgres with
# pgvector preinstalled) + tini + the skald binary.
#
# v0.1 ships postgres inside the same container ("singleton till we
# have a real working tool"). When we extract the DB out, swap the
# runtime base to debian:bookworm-slim, drop entrypoint.sh, point
# DATABASE_URL at the external pg.
#
# Build context is the workspace root:
# docker build -t skald:latest .
# Build context is the workspace root: `docker build -t skald:latest .`
# ─── builder ──────────────────────────────────────────────────────
FROM rust:1.95-bookworm AS builder
WORKDIR /build
# Cache the dependency graph: copy manifests first, fetch + build
# stubs, THEN drop in real sources. The vendored clawdforge SDK
# needs its own manifest + source available during the cache layer
# (path dep — Cargo resolves it at workspace load time, not at
# crate-compile time).
# Dependency-cache layer: copy manifests + vendored path-dep first,
# build stubs, then drop in real sources. clawdforge is a path dep
# resolved at workspace load time.
COPY Cargo.toml Cargo.lock ./
COPY skald-core/Cargo.toml skald-core/Cargo.toml
COPY skald/Cargo.toml skald/Cargo.toml

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Cobb (Jacob Hayes)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md
View file

@ -1,95 +1,79 @@
# skald
Long-form story-writer with canon-keeping, sequel continuity, and
(future) self-hosted audiobook narration. Database is the source of
truth — the writer is the tooling.
self-hosted audiobook narration. The database is the source of truth;
the binary is the tooling.
Named for the Old Norse poets who composed and memorized kings'
sagas across generations.
Named for the Old Norse poets who composed and memorized kings' sagas
across generations.
## Status: v0.1 — scaffold
What's wired:
## What's wired
- Rust workspace (`skald-core` + `skald`)
- Postgres schema for stories, characters, canon facts, chapters,
passages, generation runs, audit findings, tags
- pgvector extension installed for future similarity search
- pgvector for future similarity search
- `skald import-markdown` ingests a story file (chapters + bible)
into the schema
- `skald serve` exposes `/health` and runs migrations on boot
- Single-container deploy: postgres + skald in one image
Wired (this commit):
- clawdforge Rust SDK vendored at `vendor/clawdforge/` (upstream:
`Sulkta-Coop/clawdforge` `clients/rust/`)
- `skald-core::forge` — three-pass orchestration shell (gen / cleanup /
audit). Prompts are TODO stubs; pipeline plumbing is in place.
Not yet wired:
- Web UI (the inbox + browse + queue surface)
- Prompt templates for the three passes (heavy prompt-engineering
work — own session)
- `skald-core::context` — assemble the LLM context blob from DB rows
(bible + characters + parent prose summaries + similarity-matched
passages)
- Embeddings backfill + ivfflat index
- TTS sidecar container + post-render audit chain (see
`docs/tts-pipeline.md`)
## v0.1 smoke
```bash
docker compose -p skald up -d
docker exec skald skald import-markdown \
--path /seed/coast-down.md \
--title "The Coast-Down"
curl http://lucy:7780/health
# → { ok: true, db_ok: true, story_count: 1, ... }
```
- `skald serve` exposes `/health` + the web inspector and runs
migrations on boot
- `skald continue` runs gen → cleanup → audit per chapter, with
multi-chapter batching (cap 20)
- `skald rewrite` re-authors a chapter in a named author's voice
- `skald audit` runs whole-story prose-quality audit; `skald dedup`
is the surgical fix half of the loop
- `skald prepare-narration` annotates a chapter with `[breath]` /
`[pause:Xs]` / `[scene]` beats and per-character `[voice:...]`
tags
- `skald narrate` renders a chapter to audio via one of three TTS
engines (F5-TTS, Kokoro-82M, Tortoise-TTS) — see `engines/`
- Named-author "soul" personas via `skald authors seed`; author
voice replaces the model's base system prompt for gen/cleanup/
rewrite/dedup/narrate_prep
## Schema (cheat sheet)
```
stories → meta + status + parent/root for series
characters → real or fictional, story-scoped
canon_facts → setting, mystery, theme, rule, historical_anchor, hook
chapters → full prose body
chapter_summaries → short summaries for cheap context loading
passages → paragraph-level + embedding vector(1536)
generation_runs → every LLM call logged
audit_findings → canon audit output (severity + area)
tags → arbitrary labels
stories meta + status + parent/root for series
authors persona identity (slug, display_name, model)
author_revisions versioned souls; one current per author
characters real or fictional, story-scoped, voice-mappable
canon_facts setting, mystery, theme, rule, historical_anchor, hook
chapters full prose body + optional body_md_tts annotation
chapter_summaries short summaries for cheap context loading
passages paragraph-level + embedding vector(1536)
voices TTS voice rows (F5 ref clips / Kokoro / Tortoise names)
pronunciation_overrides per-story + global respellings for proper nouns
generation_runs every LLM call logged
audit_findings audit pass output (severity + area)
narration_runs per-chapter TTS renders
```
## Architecture (v0.1 + the plan)
## Quickstart
```
┌─────────────────────────────────┐
│ skald container │
│ ┌───────────┐ ┌────────────┐ │
│ │ postgres │ │ skald-rust │ │
│ │ pgvector │←─│ axum + cli │ │
│ │ localhost │ │ :7780 │ │
│ └───────────┘ └─────┬──────┘ │
└─────────────────────────┼────────┘
│ HTTP (future)
┌──────────┐
│clawdforge│
└─────┬────┘
opus calls
```sh
docker compose up -d
docker exec skald skald import-markdown --path /seed/<story>.md \
--title "<title>"
curl http://localhost:7780/health
```
v1.0+: extract postgres to its own container on db-net. skald
becomes pure stateless rust, connects via `DATABASE_URL`. Migration
is a connection-string change + a network move; the binary doesn't
care where the DB lives.
The compose file expects `POSTGRES_PASSWORD` and (optionally)
`CLAWDFORGE_URL` + `CLAWDFORGE_TOKEN` in `.env`. Story markdown
goes into `./seed/`; postgres data persists in `./pgdata/`.
## Architecture
v0.1 ships postgres inside the skald container — singleton until
the tool stabilises. To extract postgres later, swap the runtime
base to `debian:bookworm-slim`, drop `entrypoint.sh`, and point
`DATABASE_URL` at the external pg. The binary doesn't care where
the DB lives.
The generation passes call out to `clawdforge` (a bearer-token-gated
HTTP wrapper around `claude -p`). The Rust client is vendored at
`vendor/clawdforge/`. TTS calls go HTTP+JSON to the per-engine
sidecars under `engines/`.
## License
MIT.
MIT — see `LICENSE`.

View file

@ -1,37 +1,42 @@
# Standalone compose stack for skald v0.1. Postgres lives in the
# same container — single deployable unit "till we have a real
# working tool" (cobb's call, 2026-05-13).
# Standalone compose stack for skald. Postgres lives inside the
# skald container — single deployable unit until the tool stabilises.
#
# To deploy on Lucy:
# sudo mkdir -p /mnt/cache/appdata/skald/{pgdata,seed}
# sudo cp <story>.md /mnt/cache/appdata/skald/seed/
# sudo cp skald.env /mnt/cache/appdata/secrets/skald.env # POSTGRES_PASSWORD=...
# docker compose -p skald up -d
# Set in .env (or the environment):
# POSTGRES_PASSWORD=... # required
# CLAWDFORGE_URL=http://...:8800 # if running gen / cleanup / audit
# CLAWDFORGE_TOKEN=cf_...
# SKALD_DATA=./pgdata # optional override; defaults to ./pgdata
# SKALD_SEED=./seed # optional override; defaults to ./seed
#
# To import the first story:
# docker exec skald skald import-markdown \
# docker compose exec skald skald import-markdown \
# --path /seed/<story>.md \
# --title "<title>"
name: skald
services:
skald:
image: lucy-registry:5000/skald:latest
build: .
image: skald:latest
container_name: skald
restart: unless-stopped
ports:
- "7780:7780"
env_file:
- /mnt/cache/appdata/secrets/skald.env
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_USER: ${POSTGRES_USER:-skald}
POSTGRES_DB: ${POSTGRES_DB:-skald}
DATABASE_URL: ${DATABASE_URL:-postgresql://skald:${POSTGRES_PASSWORD}@localhost:5432/skald}
CLAWDFORGE_URL: ${CLAWDFORGE_URL:-}
CLAWDFORGE_TOKEN: ${CLAWDFORGE_TOKEN:-}
SKALD_MODEL: ${SKALD_MODEL:-opus}
F5_TTS_URL: ${F5_TTS_URL:-}
KOKORO_URL: ${KOKORO_URL:-}
TORTOISE_URL: ${TORTOISE_URL:-}
RUST_LOG: ${RUST_LOG:-info}
SKALD_LOG_FORMAT: ${SKALD_LOG_FORMAT:-json}
volumes:
# Postgres data — persist across container recreates.
- /mnt/cache/appdata/skald/pgdata:/var/lib/postgresql/data
# Markdown corpus to import via `docker exec skald skald import-markdown`.
- /mnt/cache/appdata/skald/seed:/seed:ro
environment:
RUST_LOG: ${RUST_LOG:-info}
SKALD_LOG_FORMAT: json
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "cobb"
org.sulkta.managed-by: "compose"
org.sulkta.role: "skald"
- ${SKALD_DATA:-./pgdata}:/var/lib/postgresql/data
# Markdown corpus to import via `skald import-markdown`.
- ${SKALD_SEED:-./seed}:/seed:ro

View file

@ -1,83 +1,51 @@
# Authors as personas with souls (v0.3 design — IN PROGRESS)
# Authors as personas with souls
The pivot from "anonymous generator" to "real-feeling human authors."
Each story has a named author with a soul. The author's voice bleeds
through every generation pass — not as instruction stapled to the
prompt, but as the substrate the prose grows from. The reader should
feel "a person wrote this."
## Vision (cobb 2026-05-13)
Authors have memory across their corpus when the per-story
`cross_story_memory` toggle is on. An author writing a Chernobyl
piece can quietly echo a phrase from an earlier mining-strike story.
Default is off — most stories stand alone.
Each story has a **named author with a soul.** The author's voice
bleeds through every pass — not as instruction stapled to the prompt,
but as the substrate from which the prose grows. The reader should
feel "a person wrote this." Example tagline cobb gave:
## Schema
> "George Orwell but more rebel and pissed off — slightly comes out
> in stories."
Authors have **memory across their corpus.** Per-story opt-in toggle
lets an author subtly reference / echo other stories they've
written. "Cross-dimensional story crosses" — Orson Black writing a
Chernobyl piece might quietly echo a phrase from his earlier
mining-strike story when the toggle is on.
## Why this is a real design change
Until now skald is opus + a thin prompt. The output WILL eventually
sound like opus-with-a-system-prompt. That's a ceiling on prose
quality + identity. Authors-with-souls is the ceiling-lifter.
Knock-on effects:
- All three forge passes (gen / cleanup / audit) need author awareness.
- Audit becomes nuanced: "does the prose match this author's voice?"
is an audit signal, not just "does it match canon?"
- Cross-story memory unlocks long-arc literary projects (cobb's
potential YouTube channel idea, narrated-audiobook coop, etc).
## Locked decisions (so far)
1. **Authors live in the DB**, not on disk. Soul is a markdown blob
in `authors.soul`. Editable via web UI eventually; portable per
story.
2. **Soul replaces opus's default system prompt** for every forge
pass on that story. clawdforge currently maps `system` to
`--append-system-prompt` — we'll either (a) live with appending,
or (b) extend clawdforge with a `system_mode: append|replace`
field. **Option (a) tonight** because clawdforge changes are
their own session.
3. **Per-story toggle for cross-story memory** (`stories.cross_story_memory: bool`).
Per-author would make the toggle useless — we want Orson Black to
write standalone-Chernobyl AND deeply-cross-referenced-mining-strike.
4. **Multiple authors per series allowed.** `stories.author_id` is
per-story, not per-root_story. Future-proofs collaborations.
## Schema sketch
Authors live in the database, not on disk. The soul is a markdown
blob in `author_revisions.soul`. Authors are immutable; new soul
revisions create a new `author_revisions` row marked `is_current`
and the previous one is demoted.
```sql
CREATE TABLE authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- "orson-black"
display_name TEXT NOT NULL, -- "Orson Black"
persona_tagline TEXT, -- "Orwell but more rebel + pissed off"
soul TEXT NOT NULL, -- SOUL.md-style markdown blob
id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
persona_tagline TEXT,
model TEXT NOT NULL DEFAULT 'opus',
-- Optional per-author system-prompt scaffold. The author's soul
-- fills in the persona substance; the scaffold structures it
-- around skald's needs (canon honoring, no-meta-commentary,
-- etc). Default scaffold lives in skald-core.
system_template TEXT,
-- Tools the author can call during gen. Off by default — fiction
-- writes don't normally need WebSearch / Read. But an author
-- with research bent could opt in for fact-checking detours.
tools TEXT[] NOT NULL DEFAULT '{}',
is_synthetic BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE author_revisions (
id UUID PRIMARY KEY,
author_id UUID NOT NULL REFERENCES authors(id),
n INT NOT NULL,
soul TEXT NOT NULL,
system_template TEXT,
tools TEXT[] NOT NULL DEFAULT '{}',
note TEXT,
is_current BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE stories
ADD COLUMN author_id UUID REFERENCES authors(id) ON DELETE SET NULL,
ADD COLUMN author_id UUID REFERENCES authors(id),
ADD COLUMN author_revision_id UUID REFERENCES author_revisions(id),
ADD COLUMN cross_story_memory BOOLEAN NOT NULL DEFAULT false;
-- For cross_story_memory: which stories does this author have access
-- to? Auto-row on every authored story; explicit "marked as read"
-- rows for stories by other authors the author has internalized.
CREATE TABLE author_corpus (
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
story_id UUID NOT NULL REFERENCES stories(id) ON DELETE CASCADE,
@ -87,11 +55,29 @@ CREATE TABLE author_corpus (
);
```
## Soul template (the contract per author)
## Per-pass author roles
Following the same shape as `/root/.openclaw/workspace/SOUL.md` but
recalibrated for authorial identity. Suggested sections (free-form
prose, not strict schema):
- **gen** — full author voice. They are writing.
- **cleanup** — full author voice. Polishing their own draft, not a
neutral editor.
- **rewrite** — full author voice. Re-authoring another hand's
prose; canon preserved, prose reworked.
- **dedup** — full author voice. Surgical fix of audit-flagged
repetitions only.
- **narrate_prep** — author voice if bound; the author's beat
placement carries.
- **audit / prose_audit** — neutral. The audit checks the author's
work with fresh eyes; no author bound.
- **summarize** — neutral. Continuity utility, not prose.
When an author is bound, the soul replaces the model's base system
prompt (`SystemMode::Replace`). Without an author, a neutral house
scaffold is appended (`SystemMode::Append`).
## Soul template
Following the SOUL.md shape, recalibrated for authorial identity.
Free-form prose under each section.
```markdown
# Author: {{display_name}}
@ -101,8 +87,8 @@ _Tagline: {{persona_tagline}}_
## Voice
Sentence rhythm. Vocabulary register. Paragraph length tendencies.
Dialogue density. Punctuation habits (dashes, semicolons,
sentence fragments). What your prose SOUNDS like read aloud.
Dialogue density. Punctuation habits (dashes, semicolons, sentence
fragments). What your prose SOUNDS like read aloud.
## Worldview
@ -112,15 +98,13 @@ in the implicit moral architecture of any scene.
## Specifics over abstractions
What concrete details you reach for. (Orwell: the cold tap, the
miners' shoes, the gin.) The 5 senses you favor. Smells? Cold?
Texture of cloth? Sound of machines?
The concrete details you reach for. The five senses you favor.
Smells? Cold? Texture of cloth? Sound of machines?
## Pet peeves
Words you refuse to write. Tropes you avoid. Sentimentalities you
gut. (Orson: "soul-stirring," "ineffable," "tapestry of
emotions." No prose-poetry trim.)
gut.
## Sense of humor
@ -129,159 +113,51 @@ does humor live — end of a sentence, mid-clause, or never?
## Biography (real or invented)
A few biographical facts that EXPLAIN the voice. (Not a CV — the
formative cuts. Orson grew up in a coal town. His father died of
black lung. He read Trotsky at seventeen. He spent two winters
working in a Tyne shipyard.)
A few biographical facts that EXPLAIN the voice — the formative
cuts, not a CV.
## Anchor authors
Living or dead authors the prose draws from. Useful for the model
to triangulate voice. (Orson: Orwell, Cormac McCarthy, Don
DeLillo, Tony Judt's nonfiction.)
to triangulate voice.
## Do
## Do / Don't
- Specifics that puncture sentiment.
- Direct address.
- Cold air, hard surfaces.
- Politics in the texture, not the lecture.
## Don't
- Soft consolations.
- Therapy-speak.
- Magical resolutions.
- Adverbs that intensify ("absolutely," "incredibly," "deeply").
Concrete prose moves to reach for and to avoid.
```
The soul gets jammed into the system prompt via a scaffold like:
The default scaffold (`DEFAULT_AUTHOR_SCAFFOLD` in `skald-core::forge`)
wraps the soul in a system prompt that:
```
You are {{display_name}}, an author. Your voice and worldview are
described in the soul below. Honor them in every sentence — not as
performance but as substrate. The story's canon (characters,
setting, established facts) is non-negotiable; your voice lives
WITHIN those constraints.
- declares the model IS the author, not playing one
- pins canon as non-negotiable (names, dates, established events)
- forbids preamble, meta-commentary, fourth-wall breaks
- substitutes the per-pass directive (`GEN_DIRECTIVE`,
`CLEANUP_DIRECTIVE`, `REWRITE_DIRECTIVE`, `DEDUP_DIRECTIVE`,
`NARRATE_PREP_DIRECTIVE`)
If your worldview makes the user-prompted plot uncomfortable,
write through that discomfort — that's where good prose lives.
Never break the fourth wall to comment on the task. No meta-prose.
A per-author `system_template` overrides the default scaffold when
set; otherwise the default is used.
---
## Cross-story memory
{{soul}}
When `stories.cross_story_memory = true`, the continuation context
pulls characters / canon_facts / passages from every story the
author has authored or marked-read, not just the parent chain.
---
To keep token budget sane, cross-corpus pulls are summary-only by
default. Embeddings-similarity (once wired) can surface direct
callbacks.
You are writing for an audience of one (your reader). The story
matters. The voice matters. Both at once.
## Seeding an author
```sh
skald authors seed \
--slug orson-black \
--display-name "Orson Black" \
--tagline "Orwell but more rebel and pissed off" \
--file seeds/authors/orson-black.md
```
## Cross-story memory mechanics
When `stories.cross_story_memory = true`:
1. Continuation context pulls characters / canon_facts / passages
from EVERY story the author has authored or marked-read, not just
the parent chain.
2. To keep token budget sane, cross-story pulls are **summary-only
by default** — full passages from other corpora are too token-
expensive and the model only needs flavor, not detail.
3. Embeddings (when wired) can surface similarity-matched passages
from cross-corpus for direct callbacks.
When `cross_story_memory = false` (default):
- Context is just the parent chain. No cross-corpus pulls.
- This is the right default — most stories should stand alone.
## Per-pass author roles
Authors carry voice through all three passes, but the lens shifts:
- **gen**: full author voice. They're writing.
- **cleanup**: full author voice. Polishing their own draft (not
a neutral editor).
- **audit**: author SHIFTS to "canon auditor" mode — neutral, no
voice. The author isn't checking their own work; we want detached
canon-fact analysis. Schema-wise: audit ignores `stories.author_id`
and uses a default neutral system prompt.
This is asymmetric in a useful way. The author writes + revises.
The audit is the system's check on the author. Mirrors how books
get edited in real life.
## Seed authors (proposals — cobb picks)
1. **Orson Black** — "Orwell but more rebel + pissed off." Cobb's
stated direction. Coast-Down sequel candidate.
2. **Bay** — Bay's actual literary voice as captured in "Petal &
Bone" (saved in `memory/petal-and-bone.md` per PEOPLE.md). Soft,
observational, environmentally aware. For nature / family /
memory-shaped stories.
3. **Kayos** (me) — ghost-mode, dry, technical, distrust-by-default.
Useful for cyberpunk-adjacent / hacker / industrial-decline
stories. Could write a parallel Chernobyl piece that focuses on
the dosimetry/control-room minutiae over the human lens.
4. **House** — no soul, plain opus. For stories the user doesn't
want filtered through any specific personality. Like an
anonymous-author fallback.
## Open questions (cobb decides)
1. **Soul format** — strict section headings (template above) OR
free-form prose? The template helps the model know what to
prioritize; free-form lets each author breathe. *My lean:
strict headings for the seed authors, prove the shape works,
then loosen.*
2. **Who's the first seed author for Coast-Down sequels?** Orson
Black makes thematic sense — the Soviet industrial-decline
subject matter. Bay would write a very different Coast-Down
sequel (more interior, more sensory, less political). Pick one
for the v0.3 demo or seed both?
3. **Cross-story pull granularity** — summaries-only (v0.3 simple)
vs embeddings-similarity matched (needs embeddings wired first)
vs the full firehose (too expensive). *My lean: summaries-only
for v0.3, embeddings-similarity in v0.4 when we have multiple
stories per author.*
4. **clawdforge `system_mode: replace`** — append now (today's
path), full replace later (clawdforge enhancement / future
Rust rewrite). Acceptable? Or worth doing the clawdforge change
first?
5. **Audit pass author-neutrality** — should the author still
inform the audit at all? E.g., a "in this author's universe,
X is acceptable" could be a soul-derived note the audit
honors. *My lean: audit stays neutral. The author wrote the
thing; we want fresh eyes checking it.*
6. **Author edit history** — does soul change over time matter?
Should we version souls so a story always knows "this was the
version of Orson Black active when I was written"? *My lean:
YES, easy v0.4 add — `authors.id` becomes immutable; a new
soul-revision creates a NEW author row with a `revision_of`
FK. Stories pin to a specific revision.*
## Order of work after design lock
1. Migration 0004: authors + stories.author_id + author_corpus + cross_story_memory.
2. `skald-core::authors` module: load_author(slug), get_system_prompt_for_pass(author, kind).
3. `skald-core::forge` updated: every pass takes a `&Author` (or None for "house"), builds system prompt from author + scaffold.
4. `skald-core::context::ContinuationContext::assemble`: when story.cross_story_memory, pull from author_corpus instead of just parent chain.
5. Seed the first author(s) via a `skald authors seed --persona orson-black` subcommand OR direct SQL bootstrap.
6. THEN gen prompt template (the prose-craft session) lands AGAINST the authors layer.
7. `skald continue --story <id>` wires it all together.
## Status
- [x] Brainstorm captured in this doc (2026-05-13 late)
- [ ] Cobb's call on open questions 1-6
- [ ] Schema migration 0004
- [ ] authors module
- [ ] forge author-aware
- [ ] Seed Orson Black (?) soul
- [ ] gen prompt template
- [ ] CLI continue
This creates the author + first revision (or adds a new revision to
an existing author, which becomes current).

View file

@ -40,19 +40,18 @@ generalise. Examples:
all three at once), preset choice ergonomics, character→tortoise-
voice seed assignments.
When deploying an engine to Lucy, the build dir at
`/mnt/cache/appdata/<engine>/build/` tracks the engine's branch:
To deploy a tuned engine, check out the engine's branch in the build
dir and `docker compose up -d --build`:
```bash
cd /mnt/cache/appdata/kokoro/build
git fetch && git checkout engine/kokoro
docker compose -p <name> up -d --build
docker compose up -d --build
```
## GPU coordination (2070 Super)
## GPU coordination
The 8GB card is the bottleneck. F5 + Kokoro can co-reside (~5GB +
~1GB). Tortoise pushes the budget over and needs the GPU largely
to itself — the `engine/tortoise` branch will carry the script
that stops kokoro + f5 before a tortoise run and restarts them
after. Replace with proper coordination once we have more VRAM.
On an 8GB card F5 + Kokoro can co-reside (~5GB + ~1GB). Tortoise
pushes the budget over and needs the GPU largely to itself — the
`engine/tortoise` branch carries a script to stop kokoro + f5
before a Tortoise run and restart them after. Replace with proper
coordination once more VRAM is available.

View file

@ -1,9 +1,6 @@
# Sulkta build of F5-TTS — upstream ghcr.io/swivid/f5-tts:main was
# shipped with torch 2.11/torchaudio 2.4 ABI mismatch on 2026-05-13,
# breaking import torchaudio at boot. We rebuild on a known-good
# pytorch base + pip install f5-tts.
#
# Image tag in lucy-registry: lucy-registry:5000/f5-tts:<ver>
# F5-TTS rebuild on a known-good pytorch base. Upstream
# ghcr.io/swivid/f5-tts:main shipped a torch/torchaudio ABI mismatch
# that broke `import torchaudio` at boot; this image bypasses that.
#
# License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights).
# Personal use OK; redistribution gray-area — flagged.
@ -30,12 +27,11 @@ RUN pip install --no-cache-dir 'f5-tts>=1.0.0'
# Pre-warm the HF cache directory.
RUN mkdir -p /cache/hf /audio /voices
COPY f5_server.py /app/f5_server.py
COPY server.py /app/server.py
WORKDIR /app
EXPOSE 7860
# Skald talks to our purpose-built FastAPI server, not Gradio.
# Models load at startup (first request would otherwise pay the
# cold-start cost). uvicorn on :7860 to keep the port stable.
CMD ["uvicorn", "f5_server:app", "--host", "0.0.0.0", "--port", "7860"]
# Purpose-built FastAPI server, not Gradio. Models load at startup
# so the first request doesn't pay the cold-start cost.
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,19 +1,21 @@
# F5-TTS standalone stack on Lucy.
# F5-TTS sidecar.
#
# License posture (acknowledged 2026-05-13): code is Apache 2.0, but
# the pretrained model weights are CC-BY-NC (Emilia training data).
# Personal listen is fine; public sharing is a flagged gray area.
# Cobb's call: ship anyway.
# Code is Apache 2.0; pretrained F5TTS_v1_Base weights are CC-BY-NC
# (Emilia training data). Personal use is fine; redistribution is a
# flagged gray area.
#
# Runtime: 8GB GPU is plenty (F5 inference ~4-6GB peak).
# First run downloads ~2GB of model weights from HuggingFace into
# the hf-cache volume; subsequent runs are warm.
#
# First-run cost: ~2GB model download from HuggingFace into hf-cache,
# happens on first inference request. Subsequent runs are warm.
# Set in .env (or override):
# F5_HOST_PORT=7792
# F5_DATA=./data # ${F5_DATA}/hf-cache + voices + audio
name: f5-tts
services:
f5-tts:
image: lucy-registry:5000/f5-tts:0.3
build: .
image: f5-tts:0.3
container_name: f5-tts
restart: unless-stopped
deploy:
@ -24,20 +26,11 @@ services:
count: all
capabilities: [gpu]
ports:
- "192.168.0.5:7792:7860"
- "127.0.0.1:7792:7860"
- "${F5_HOST_PORT:-7792}:7860"
volumes:
# HF model weights cache — persists ~2GB after first download.
- /mnt/cache/appdata/f5-tts/hf-cache:/cache/hf
# Reference voice clips (lj_speech.wav, etc).
- /mnt/cache/appdata/f5-tts/voices:/voices:ro
# Rendered audio output — skald writes story narrations here.
- /mnt/cache/appdata/f5-tts/audio:/audio
- ${F5_DATA:-./data}/hf-cache:/cache/hf
- ${F5_DATA:-./data}/voices:/voices:ro
- ${F5_DATA:-./data}/audio:/audio
environment:
HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1"
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "cobb"
org.sulkta.managed-by: "compose"
org.sulkta.role: "f5-tts"

View file

@ -1,13 +1,8 @@
# Sulkta build of Kokoro-82M TTS.
# Kokoro-82M TTS. Apache 2.0 code AND weights — clean stack vs
# F5-TTS's CC-BY-NC asterisk.
#
# License: Apache 2.0 (code AND model weights). Clean stack — no
# CC-BY-NC asterisk like F5-TTS's Emilia weights. This is the
# narrator engine for sleep-quality audiobook reads; F5-TTS stays
# around for voice-cloning cases.
#
# Kokoro is small enough to run on CPU but we use the cuda base
# anyway to stay consistent with f5-tts and so it'll pick up the
# GPU when no other tenant has it.
# Kokoro runs fine on CPU but we use the cuda base to stay
# consistent with f5-tts and pick up the GPU when free.
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
ENV DEBIAN_FRONTEND=noninteractive \
@ -27,9 +22,9 @@ RUN pip install --no-cache-dir 'kokoro>=0.9.0' 'fastapi>=0.115.0' 'uvicorn>=0.32
RUN mkdir -p /cache/hf /audio
COPY kokoro_server.py /app/kokoro_server.py
COPY server.py /app/server.py
WORKDIR /app
EXPOSE 7860
CMD ["uvicorn", "kokoro_server:app", "--host", "0.0.0.0", "--port", "7860"]
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,16 +1,18 @@
# Kokoro-82M TTS stack on Lucy.
# Kokoro-82M TTS sidecar.
#
# Audiobook-quality narrator engine (Apache 2.0 code + weights —
# clean stack vs F5-TTS's CC-BY-NC asterisk). Sibling to f5-tts;
# both share /mnt/cache/appdata/f5-tts/audio so skald's audio
# route serves outputs from either engine through the same path.
# Apache 2.0 code AND model weights — clean stack for share/publish.
# Audiobook-quality narrator; F5-TTS stays around for voice-cloning.
#
# License: Apache 2.0 top to bottom. Right for share/publish.
# Set in .env (or override):
# KOKORO_HOST_PORT=7794
# KOKORO_DATA=./data # ${KOKORO_DATA}/hf-cache
# AUDIO_DIR=../f5-tts/data/audio # shared output dir across engines
name: kokoro
services:
kokoro:
image: lucy-registry:5000/kokoro:0.5
build: .
image: kokoro:0.5
container_name: kokoro
restart: unless-stopped
deploy:
@ -21,17 +23,10 @@ services:
count: all
capabilities: [gpu]
ports:
- "192.168.0.5:7794:7860"
- "127.0.0.1:7794:7860"
- "${KOKORO_HOST_PORT:-7794}:7860"
volumes:
- /mnt/cache/appdata/kokoro/hf-cache:/cache/hf
# Shared with f5-tts so skald's /audio route covers both.
- /mnt/cache/appdata/f5-tts/audio:/audio
- ${KOKORO_DATA:-./data}/hf-cache:/cache/hf
- ${AUDIO_DIR:-./data/audio}:/audio
environment:
HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1"
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "cobb"
org.sulkta.managed-by: "compose"
org.sulkta.role: "kokoro"

View file

@ -1,4 +1,4 @@
"""Kokoro-82M FastAPI server, sibling to f5_server.
"""Kokoro-82M FastAPI server, sibling to the f5-tts server.
Same /synthesize contract as F5 so skald can route between engines
just by which URL it points at. The semantic difference: Kokoro
@ -234,7 +234,7 @@ def _startup() -> None:
@app.get("/healthz")
def healthz() -> dict:
# Shape matches f5_server's so the same Rust HealthResponse
# Shape matches the f5-tts server's so the same Rust HealthResponse
# struct deserializes both: model/vocoder/loaded fields are
# required by skald-core::narrate::HealthResponse.
return {

View file

@ -1,17 +1,13 @@
# Sulkta build of Tortoise-TTS.
# Tortoise-TTS. Apache 2.0 code + weights.
#
# Voice roster (built-in, no cloning needed): angie, daniel, deniro,
# emma, freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2,
# rainbow, snakes, tim_reynolds, tom, train_atkins, train_dotrice,
# ~26 built-in voices (no cloning): angie, daniel, deniro, emma,
# freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2, rainbow,
# snakes, tim_reynolds, tom, train_atkins, train_dotrice,
# train_dreams, train_grace, train_kennard, train_lescault,
# train_mouse, weaver, william. ~26 voices baked in.
# train_mouse, weaver, william.
#
# License: Apache 2.0 (code) + Apache 2.0 (model weights). Clean
# stack for share/publish.
#
# Speed: slow. Trade for quality. Standard preset is ~10x slower
# than Kokoro; high_quality is ~30x slower. Worth it for the
# audiobook-quality bar.
# Slow: standard preset is ~10x slower than Kokoro; high_quality is
# ~30x. Trade for quality.
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
@ -37,9 +33,9 @@ RUN pip install --no-cache-dir \
RUN mkdir -p /cache/hf /cache/tortoise-models /audio
COPY tortoise_server.py /app/tortoise_server.py
COPY server.py /app/server.py
WORKDIR /app
EXPOSE 7860
CMD ["uvicorn", "tortoise_server:app", "--host", "0.0.0.0", "--port", "7860"]
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]

View file

@ -1,19 +1,22 @@
# Tortoise-TTS stack on Lucy. Audiobook-quality engine with 25+
# named voices (no cloning). Apache 2.0 top to bottom.
# Tortoise-TTS sidecar. 25+ named voices, no cloning needed.
# Apache 2.0 top to bottom.
#
# Slow: ~10x kokoro wall clock at 'standard' preset. Worth it for
# the quality bar. Cobb's call 2026-05-14: "use higgs (now tortoise)
# and we will only let it use the full gpu for runs" — translated:
# runs are batched, slow is acceptable.
# Slow: ~10x kokoro wall-clock at 'standard' preset. Worth it for the
# quality bar; runs are batched.
#
# Co-resides with kokoro on the 2070 Super since tortoise is ~5GB
# and kokoro is ~1GB (8GB total). If OOM hits during a render,
# we'll add a coordination layer to pause kokoro first.
# Co-resides with kokoro on an 8GB card (tortoise ~5GB + kokoro ~1GB).
# OOM during a render: add a coordinator that pauses kokoro first.
#
# Set in .env (or override):
# TORTOISE_HOST_PORT=7795
# TORTOISE_DATA=./data # ${TORTOISE_DATA}/{hf-cache,models}
# AUDIO_DIR=../f5-tts/data/audio # shared output dir across engines
name: tortoise
services:
tortoise:
image: lucy-registry:5000/tortoise:0.1
build: .
image: tortoise:0.1
container_name: tortoise
restart: unless-stopped
deploy:
@ -24,20 +27,12 @@ services:
count: all
capabilities: [gpu]
ports:
- "192.168.0.5:7795:7860"
- "127.0.0.1:7795:7860"
- "${TORTOISE_HOST_PORT:-7795}:7860"
volumes:
- /mnt/cache/appdata/tortoise/hf-cache:/cache/hf
- /mnt/cache/appdata/tortoise/models:/cache/tortoise-models
# Shared audio dir with f5/kokoro so skald serves all engines'
# outputs through the same /audio route.
- /mnt/cache/appdata/f5-tts/audio:/audio
- ${TORTOISE_DATA:-./data}/hf-cache:/cache/hf
- ${TORTOISE_DATA:-./data}/models:/cache/tortoise-models
- ${AUDIO_DIR:-./data/audio}:/audio
environment:
HF_HOME: /cache/hf
HF_HUB_DISABLE_TELEMETRY: "1"
TORTOISE_MODELS_DIR: /cache/tortoise-models
labels:
org.sulkta.domain: "sulkta"
org.sulkta.owner: "cobb"
org.sulkta.managed-by: "compose"
org.sulkta.role: "tortoise-tts"

View file

@ -1,4 +1,4 @@
"""Tortoise-TTS FastAPI server. Sibling to kokoro_server.
"""Tortoise-TTS FastAPI server. Sibling to the kokoro server.
Same /synthesize contract as the kokoro server so skald only has to
route by voice.source. Differences:
@ -71,7 +71,7 @@ def _get_voice(name: str) -> tuple:
return _voice_cache[name]
# ─── tag splitter (lifted from kokoro_server) ───────────────────
# ─── tag splitter (lifted from the kokoro server) ───────────────
class Node:
@ -209,7 +209,7 @@ def _startup() -> None:
@app.get("/healthz")
def healthz() -> dict:
# Shape matches f5_server/kokoro_server so skald's HealthResponse
# Shape matches the f5-tts + kokoro servers so skald's HealthResponse
# struct deserializes all three.
return {
"ok": True,

View file

@ -1,14 +1,9 @@
#!/usr/bin/env bash
# Skald container entrypoint.
#
# Boots the embedded postgres via the pgvector image's own
# docker-entrypoint, waits for it to accept connections, then execs
# `skald` in the foreground. Tini is PID 1 (so it can reap zombies +
# forward signals); we are PID 2; postgres becomes our child.
#
# This is explicitly "DB in the same container, for now" — when we
# split the DB out (see project notes), the entrypoint reduces to
# `exec /usr/local/bin/skald "$@"` and the pg startup goes away.
# `skald` in the foreground. Tini is PID 1; postgres becomes our
# child. When the DB is extracted to its own container, this reduces
# to `exec /usr/local/bin/skald "$@"`.
set -eo pipefail

View file

@ -8,24 +8,23 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeConfig {
/// Base URL of the clawdforge HTTP service. Defaults to
/// `http://clawdforge.sulkta.lan:8800` in production; override
/// for tests via env.
/// Base URL of the clawdforge HTTP service. The calling binary
/// resolves this from `CLAWDFORGE_URL`.
pub base_url: String,
/// App-level bearer token. Resolved by the binary from
/// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed.
pub app_token: String,
/// Model alias passed to clawdforge → `claude -p --model`. Skald
/// is opinionated: always opus max effort. Default reflects that.
/// Model alias passed to clawdforge → `claude -p --model`.
/// Defaults to opus.
pub model: String,
}
impl Default for ForgeConfig {
fn default() -> Self {
Self {
base_url: "http://clawdforge.sulkta.lan:8800".into(),
base_url: "http://localhost:8800".into(),
app_token: String::new(),
model: "opus".into(),
}

View file

@ -27,7 +27,7 @@ use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct F5Config {
/// e.g. http://192.168.0.5:7792
/// e.g. http://localhost:7792
pub base_url: String,
/// Inference subprocess timeout. Long-form chapters (3000 words)
/// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge.

View file

@ -30,8 +30,8 @@ use uuid::Uuid;
about = "Long-form story-writer. Database is the source of truth; the writer is the tooling."
)]
struct Cli {
/// Postgres connection URL. Defaults to `postgresql://skald:skald@localhost:5432/skald`.
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://skald:skald@localhost:5432/skald")]
/// Postgres connection URL. Read from `DATABASE_URL` if unset.
#[arg(long, env = "DATABASE_URL")]
database_url: String,
#[command(subcommand)]

View file

@ -458,16 +458,15 @@ async fn cleanup_superseded_renders(pool: &PgPool, chapter_id: Uuid, current_run
/// kokoro_* → KOKORO_URL
/// tortoise_* → TORTOISE_URL
/// anything else (lj_speech etc.) → F5_TTS_URL
/// Each env var has a LAN-default for Lucy.
fn engine_url_for(source: &str) -> anyhow::Result<String> {
if source.starts_with("kokoro") {
Ok(std::env::var("KOKORO_URL")
.unwrap_or_else(|_| "http://192.168.0.5:7794".into()))
let (env_var, engine) = if source.starts_with("kokoro") {
("KOKORO_URL", "kokoro")
} else if source.starts_with("tortoise") {
Ok(std::env::var("TORTOISE_URL")
.unwrap_or_else(|_| "http://192.168.0.5:7795".into()))
("TORTOISE_URL", "tortoise")
} else {
Ok(std::env::var("F5_TTS_URL")
.unwrap_or_else(|_| "http://192.168.0.5:7792".into()))
}
("F5_TTS_URL", "f5-tts")
};
std::env::var(env_var).map_err(|_| {
anyhow::anyhow!("{env_var} not set — point at the {engine} sidecar")
})
}

View file

@ -201,8 +201,12 @@ async fn new_story_create(
// parent to compare against. So you get a single first-chapter
// gen + cleanup pass and status flows to 'complete'.
if form.fire == "now" {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
let Ok(database_url) = std::env::var("DATABASE_URL") else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background gen".into(),
));
};
let author_owned = if author_slug.is_empty() {
None
} else {
@ -337,7 +341,12 @@ async fn continue_create(
// If user clicked "fire now," spawn a background gen task.
// Otherwise the sequel sits in seed state until CLI fires it.
if form.fire == "now" {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
let Ok(database_url) = std::env::var("DATABASE_URL") else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background gen".into(),
));
};
let author_owned = if author_slug.is_empty() { None } else { Some(author_slug.to_string()) };
let direction_owned = direction.clone();
let chapters = parse_chapters(&form.chapters);
@ -512,8 +521,12 @@ async fn chapter_narrate_fire(
let chapter_id =
chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?;
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
let Ok(database_url) = std::env::var("DATABASE_URL") else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"DATABASE_URL not set — cannot spawn background narrate".into(),
));
};
tokio::spawn(async move {
if let Err(e) = crate::narrate::run(&database_url, chapter_id, None, 1.0).await {
tracing::error!(chapter_id = %chapter_id, error = %e, "background narrate failed");
@ -671,7 +684,7 @@ fn render_shell(stories: &[StoryRow], current: Option<Uuid>, main: Markup) -> Ma
}
footer.footbar {
span { "skald · v0.3 · written down · "
a href="http://192.168.0.5:3001/cobb/skald" { "cobb/skald" }
a href="https://git.sulkta.com/cobb/skald" { "cobb/skald" }
}
}
}