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.
Adds `skald dedup --story <id>`: reads a story's most recent
prose-audit findings and walks every chapter, handing the author
the chapter prose + the findings with instructions to rephrase
ONLY the flagged repetitions (each recurrence made distinct) and
fix flagged continuity errors — everything else stays verbatim.
A surgical dedup, not a rewrite. Overwrites body_md, clears
body_md_tts so the chapter is re-prepped before narration. High
effort (prose-craft). Migration 0011 adds the 'dedup' run kind.
Completes the QC loop: audit (find) -> dedup (fix) -> re-audit.
A max-effort pass over a whole-book input (quarter-million chars)
runs an impractical hour-plus and was timing out. Finding repeated
passages is comparison work, not deep reasoning — high effort does
it well in minutes at a fraction of the cost.
The prose audit reads a whole story at max effort and genuinely
runs past 1800s on a 50k-word book — it was timing out. Bump the
audit pass server-side timeout to 3600s and the shared reqwest
client ceiling to 7200s (matching clawdforge's new cap). Other
passes keep their own shorter timeout_secs.
Adds `skald audit --story <id>`: a whole-story QC pass that reads
every chapter end to end and flags repetition, template tics,
self-restatement and continuity drift — the gate before a story
goes to narration, where repetition a silent reader skims is
glaring read aloud. Runs at max effort (real reasoning work,
worth the spend); findings land in audit_findings and print.
Also hardens the gen + cleanup directives to hunt repetition at
the source: re-phrase recurring motifs fresh, no stacked template
anaphora, dialogue echoed verbatim at most once.
Migration 0010: 'prose_audit' generation_runs.kind, 'repetition'
audit_findings.area.
Two fixes:
- narrate_prep in single-voice mode (empty character roster) was
still handed the multi-voice directive, so the model invented
[voice:<slug>] tags from character names in the prose. The
narrate path neutralised them by falling back to the narrator,
but it was log spam and a leak of intent. Single-voice now gets
directive + house-system variants that forbid voice tags
outright, and the user-prompt task line matches.
- Every narrate run wrote a fresh ~80MB WAV and never reclaimed
the previous one, so re-renders piled up stale files. A
successful render now deletes the WAVs of prior renders of the
same chapter and nulls their output_path. Render history rows
are kept; only the dead file pointer is cleared. Best-effort —
cleanup failure never fails the render.
gen, cleanup, narrate_prep and rewrite drop from max to high
effort. Audit keeps max — it is the one pass doing real reasoning
(canon drift, timeline gaps, retcons) rather than prose-craft, so
it is worth the frontier spend. Prose-craft is "good enough" at
high. This also keeps the all-Opus skald pattern under the
$200/month claude -p cap landing next month.
New Forge::rewrite + PassKind::Rewrite. An author re-authors
existing chapter prose entirely in their voice — sentence rhythm,
word choice, paragraph shape all become theirs — while canon
(names, dates, places, events, order, technical facts) is preserved
exactly. Not editing; re-authoring. SystemMode::Replace, max effort.
skald rewrite --chapter <uuid> [--author slug] overwrites body_md
with the rewritten version. The pre-rewrite prose is stashed in the
new chapters.body_md_original column on first rewrite (migration
0008, idempotent) so the original is never lost. body_md_tts is
cleared — it was annotated against the old prose and must be
regenerated by a fresh prepare-narration.
prepare-narration gains --single-voice: skips the character speaker
roster so no [voice:X] dialogue tags are inserted, only beat
markers. Right for one-voice narration.
Migration 0008 also extends generation_runs.kind to allow 'rewrite'.
Schema: characters.voice_id + characters.slug (migration 0007).
voice_id is FK to voices(id); slug is the stable lowercase token
the narrate_prep pass uses inside [voice:slug]...[/voice].
Forge::narrate_prep takes &[CharacterSpeaker]. System prompt
expanded to instruct the author to wrap dialogue lines in voice
tags based on a roster supplied in the user prompt (slug + name +
short hint from key_facts). Unattributed dialogue stays unwrapped
and inherits the narrator voice.
skald narrate substitutes [voice:<character-slug>] →
[voice:<kokoro-voice-name>] right before sending to Kokoro, using
characters.voice_id JOIN voices.reference_path as the map. Slugs
with no voice or no character row fall back to the narrator voice
defensively (logged as warn).
kokoro_server.py v0.4: splitter recognises [voice:X]...[/voice]
blocks at the paragraph level. Each text node carries an optional
voice attribution; renderer feeds it to Kokoro per-segment. Outside
voice blocks the request's default voice is used. voices_used is
reported back so callers can verify multi-voice actually ran.
Only kokoro-routed renders pre-process voice tags; F5 paths leave
the tags in place (F5 multi-voice not implemented). Defensive
fallback: orphan/unclosed [/voice] markers are silently absorbed
rather than failing the render.
Two new things working together:
1. Migration 0005 adds chapters.body_md_tts (nullable). Narrate path
prefers it over body_md when present — that's the annotated-for-
audiobook variant. Falls back to body_md if not set.
2. New Forge::narrate_prep pass: author (or House) annotates prose
with [breath] / [pause:Xs] / [scene] beat markers AND occasional
humanizing narrator stumbles (em-dash repetition, self-correction,
hesitation — sparingly, 1-3 per chapter). Apart from stumbles, the
prose is verbatim. Author voice threads through.
3. New CLI: 'skald prepare-narration --chapter <uuid> [--author slug]
[--overwrite]'. Records as generation_runs row kind=narrate_prep.
4. skald narrate now routes by voice.source — kokoro_* voices hit
KOKORO_URL (Apache 2.0 stack, audiobook-tuned with the v0.2 render-
and-stitch server), everything else hits F5_TTS_URL (voice-cloning
path). Voice DB row carries source as the dispatch key.
Why no new tag for narrator stumbles: em-dash repetition and self-
correction are just prose patterns Kokoro reads correctly because of
its punctuation cues. No new server-side machinery.
Caught on the 2026-05-13 Coast-Down 10-chapter Orson run: the LLM
labeled two chapters 'Chapter 1' instead of 9 and 10, and
ON CONFLICT (story_id, n) DO UPDATE silently overwrote them.
8 visible chapters from 10 successful gen+cleanup passes; 27k
words of work, ~6k buried. The audit caught the symptom but the
data damage was already done.
Fix:
- continue_story::run computes next_n from MAX(chapters.n) before
the batch loop; each iteration's authoritative n is next_n,
incremented after success.
- forge::generate + cleanup take chapter_n: Option<i32>. The gen
prompt is now 'Write Chapter N. Begin with: ## Chapter N — ...'
instead of the vague 'Write the next chapter.'
- We still parse_chapter() the LLM output but only to extract the
title; if the LLM-returned n disagrees with ours, we log a warn
and use the authoritative N at INSERT time.
The (story_id, n) unique constraint stays — it's now a defensive
catch for skald bugs, not the LLM's free-spirited numbering.
forge.rs threads Effort::Max on gen + cleanup. Audit + summarize stay
default — they're structured-output / tool-shaped tasks where extended
thinking doesn't help. Bumps subprocess timeout from 600s to 1800s so
max-effort prose-craft has the wall clock it needs.
continue_story::run takes a chapter_count param; loops gen+cleanup per
chapter with each iteration's just-written prose appended to context.
Audit fires once at end against the combined batch vs parent canon.
Cap is 20 (~5h wall clock, ~$600 at max effort — beyond that is
operationally absurd).
CLI: 'skald continue --chapters N'. Web: numeric field on both new-
story and continue forms, 1..=20, defaults to 1.
Vendored clawdforge SDK refreshed for the Effort enum.
Three new pieces lock the gen pipeline:
1. seeds/authors/orson-black.md — Orson Black's soul. ~2k words,
strict-headings format per cobb's decision. Voice / Worldview /
Specifics / Pet peeves / Sense of humor / Biography (fully
fictional — synthetic literary persona; coal town Durham 1948,
father died of pneumoconiosis at 14, two winters as welder's
apprentice on the Tyne, etc) / Anchor authors (Orwell, McCarthy,
DeLillo, Judt, Modiano) / Do / Don't.
2. skald-core::forge — author-aware. Forge::generate, ::cleanup,
and ::audit now take Option<&AuthorWithRevision>. When Some:
scaffold + soul composed and passed as SystemMode::Replace —
the model BECOMES the author. When None: house neutral scaffold,
Append mode, claude defaults stay. audit() always neutral,
regardless of author. Real prompt templates ship for gen +
cleanup (the prose-craft IP we were deferring) — scaffold has
{{display_name}}, {{pass_directive}}, {{soul}} substitutions,
plus separate gen/cleanup directive blocks.
3. skald continue --story <uuid> [--author SLUG] [--direction
STR] [--target-words N] [--recent N] [--skip-audit] — the
pipeline CLI:
load story → resolve author (--flag wins, else story.author_id,
else None) → pin author/revision onto story → assemble
ContinuationContext from parent chain → run gen pass →
parse heading + insert chapter + passages → run cleanup pass
on the draft → replace chapter body + passages → run audit
pass (parent prose vs new chapter vs bible) → parse JSON
findings into audit_findings table → status flow
seed→generating→cleaning→auditing→complete.
Plus skald authors seed --slug --display-name --tagline --file
--note for loading souls from disk into the DB.
End-to-end testable: seed Orson Black, create a sequel stub via
web or SQL, fire 'skald continue' against it. Coast-Down chapter 8
in Orson's voice is the smoke test.
Migration 0004 — authors + author_revisions + stories.author_id +
stories.author_revision_id + stories.cross_story_memory +
author_corpus. Soul versioning built in from day one per cobb's
locked decisions:
- authors.id immutable identity (slug + display_name + tagline + model)
- author_revisions tracks each soul revision with n monotonic
- Partial unique index 'idx_author_revisions_current' enforces
exactly one is_current=true per author
- stories.author_revision_id pins to the exact soul used at gen
time (so 'this was the Orson Black active when chapter 8 was
written' is always recoverable)
- author_corpus tracks 'authored' + 'read' relationships for the
v0.3 cross-story memory toggle
skald-core::authors module — CRUD: get_by_slug,
get_with_current_revision, get_current_revision, get_revision,
create_or_get (idempotent), add_revision (transactional, demotes
prior is_current=true), assign_to_story (also touches
author_corpus).
Web v0.1 forms (the second feedback bucket — 'no way to make new
stories', 'no options for sequels'): handlers + form panels +
POST routes for /stories/new and /stories/:id/continue. Both
create a story stub with status='seed'; actual generation will be
fired by 'skald continue' (next commit) walking seed rows.
Norse visual revamp + mobile collapse deferred — vetting full gen
is the priority per cobb's 'green light for v0.3'. Coming back to
the aesthetic after the pipeline works end-to-end against a real
Orson Black-authored Chapter 8 of Coast-Down.
The old parent_coverage was raw-prose / parent-words — a signal of
'how much actual prose opus is reading.' But the more actionable
signal is 'is every chapter represented somehow' which sits at 1.0
for any parent with summaries (or placeholders) for older chapters.
Add chapter_coverage = 1.0 when every chapter has either a summary
or full-recent-prose row in the context. Keep prose_coverage as
the precise raw-words metric for ops that care about token budget.
Deprecate parent_coverage with a one-release shim (renames to
prose_coverage).
show_context CLI prints both percentages.
skald summarize --story <uuid> walks every chapter without an
existing summary, calls Forge::summarize() (clawdforge → opus →
~250 words of plot/character/setting/threads), and inserts the
result into chapter_summaries.
Side effects:
- generation_runs row per chapter (kind='summary', status flow
running → succeeded|failed). Errors update the row + bail; happy
path closes it with ended_at + tokens.
- ON CONFLICT (chapter_id) means re-running with --force replaces
the previous summary cleanly.
CLI:
skald summarize --story <uuid> # only-missing
skald summarize --story <uuid> --force # re-summarize all
Reads from env (loaded by skald.env in the container):
CLAWDFORGE_URL — base URL of clawdforge HTTP service
CLAWDFORGE_TOKEN — app-level bearer (per-app, not the admin token)
SKALD_MODEL — defaults to 'opus'
This is the first subcommand that actually exercises the forge.
Unlocks ContinuationContext::assemble's coverage metric (was stuck
at 24%% on Coast-Down because the 5 placeholder summaries don't
actually carry the prose). After running summarize against
Coast-Down: coverage should jump to ~100%% and the context blob
for any sequel becomes fully canon-faithful without dragging the
full ~21k words of earlier-chapter prose along.
Forge prompt template for summarize ships REAL (not stubbed) — it's
the simplest pass and has a well-defined shape. The gen/cleanup/
audit prompts remain stubs pending the deeper prose-craft session.
skald-core::context is the bridge between 'rows in postgres' and
'prompt-ready markdown blob.' ContinuationContext::assemble(pool,
parent_story_id, recent_n) pulls:
- parent story meta (title, series, total word count)
- characters split real / fictional
- canon_facts grouped by category
- chapter summaries for everything older than the recent window
- FULL prose for the last recent_n chapters
render_markdown() formats it with the most-condensed data first
(characters, canon) and the richest detail last (recent chapter
prose). Opus reads it linearly so by the time it's writing the new
chapter, the previous chapter's prose is freshest in its context
window.
The 'continuation reads ≥85% of parent' rule lands here via
parent_coverage() which counts recent prose + summaries-as-proxy
(250 words / summary) against parent word_count. The web UI / CLI
can warn before firing a gen pass if coverage is below threshold.
New CLI subcommand:
skald show-context --story <uuid> --recent <N>
Assembles + prints the blob to stdout (eprintln'd stats summary
goes to stderr). No LLM call — pre-flight inspection so we see
what would be sent before paying for it. Useful for prompt-eng
work in the next session.
Module structure now:
skald-core/
config.rs ForgeConfig
context.rs ContinuationContext (new)
db.rs connect_and_migrate
forge.rs Forge — three-pass orchestration
ingest.rs markdown parser
models.rs row types
lib.rs MIGRATOR + module exports
skald/
main.rs clap CLI
serve.rs axum + /health + migrations
import.rs skald import-markdown
show_context.rs skald show-context (new)
The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.
What landed:
- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
clawdforge::Client; generate() / cleanup() / audit() each build a
RunRequest with the right system prompt + model alias (always opus),
call client.run(), return a PassOutput.
Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
chain. Whisper-large-v3 STT does text-to-text verification on every
render; an optional Gemini Flash audio pass catches subjective issues
(prosody, tone) Whisper can't see. Reroll loop on crit findings.
What's still stubbed:
- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.
Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
Skald is a generic story-writer. The database is the product; the
binary is the tooling. Everything story-specific lives in rows, not
in code. cwho's monorepo + binary-per-role pattern transplanted to
this domain.
What this commit ships:
- Cargo workspace (resolver=3, edition 2024): skald-core (lib) +
skald (bin)
- Migration 0001: stories, characters, canon_facts, chapters,
chapter_summaries, passages (vector(1536)), generation_runs,
audit_findings, tags. pgvector + pg_trgm extensions. ivfflat
index deferred until we have data (post-import the first ~1k
passages and add the index).
- skald-core::ingest — markdown parser for the cwho/coast-down shape:
'# Title' → '## Chapter N — date' headings → '# Continuity Bible'
section with character roster (real + fictional sub-sections) +
setting / mystery / historical / liberty / hook sub-sections.
Decomposed into structured rows; original bullet body preserved
in key_facts/body fields for fidelity. 6 unit tests cover the
shape.
- skald-core::db — Postgres connection pool + migration runner.
- skald-core::models — row types via sqlx::FromRow.
- skald binary — clap CLI: 'serve' (http + migrations) and
'import-markdown' (one-shot ingest).
- Dockerfile — multi-stage: rust:1.95-bookworm builder, pgvector/
pgvector:pg17 runtime, tini under PID 1, custom entrypoint.sh
that boots embedded postgres then execs skald serve.
- compose.yml — singleton container, postgres data in volume,
story corpus mounted read-only at /seed.
Decisions locked 2026-05-13:
1. DB in same container 'till we have a real working tool' (cobb)
2. postgres+pgvector (NOT sqlite) — keeps semantic-search story
3. Network-not-socket connection (postgresql://localhost:5432) from
day one so future split is config-only, not code-rewrite
Not yet wired:
- Web UI
- clawdforge calls (gen → cleanup → canon-audit pipeline)
- Embedding pass
- TTS sidecar