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:
parent
4402c53979
commit
346cea515d
21 changed files with 325 additions and 474 deletions
|
|
@ -7,7 +7,7 @@ version = "0.0.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Cobb (Jacob Hayes)"]
|
authors = ["Cobb (Jacob Hayes)"]
|
||||||
repository = "http://192.168.0.5:3001/cobb/skald"
|
repository = "https://git.sulkta.com/cobb/skald"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
|
||||||
26
Dockerfile
26
Dockerfile
|
|
@ -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.
|
# Build context is the workspace root: `docker build -t skald:latest .`
|
||||||
# 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 .
|
|
||||||
|
|
||||||
# ─── builder ──────────────────────────────────────────────────────
|
|
||||||
FROM rust:1.95-bookworm AS builder
|
FROM rust:1.95-bookworm AS builder
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Cache the dependency graph: copy manifests first, fetch + build
|
# Dependency-cache layer: copy manifests + vendored path-dep first,
|
||||||
# stubs, THEN drop in real sources. The vendored clawdforge SDK
|
# build stubs, then drop in real sources. clawdforge is a path dep
|
||||||
# needs its own manifest + source available during the cache layer
|
# resolved at workspace load time.
|
||||||
# (path dep — Cargo resolves it at workspace load time, not at
|
|
||||||
# crate-compile time).
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY skald-core/Cargo.toml skald-core/Cargo.toml
|
COPY skald-core/Cargo.toml skald-core/Cargo.toml
|
||||||
COPY skald/Cargo.toml skald/Cargo.toml
|
COPY skald/Cargo.toml skald/Cargo.toml
|
||||||
|
|
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
130
README.md
|
|
@ -1,95 +1,79 @@
|
||||||
# skald
|
# skald
|
||||||
|
|
||||||
Long-form story-writer with canon-keeping, sequel continuity, and
|
Long-form story-writer with canon-keeping, sequel continuity, and
|
||||||
(future) self-hosted audiobook narration. Database is the source of
|
self-hosted audiobook narration. The database is the source of truth;
|
||||||
truth — the writer is the tooling.
|
the binary is the tooling.
|
||||||
|
|
||||||
Named for the Old Norse poets who composed and memorized kings'
|
Named for the Old Norse poets who composed and memorized kings' sagas
|
||||||
sagas across generations.
|
across generations.
|
||||||
|
|
||||||
## Status: v0.1 — scaffold
|
## What's wired
|
||||||
|
|
||||||
What's wired:
|
|
||||||
|
|
||||||
- Rust workspace (`skald-core` + `skald`)
|
- Rust workspace (`skald-core` + `skald`)
|
||||||
- Postgres schema for stories, characters, canon facts, chapters,
|
- Postgres schema for stories, characters, canon facts, chapters,
|
||||||
passages, generation runs, audit findings, tags
|
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)
|
- `skald import-markdown` ingests a story file (chapters + bible)
|
||||||
into the schema
|
- `skald serve` exposes `/health` + the web inspector and runs
|
||||||
- `skald serve` exposes `/health` and runs migrations on boot
|
migrations on boot
|
||||||
- Single-container deploy: postgres + skald in one image
|
- `skald continue` runs gen → cleanup → audit per chapter, with
|
||||||
|
multi-chapter batching (cap 20)
|
||||||
Wired (this commit):
|
- `skald rewrite` re-authors a chapter in a named author's voice
|
||||||
|
- `skald audit` runs whole-story prose-quality audit; `skald dedup`
|
||||||
- clawdforge Rust SDK vendored at `vendor/clawdforge/` (upstream:
|
is the surgical fix half of the loop
|
||||||
`Sulkta-Coop/clawdforge` `clients/rust/`)
|
- `skald prepare-narration` annotates a chapter with `[breath]` /
|
||||||
- `skald-core::forge` — three-pass orchestration shell (gen / cleanup /
|
`[pause:Xs]` / `[scene]` beats and per-character `[voice:...]`
|
||||||
audit). Prompts are TODO stubs; pipeline plumbing is in place.
|
tags
|
||||||
|
- `skald narrate` renders a chapter to audio via one of three TTS
|
||||||
Not yet wired:
|
engines (F5-TTS, Kokoro-82M, Tortoise-TTS) — see `engines/`
|
||||||
|
- Named-author "soul" personas via `skald authors seed`; author
|
||||||
- Web UI (the inbox + browse + queue surface)
|
voice replaces the model's base system prompt for gen/cleanup/
|
||||||
- Prompt templates for the three passes (heavy prompt-engineering
|
rewrite/dedup/narrate_prep
|
||||||
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, ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schema (cheat sheet)
|
## Schema (cheat sheet)
|
||||||
|
|
||||||
```
|
```
|
||||||
stories → meta + status + parent/root for series
|
stories meta + status + parent/root for series
|
||||||
characters → real or fictional, story-scoped
|
authors persona identity (slug, display_name, model)
|
||||||
canon_facts → setting, mystery, theme, rule, historical_anchor, hook
|
author_revisions versioned souls; one current per author
|
||||||
chapters → full prose body
|
characters real or fictional, story-scoped, voice-mappable
|
||||||
chapter_summaries → short summaries for cheap context loading
|
canon_facts setting, mystery, theme, rule, historical_anchor, hook
|
||||||
passages → paragraph-level + embedding vector(1536)
|
chapters full prose body + optional body_md_tts annotation
|
||||||
generation_runs → every LLM call logged
|
chapter_summaries short summaries for cheap context loading
|
||||||
audit_findings → canon audit output (severity + area)
|
passages paragraph-level + embedding vector(1536)
|
||||||
tags → arbitrary labels
|
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
|
||||||
|
|
||||||
```
|
```sh
|
||||||
┌─────────────────────────────────┐
|
docker compose up -d
|
||||||
│ skald container │
|
docker exec skald skald import-markdown --path /seed/<story>.md \
|
||||||
│ ┌───────────┐ ┌────────────┐ │
|
--title "<title>"
|
||||||
│ │ postgres │ │ skald-rust │ │
|
curl http://localhost:7780/health
|
||||||
│ │ pgvector │←─│ axum + cli │ │
|
|
||||||
│ │ localhost │ │ :7780 │ │
|
|
||||||
│ └───────────┘ └─────┬──────┘ │
|
|
||||||
└─────────────────────────┼────────┘
|
|
||||||
│ HTTP (future)
|
|
||||||
↓
|
|
||||||
┌──────────┐
|
|
||||||
│clawdforge│
|
|
||||||
└─────┬────┘
|
|
||||||
↓
|
|
||||||
opus calls
|
|
||||||
```
|
```
|
||||||
|
|
||||||
v1.0+: extract postgres to its own container on db-net. skald
|
The compose file expects `POSTGRES_PASSWORD` and (optionally)
|
||||||
becomes pure stateless rust, connects via `DATABASE_URL`. Migration
|
`CLAWDFORGE_URL` + `CLAWDFORGE_TOKEN` in `.env`. Story markdown
|
||||||
is a connection-string change + a network move; the binary doesn't
|
goes into `./seed/`; postgres data persists in `./pgdata/`.
|
||||||
care where the DB lives.
|
|
||||||
|
## 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
|
## License
|
||||||
|
|
||||||
MIT.
|
MIT — see `LICENSE`.
|
||||||
|
|
|
||||||
51
compose.yml
51
compose.yml
|
|
@ -1,37 +1,42 @@
|
||||||
# Standalone compose stack for skald v0.1. Postgres lives in the
|
# Standalone compose stack for skald. Postgres lives inside the
|
||||||
# same container — single deployable unit "till we have a real
|
# skald container — single deployable unit until the tool stabilises.
|
||||||
# working tool" (cobb's call, 2026-05-13).
|
|
||||||
#
|
#
|
||||||
# To deploy on Lucy:
|
# Set in .env (or the environment):
|
||||||
# sudo mkdir -p /mnt/cache/appdata/skald/{pgdata,seed}
|
# POSTGRES_PASSWORD=... # required
|
||||||
# sudo cp <story>.md /mnt/cache/appdata/skald/seed/
|
# CLAWDFORGE_URL=http://...:8800 # if running gen / cleanup / audit
|
||||||
# sudo cp skald.env /mnt/cache/appdata/secrets/skald.env # POSTGRES_PASSWORD=...
|
# CLAWDFORGE_TOKEN=cf_...
|
||||||
# docker compose -p skald up -d
|
# SKALD_DATA=./pgdata # optional override; defaults to ./pgdata
|
||||||
|
# SKALD_SEED=./seed # optional override; defaults to ./seed
|
||||||
#
|
#
|
||||||
# To import the first story:
|
# To import the first story:
|
||||||
# docker exec skald skald import-markdown \
|
# docker compose exec skald skald import-markdown \
|
||||||
# --path /seed/<story>.md \
|
# --path /seed/<story>.md \
|
||||||
# --title "<title>"
|
# --title "<title>"
|
||||||
|
name: skald
|
||||||
|
|
||||||
services:
|
services:
|
||||||
skald:
|
skald:
|
||||||
image: lucy-registry:5000/skald:latest
|
build: .
|
||||||
|
image: skald:latest
|
||||||
container_name: skald
|
container_name: skald
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "7780:7780"
|
- "7780:7780"
|
||||||
env_file:
|
environment:
|
||||||
- /mnt/cache/appdata/secrets/skald.env
|
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:
|
volumes:
|
||||||
# Postgres data — persist across container recreates.
|
# Postgres data — persist across container recreates.
|
||||||
- /mnt/cache/appdata/skald/pgdata:/var/lib/postgresql/data
|
- ${SKALD_DATA:-./pgdata}:/var/lib/postgresql/data
|
||||||
# Markdown corpus to import via `docker exec skald skald import-markdown`.
|
# Markdown corpus to import via `skald import-markdown`.
|
||||||
- /mnt/cache/appdata/skald/seed:/seed:ro
|
- ${SKALD_SEED:-./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"
|
|
||||||
|
|
|
||||||
308
docs/authors.md
308
docs/authors.md
|
|
@ -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
|
## Schema
|
||||||
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:
|
|
||||||
|
|
||||||
> "George Orwell but more rebel and pissed off — slightly comes out
|
Authors live in the database, not on disk. The soul is a markdown
|
||||||
> in stories."
|
blob in `author_revisions.soul`. Authors are immutable; new soul
|
||||||
|
revisions create a new `author_revisions` row marked `is_current`
|
||||||
Authors have **memory across their corpus.** Per-story opt-in toggle
|
and the previous one is demoted.
|
||||||
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
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE authors (
|
CREATE TABLE authors (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY,
|
||||||
slug TEXT NOT NULL UNIQUE, -- "orson-black"
|
slug TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL, -- "Orson Black"
|
display_name TEXT NOT NULL,
|
||||||
persona_tagline TEXT, -- "Orwell but more rebel + pissed off"
|
persona_tagline TEXT,
|
||||||
soul TEXT NOT NULL, -- SOUL.md-style markdown blob
|
|
||||||
model TEXT NOT NULL DEFAULT 'opus',
|
model TEXT NOT NULL DEFAULT 'opus',
|
||||||
-- Optional per-author system-prompt scaffold. The author's soul
|
is_synthetic BOOLEAN NOT NULL DEFAULT true,
|
||||||
-- 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 '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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
|
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;
|
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 (
|
CREATE TABLE author_corpus (
|
||||||
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
|
||||||
story_id UUID NOT NULL REFERENCES stories(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
|
- **gen** — full author voice. They are writing.
|
||||||
recalibrated for authorial identity. Suggested sections (free-form
|
- **cleanup** — full author voice. Polishing their own draft, not a
|
||||||
prose, not strict schema):
|
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
|
```markdown
|
||||||
# Author: {{display_name}}
|
# Author: {{display_name}}
|
||||||
|
|
@ -101,8 +87,8 @@ _Tagline: {{persona_tagline}}_
|
||||||
## Voice
|
## Voice
|
||||||
|
|
||||||
Sentence rhythm. Vocabulary register. Paragraph length tendencies.
|
Sentence rhythm. Vocabulary register. Paragraph length tendencies.
|
||||||
Dialogue density. Punctuation habits (dashes, semicolons,
|
Dialogue density. Punctuation habits (dashes, semicolons, sentence
|
||||||
sentence fragments). What your prose SOUNDS like read aloud.
|
fragments). What your prose SOUNDS like read aloud.
|
||||||
|
|
||||||
## Worldview
|
## Worldview
|
||||||
|
|
||||||
|
|
@ -112,15 +98,13 @@ in the implicit moral architecture of any scene.
|
||||||
|
|
||||||
## Specifics over abstractions
|
## Specifics over abstractions
|
||||||
|
|
||||||
What concrete details you reach for. (Orwell: the cold tap, the
|
The concrete details you reach for. The five senses you favor.
|
||||||
miners' shoes, the gin.) The 5 senses you favor. Smells? Cold?
|
Smells? Cold? Texture of cloth? Sound of machines?
|
||||||
Texture of cloth? Sound of machines?
|
|
||||||
|
|
||||||
## Pet peeves
|
## Pet peeves
|
||||||
|
|
||||||
Words you refuse to write. Tropes you avoid. Sentimentalities you
|
Words you refuse to write. Tropes you avoid. Sentimentalities you
|
||||||
gut. (Orson: "soul-stirring," "ineffable," "tapestry of
|
gut.
|
||||||
emotions." No prose-poetry trim.)
|
|
||||||
|
|
||||||
## Sense of humor
|
## Sense of humor
|
||||||
|
|
||||||
|
|
@ -129,159 +113,51 @@ does humor live — end of a sentence, mid-clause, or never?
|
||||||
|
|
||||||
## Biography (real or invented)
|
## Biography (real or invented)
|
||||||
|
|
||||||
A few biographical facts that EXPLAIN the voice. (Not a CV — the
|
A few biographical facts that EXPLAIN the voice — the formative
|
||||||
formative cuts. Orson grew up in a coal town. His father died of
|
cuts, not a CV.
|
||||||
black lung. He read Trotsky at seventeen. He spent two winters
|
|
||||||
working in a Tyne shipyard.)
|
|
||||||
|
|
||||||
## Anchor authors
|
## Anchor authors
|
||||||
|
|
||||||
Living or dead authors the prose draws from. Useful for the model
|
Living or dead authors the prose draws from. Useful for the model
|
||||||
to triangulate voice. (Orson: Orwell, Cormac McCarthy, Don
|
to triangulate voice.
|
||||||
DeLillo, Tony Judt's nonfiction.)
|
|
||||||
|
|
||||||
## Do
|
## Do / Don't
|
||||||
|
|
||||||
- Specifics that puncture sentiment.
|
Concrete prose moves to reach for and to avoid.
|
||||||
- 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").
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
```
|
- declares the model IS the author, not playing one
|
||||||
You are {{display_name}}, an author. Your voice and worldview are
|
- pins canon as non-negotiable (names, dates, established events)
|
||||||
described in the soul below. Honor them in every sentence — not as
|
- forbids preamble, meta-commentary, fourth-wall breaks
|
||||||
performance but as substrate. The story's canon (characters,
|
- substitutes the per-pass directive (`GEN_DIRECTIVE`,
|
||||||
setting, established facts) is non-negotiable; your voice lives
|
`CLEANUP_DIRECTIVE`, `REWRITE_DIRECTIVE`, `DEDUP_DIRECTIVE`,
|
||||||
WITHIN those constraints.
|
`NARRATE_PREP_DIRECTIVE`)
|
||||||
|
|
||||||
If your worldview makes the user-prompted plot uncomfortable,
|
A per-author `system_template` overrides the default scaffold when
|
||||||
write through that discomfort — that's where good prose lives.
|
set; otherwise the default is used.
|
||||||
Never break the fourth wall to comment on the task. No meta-prose.
|
|
||||||
|
|
||||||
---
|
## 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
|
## Seeding an author
|
||||||
matters. The voice matters. Both at once.
|
|
||||||
|
```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
|
This creates the author + first revision (or adds a new revision to
|
||||||
|
an existing author, which becomes current).
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,18 @@ generalise. Examples:
|
||||||
all three at once), preset choice ergonomics, character→tortoise-
|
all three at once), preset choice ergonomics, character→tortoise-
|
||||||
voice seed assignments.
|
voice seed assignments.
|
||||||
|
|
||||||
When deploying an engine to Lucy, the build dir at
|
To deploy a tuned engine, check out the engine's branch in the build
|
||||||
`/mnt/cache/appdata/<engine>/build/` tracks the engine's branch:
|
dir and `docker compose up -d --build`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/cache/appdata/kokoro/build
|
|
||||||
git fetch && git checkout engine/kokoro
|
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 +
|
On an 8GB card F5 + Kokoro can co-reside (~5GB + ~1GB). Tortoise
|
||||||
~1GB). Tortoise pushes the budget over and needs the GPU largely
|
pushes the budget over and needs the GPU largely to itself — the
|
||||||
to itself — the `engine/tortoise` branch will carry the script
|
`engine/tortoise` branch carries a script to stop kokoro + f5
|
||||||
that stops kokoro + f5 before a tortoise run and restarts them
|
before a Tortoise run and restart them after. Replace with proper
|
||||||
after. Replace with proper coordination once we have more VRAM.
|
coordination once more VRAM is available.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
# Sulkta build of F5-TTS — upstream ghcr.io/swivid/f5-tts:main was
|
# F5-TTS rebuild on a known-good pytorch base. Upstream
|
||||||
# shipped with torch 2.11/torchaudio 2.4 ABI mismatch on 2026-05-13,
|
# ghcr.io/swivid/f5-tts:main shipped a torch/torchaudio ABI mismatch
|
||||||
# breaking import torchaudio at boot. We rebuild on a known-good
|
# that broke `import torchaudio` at boot; this image bypasses that.
|
||||||
# pytorch base + pip install f5-tts.
|
|
||||||
#
|
|
||||||
# Image tag in lucy-registry: lucy-registry:5000/f5-tts:<ver>
|
|
||||||
#
|
#
|
||||||
# License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights).
|
# License: Apache 2.0 (code) / CC-BY-NC (Emilia-trained weights).
|
||||||
# Personal use OK; redistribution gray-area — flagged.
|
# 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.
|
# Pre-warm the HF cache directory.
|
||||||
RUN mkdir -p /cache/hf /audio /voices
|
RUN mkdir -p /cache/hf /audio /voices
|
||||||
|
|
||||||
COPY f5_server.py /app/f5_server.py
|
COPY server.py /app/server.py
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 7860
|
EXPOSE 7860
|
||||||
|
|
||||||
# Skald talks to our purpose-built FastAPI server, not Gradio.
|
# Purpose-built FastAPI server, not Gradio. Models load at startup
|
||||||
# Models load at startup (first request would otherwise pay the
|
# so the first request doesn't pay the cold-start cost.
|
||||||
# cold-start cost). uvicorn on :7860 to keep the port stable.
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
|
||||||
CMD ["uvicorn", "f5_server:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Code is Apache 2.0; pretrained F5TTS_v1_Base weights are CC-BY-NC
|
||||||
# the pretrained model weights are CC-BY-NC (Emilia training data).
|
# (Emilia training data). Personal use is fine; redistribution is a
|
||||||
# Personal listen is fine; public sharing is a flagged gray area.
|
# flagged gray area.
|
||||||
# Cobb's call: ship anyway.
|
|
||||||
#
|
#
|
||||||
# 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,
|
# Set in .env (or override):
|
||||||
# happens on first inference request. Subsequent runs are warm.
|
# F5_HOST_PORT=7792
|
||||||
|
# F5_DATA=./data # ${F5_DATA}/hf-cache + voices + audio
|
||||||
name: f5-tts
|
name: f5-tts
|
||||||
|
|
||||||
services:
|
services:
|
||||||
f5-tts:
|
f5-tts:
|
||||||
image: lucy-registry:5000/f5-tts:0.3
|
build: .
|
||||||
|
image: f5-tts:0.3
|
||||||
container_name: f5-tts
|
container_name: f5-tts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -24,20 +26,11 @@ services:
|
||||||
count: all
|
count: all
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
ports:
|
ports:
|
||||||
- "192.168.0.5:7792:7860"
|
- "${F5_HOST_PORT:-7792}:7860"
|
||||||
- "127.0.0.1:7792:7860"
|
|
||||||
volumes:
|
volumes:
|
||||||
# HF model weights cache — persists ~2GB after first download.
|
- ${F5_DATA:-./data}/hf-cache:/cache/hf
|
||||||
- /mnt/cache/appdata/f5-tts/hf-cache:/cache/hf
|
- ${F5_DATA:-./data}/voices:/voices:ro
|
||||||
# Reference voice clips (lj_speech.wav, etc).
|
- ${F5_DATA:-./data}/audio:/audio
|
||||||
- /mnt/cache/appdata/f5-tts/voices:/voices:ro
|
|
||||||
# Rendered audio output — skald writes story narrations here.
|
|
||||||
- /mnt/cache/appdata/f5-tts/audio:/audio
|
|
||||||
environment:
|
environment:
|
||||||
HF_HOME: /cache/hf
|
HF_HOME: /cache/hf
|
||||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||||
labels:
|
|
||||||
org.sulkta.domain: "sulkta"
|
|
||||||
org.sulkta.owner: "cobb"
|
|
||||||
org.sulkta.managed-by: "compose"
|
|
||||||
org.sulkta.role: "f5-tts"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Kokoro runs fine on CPU but we use the cuda base to stay
|
||||||
# CC-BY-NC asterisk like F5-TTS's Emilia weights. This is the
|
# consistent with f5-tts and pick up the GPU when free.
|
||||||
# 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.
|
|
||||||
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
|
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
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
|
RUN mkdir -p /cache/hf /audio
|
||||||
|
|
||||||
COPY kokoro_server.py /app/kokoro_server.py
|
COPY server.py /app/server.py
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 7860
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
# Kokoro-82M TTS stack on Lucy.
|
# Kokoro-82M TTS sidecar.
|
||||||
#
|
#
|
||||||
# Audiobook-quality narrator engine (Apache 2.0 code + weights —
|
# Apache 2.0 code AND model weights — clean stack for share/publish.
|
||||||
# clean stack vs F5-TTS's CC-BY-NC asterisk). Sibling to f5-tts;
|
# Audiobook-quality narrator; F5-TTS stays around for voice-cloning.
|
||||||
# both share /mnt/cache/appdata/f5-tts/audio so skald's audio
|
|
||||||
# route serves outputs from either engine through the same path.
|
|
||||||
#
|
#
|
||||||
# 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
|
name: kokoro
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kokoro:
|
kokoro:
|
||||||
image: lucy-registry:5000/kokoro:0.5
|
build: .
|
||||||
|
image: kokoro:0.5
|
||||||
container_name: kokoro
|
container_name: kokoro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -21,17 +23,10 @@ services:
|
||||||
count: all
|
count: all
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
ports:
|
ports:
|
||||||
- "192.168.0.5:7794:7860"
|
- "${KOKORO_HOST_PORT:-7794}:7860"
|
||||||
- "127.0.0.1:7794:7860"
|
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/cache/appdata/kokoro/hf-cache:/cache/hf
|
- ${KOKORO_DATA:-./data}/hf-cache:/cache/hf
|
||||||
# Shared with f5-tts so skald's /audio route covers both.
|
- ${AUDIO_DIR:-./data/audio}:/audio
|
||||||
- /mnt/cache/appdata/f5-tts/audio:/audio
|
|
||||||
environment:
|
environment:
|
||||||
HF_HOME: /cache/hf
|
HF_HOME: /cache/hf
|
||||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||||
labels:
|
|
||||||
org.sulkta.domain: "sulkta"
|
|
||||||
org.sulkta.owner: "cobb"
|
|
||||||
org.sulkta.managed-by: "compose"
|
|
||||||
org.sulkta.role: "kokoro"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
Same /synthesize contract as F5 so skald can route between engines
|
||||||
just by which URL it points at. The semantic difference: Kokoro
|
just by which URL it points at. The semantic difference: Kokoro
|
||||||
|
|
@ -234,7 +234,7 @@ def _startup() -> None:
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz() -> dict:
|
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
|
# struct deserializes both: model/vocoder/loaded fields are
|
||||||
# required by skald-core::narrate::HealthResponse.
|
# required by skald-core::narrate::HealthResponse.
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
# ~26 built-in voices (no cloning): angie, daniel, deniro, emma,
|
||||||
# emma, freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2,
|
# freeman, geralt, halle, jlaw, lj, mol, myself, pat, pat2, rainbow,
|
||||||
# rainbow, snakes, tim_reynolds, tom, train_atkins, train_dotrice,
|
# snakes, tim_reynolds, tom, train_atkins, train_dotrice,
|
||||||
# train_dreams, train_grace, train_kennard, train_lescault,
|
# 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
|
# Slow: standard preset is ~10x slower than Kokoro; high_quality is
|
||||||
# stack for share/publish.
|
# ~30x. Trade for quality.
|
||||||
#
|
|
||||||
# Speed: slow. Trade for quality. Standard preset is ~10x slower
|
|
||||||
# than Kokoro; high_quality is ~30x slower. Worth it for the
|
|
||||||
# audiobook-quality bar.
|
|
||||||
|
|
||||||
FROM pytorch/pytorch:2.6.0-cuda12.4-cudnn9-runtime
|
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
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 7860
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
# Tortoise-TTS stack on Lucy. Audiobook-quality engine with 25+
|
# Tortoise-TTS sidecar. 25+ named voices, no cloning needed.
|
||||||
# named voices (no cloning). Apache 2.0 top to bottom.
|
# Apache 2.0 top to bottom.
|
||||||
#
|
#
|
||||||
# Slow: ~10x kokoro wall clock at 'standard' preset. Worth it for
|
# Slow: ~10x kokoro wall-clock at 'standard' preset. Worth it for the
|
||||||
# the quality bar. Cobb's call 2026-05-14: "use higgs (now tortoise)
|
# quality bar; runs are batched.
|
||||||
# and we will only let it use the full gpu for runs" — translated:
|
|
||||||
# runs are batched, slow is acceptable.
|
|
||||||
#
|
#
|
||||||
# Co-resides with kokoro on the 2070 Super since tortoise is ~5GB
|
# Co-resides with kokoro on an 8GB card (tortoise ~5GB + kokoro ~1GB).
|
||||||
# and kokoro is ~1GB (8GB total). If OOM hits during a render,
|
# OOM during a render: add a coordinator that pauses kokoro first.
|
||||||
# we'll add a coordination layer to pause 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
|
name: tortoise
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tortoise:
|
tortoise:
|
||||||
image: lucy-registry:5000/tortoise:0.1
|
build: .
|
||||||
|
image: tortoise:0.1
|
||||||
container_name: tortoise
|
container_name: tortoise
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -24,20 +27,12 @@ services:
|
||||||
count: all
|
count: all
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
ports:
|
ports:
|
||||||
- "192.168.0.5:7795:7860"
|
- "${TORTOISE_HOST_PORT:-7795}:7860"
|
||||||
- "127.0.0.1:7795:7860"
|
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/cache/appdata/tortoise/hf-cache:/cache/hf
|
- ${TORTOISE_DATA:-./data}/hf-cache:/cache/hf
|
||||||
- /mnt/cache/appdata/tortoise/models:/cache/tortoise-models
|
- ${TORTOISE_DATA:-./data}/models:/cache/tortoise-models
|
||||||
# Shared audio dir with f5/kokoro so skald serves all engines'
|
- ${AUDIO_DIR:-./data/audio}:/audio
|
||||||
# outputs through the same /audio route.
|
|
||||||
- /mnt/cache/appdata/f5-tts/audio:/audio
|
|
||||||
environment:
|
environment:
|
||||||
HF_HOME: /cache/hf
|
HF_HOME: /cache/hf
|
||||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||||
TORTOISE_MODELS_DIR: /cache/tortoise-models
|
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"
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
Same /synthesize contract as the kokoro server so skald only has to
|
||||||
route by voice.source. Differences:
|
route by voice.source. Differences:
|
||||||
|
|
@ -71,7 +71,7 @@ def _get_voice(name: str) -> tuple:
|
||||||
return _voice_cache[name]
|
return _voice_cache[name]
|
||||||
|
|
||||||
|
|
||||||
# ─── tag splitter (lifted from kokoro_server) ───────────────────
|
# ─── tag splitter (lifted from the kokoro server) ───────────────
|
||||||
|
|
||||||
|
|
||||||
class Node:
|
class Node:
|
||||||
|
|
@ -209,7 +209,7 @@ def _startup() -> None:
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz() -> dict:
|
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.
|
# struct deserializes all three.
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Skald container entrypoint.
|
|
||||||
#
|
|
||||||
# Boots the embedded postgres via the pgvector image's own
|
# Boots the embedded postgres via the pgvector image's own
|
||||||
# docker-entrypoint, waits for it to accept connections, then execs
|
# docker-entrypoint, waits for it to accept connections, then execs
|
||||||
# `skald` in the foreground. Tini is PID 1 (so it can reap zombies +
|
# `skald` in the foreground. Tini is PID 1; postgres becomes our
|
||||||
# forward signals); we are PID 2; postgres becomes our child.
|
# child. When the DB is extracted to its own container, this reduces
|
||||||
#
|
# to `exec /usr/local/bin/skald "$@"`.
|
||||||
# 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.
|
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,23 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ForgeConfig {
|
pub struct ForgeConfig {
|
||||||
/// Base URL of the clawdforge HTTP service. Defaults to
|
/// Base URL of the clawdforge HTTP service. The calling binary
|
||||||
/// `http://clawdforge.sulkta.lan:8800` in production; override
|
/// resolves this from `CLAWDFORGE_URL`.
|
||||||
/// for tests via env.
|
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
|
||||||
/// App-level bearer token. Resolved by the binary from
|
/// App-level bearer token. Resolved by the binary from
|
||||||
/// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed.
|
/// `CLAWDFORGE_TOKEN`; should never be logged or `Display`ed.
|
||||||
pub app_token: String,
|
pub app_token: String,
|
||||||
|
|
||||||
/// Model alias passed to clawdforge → `claude -p --model`. Skald
|
/// Model alias passed to clawdforge → `claude -p --model`.
|
||||||
/// is opinionated: always opus max effort. Default reflects that.
|
/// Defaults to opus.
|
||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ForgeConfig {
|
impl Default for ForgeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url: "http://clawdforge.sulkta.lan:8800".into(),
|
base_url: "http://localhost:8800".into(),
|
||||||
app_token: String::new(),
|
app_token: String::new(),
|
||||||
model: "opus".into(),
|
model: "opus".into(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct F5Config {
|
pub struct F5Config {
|
||||||
/// e.g. http://192.168.0.5:7792
|
/// e.g. http://localhost:7792
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
/// Inference subprocess timeout. Long-form chapters (3000 words)
|
/// Inference subprocess timeout. Long-form chapters (3000 words)
|
||||||
/// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge.
|
/// take 60-180s on an 8GB GPU; cap at 1800s to match clawdforge.
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ use uuid::Uuid;
|
||||||
about = "Long-form story-writer. Database is the source of truth; the writer is the tooling."
|
about = "Long-form story-writer. Database is the source of truth; the writer is the tooling."
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Postgres connection URL. Defaults to `postgresql://skald:skald@localhost:5432/skald`.
|
/// Postgres connection URL. Read from `DATABASE_URL` if unset.
|
||||||
#[arg(long, env = "DATABASE_URL", default_value = "postgresql://skald:skald@localhost:5432/skald")]
|
#[arg(long, env = "DATABASE_URL")]
|
||||||
database_url: String,
|
database_url: String,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
|
||||||
|
|
@ -458,16 +458,15 @@ async fn cleanup_superseded_renders(pool: &PgPool, chapter_id: Uuid, current_run
|
||||||
/// kokoro_* → KOKORO_URL
|
/// kokoro_* → KOKORO_URL
|
||||||
/// tortoise_* → TORTOISE_URL
|
/// tortoise_* → TORTOISE_URL
|
||||||
/// anything else (lj_speech etc.) → F5_TTS_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> {
|
fn engine_url_for(source: &str) -> anyhow::Result<String> {
|
||||||
if source.starts_with("kokoro") {
|
let (env_var, engine) = if source.starts_with("kokoro") {
|
||||||
Ok(std::env::var("KOKORO_URL")
|
("KOKORO_URL", "kokoro")
|
||||||
.unwrap_or_else(|_| "http://192.168.0.5:7794".into()))
|
|
||||||
} else if source.starts_with("tortoise") {
|
} else if source.starts_with("tortoise") {
|
||||||
Ok(std::env::var("TORTOISE_URL")
|
("TORTOISE_URL", "tortoise")
|
||||||
.unwrap_or_else(|_| "http://192.168.0.5:7795".into()))
|
|
||||||
} else {
|
} else {
|
||||||
Ok(std::env::var("F5_TTS_URL")
|
("F5_TTS_URL", "f5-tts")
|
||||||
.unwrap_or_else(|_| "http://192.168.0.5:7792".into()))
|
};
|
||||||
}
|
std::env::var(env_var).map_err(|_| {
|
||||||
|
anyhow::anyhow!("{env_var} not set — point at the {engine} sidecar")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,12 @@ async fn new_story_create(
|
||||||
// parent to compare against. So you get a single first-chapter
|
// parent to compare against. So you get a single first-chapter
|
||||||
// gen + cleanup pass and status flows to 'complete'.
|
// gen + cleanup pass and status flows to 'complete'.
|
||||||
if form.fire == "now" {
|
if form.fire == "now" {
|
||||||
let database_url = std::env::var("DATABASE_URL")
|
let Ok(database_url) = std::env::var("DATABASE_URL") else {
|
||||||
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"DATABASE_URL not set — cannot spawn background gen".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
let author_owned = if author_slug.is_empty() {
|
let author_owned = if author_slug.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -337,7 +341,12 @@ async fn continue_create(
|
||||||
// If user clicked "fire now," spawn a background gen task.
|
// If user clicked "fire now," spawn a background gen task.
|
||||||
// Otherwise the sequel sits in seed state until CLI fires it.
|
// Otherwise the sequel sits in seed state until CLI fires it.
|
||||||
if form.fire == "now" {
|
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 author_owned = if author_slug.is_empty() { None } else { Some(author_slug.to_string()) };
|
||||||
let direction_owned = direction.clone();
|
let direction_owned = direction.clone();
|
||||||
let chapters = parse_chapters(&form.chapters);
|
let chapters = parse_chapters(&form.chapters);
|
||||||
|
|
@ -512,8 +521,12 @@ async fn chapter_narrate_fire(
|
||||||
let chapter_id =
|
let chapter_id =
|
||||||
chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?;
|
chapter_id.ok_or((StatusCode::NOT_FOUND, "chapter not found".into()))?;
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL")
|
let Ok(database_url) = std::env::var("DATABASE_URL") else {
|
||||||
.unwrap_or_else(|_| "postgresql://skald:skald@localhost:5432/skald".into());
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"DATABASE_URL not set — cannot spawn background narrate".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = crate::narrate::run(&database_url, chapter_id, None, 1.0).await {
|
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");
|
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 {
|
footer.footbar {
|
||||||
span { "skald · v0.3 · written down · "
|
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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue