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 Sulkta'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.
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.
The Rust SDK already existed at Sulkta-OSS/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-OSS/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().