v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan

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().
This commit is contained in:
Sulkta 2026-05-13 10:18:56 -07:00
parent 79f4393f00
commit 8cbc9e04eb
17 changed files with 3340 additions and 19 deletions

33
skald-core/src/config.rs Normal file
View file

@ -0,0 +1,33 @@
//! Configuration for skald-core consumers.
//!
//! Configs are passed in explicitly by the calling binary, not loaded
//! from disk here — the lib stays env-agnostic. (skald-the-binary
//! reads env vars + maps them into these structs.)
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeConfig {
/// Base URL of the clawdforge HTTP service. Defaults to
/// `http://clawdforge.example.local:8800` in production; override
/// for tests via env.
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.
pub model: String,
}
impl Default for ForgeConfig {
fn default() -> Self {
Self {
base_url: "http://clawdforge.example.local:8800".into(),
app_token: String::new(),
model: "opus".into(),
}
}
}

203
skald-core/src/forge.rs Normal file
View file

@ -0,0 +1,203 @@
//! clawdforge wiring. Three passes per chapter; the actual prompt
//! templates are TODO (v0.2 prompt-engineering sprint) — this module
//! ships the plumbing so prompts can be filled in without
//! refactoring.
//!
//! The three passes:
//!
//! 1. **gen** — produces a new chapter draft from an assembled
//! context blob (parent prose + bible + characters + similarity-
//! matched passages, all from the database). Opus, max effort.
//!
//! 2. **cleanup** — polishes the draft for prose quality, voice
//! consistency, dialogue rhythm, pacing dead spots. Same Opus,
//! fresh eyes; sees gen pass output + same context.
//!
//! 3. **audit** — third Opus reads parent prose + sequel prose +
//! bible, returns structured findings: dropped threads, character
//! voice drift, retconned facts, timeline contradictions. Output
//! parses into rows for the `audit_findings` table.
//!
//! Every pass is logged as a `generation_runs` row before / after
//! for cost tracking, replay, and forensics.
//!
//! ## Naming context
//!
//! The Rust binding for clawdforge is the upstream `clawdforge` crate
//! (vendored at `vendor/clawdforge`). This module is the skald-side
//! glue: turn a story-id + a pass-kind into the right RunRequest +
//! parse the response into the right shape.
use std::time::Duration;
use clawdforge::{Client, ClientBuilder, RunRequest, RunResult};
use serde::{Deserialize, Serialize};
use crate::config::ForgeConfig;
/// Thin wrapper around the clawdforge `Client`. Configured once,
/// cheap to clone — each pass just calls `.run()` with a different
/// prompt.
#[derive(Clone)]
pub struct Forge {
client: Client,
/// The model alias we pass to clawdforge. Skald is opinionated:
/// always opus max effort. (See `project_story_writer_container.md`.)
/// `clawdforge` resolves the alias to the actual claude CLI flag.
model: String,
}
/// Per-pass output. `result` is the raw response from clawdforge.
/// Callers parse it into the shape they need.
#[derive(Debug, Clone)]
pub struct PassOutput {
pub kind: PassKind,
pub result: RunResult,
pub duration_ms: u64,
}
/// What a given pass over the model is for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PassKind {
/// First-pass long-form draft from prompt + context.
Gen,
/// Polish + humanize the gen pass output.
Cleanup,
/// Canon audit across parent + sequel. Outputs findings JSON.
Audit,
}
impl PassKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Gen => "gen",
Self::Cleanup => "cleanup",
Self::Audit => "audit",
}
}
}
impl Forge {
pub fn new(cfg: &ForgeConfig) -> anyhow::Result<Self> {
let client = ClientBuilder::default()
.base_url(&cfg.base_url)
.token(&cfg.app_token)
// Generation passes are slow — 600s is the clawdforge
// server-side max anyway, and gen passes routinely hit
// 5+ minutes on opus max-effort. Default 120s would
// strand them.
.timeout(Duration::from_secs(600))
.user_agent(concat!("skald/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
client,
model: cfg.model.clone(),
})
}
/// First-pass draft. `prompt` is the user-supplied story prompt;
/// `context` is the full assembled blob (bible + characters +
/// parent prose summaries + passages).
///
/// Prompt template is TODO (v0.2). Stub builds the simplest
/// possible request shape so the wiring compiles.
pub async fn generate(&self, prompt: &str, context: &str) -> anyhow::Result<PassOutput> {
let body = build_request(
&self.model,
PassKind::Gen,
prompt,
context,
SYSTEM_GEN_TODO,
);
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Gen, result: r, duration_ms })
}
/// Cleanup / humanize pass over the gen draft.
pub async fn cleanup(&self, draft: &str, context: &str) -> anyhow::Result<PassOutput> {
let body = build_request(
&self.model,
PassKind::Cleanup,
draft,
context,
SYSTEM_CLEANUP_TODO,
);
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Cleanup, result: r, duration_ms })
}
/// Canon audit comparing parent + sequel against the bible.
/// Expected to return structured JSON parseable into
/// `Vec<AuditFinding>`.
pub async fn audit(&self, parent_prose: &str, sequel_prose: &str, bible: &str) -> anyhow::Result<PassOutput> {
let body = build_audit_request(
&self.model,
parent_prose,
sequel_prose,
bible,
);
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Audit, result: r, duration_ms })
}
}
fn build_request(model: &str, kind: PassKind, primary: &str, context: &str, system: &str) -> RunRequest {
let prompt = format!(
"# Pass: {kind}\n\n## Context\n\n{context}\n\n## Input\n\n{primary}",
kind = kind.as_str(),
);
RunRequest {
prompt,
model: Some(model.to_string()),
system: Some(system.to_string()),
timeout_secs: Some(600),
..Default::default()
}
}
fn build_audit_request(model: &str, parent: &str, sequel: &str, bible: &str) -> RunRequest {
let prompt = format!(
"## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON: {{ \"findings\": [ {{ \"severity\": \"info|warn|crit\", \"area\": \"character|continuity|tone|fact|timeline|other\", \"body\": \"...\" }} ] }}"
);
RunRequest {
prompt,
model: Some(model.to_string()),
system: Some(SYSTEM_AUDIT_TODO.to_string()),
timeout_secs: Some(600),
..Default::default()
}
}
// ─── Prompt templates (TODO v0.2 — these are placeholder stubs) ───
const SYSTEM_GEN_TODO: &str = "You are a long-form fiction author. \
Write in measured, literary prose. Honor the bible and character voices \
exactly. (Full prompt template: TODO v0.2.)";
const SYSTEM_CLEANUP_TODO: &str = "You are a copy editor for long-form fiction. \
Polish the draft for prose quality, tighten dialogue, fix pacing dead \
spots, keep voice consistent. Do not add new plot. (Full prompt template: TODO v0.2.)";
const SYSTEM_AUDIT_TODO: &str = "You are a canon auditor. Compare the parent \
and sequel against the bible. Flag contradictions, character voice drift, \
retconned facts, dropped threads, timeline issues. Output structured \
JSON only no commentary. (Full prompt template: TODO v0.2.)";
/// Audit finding shape returned by the audit pass. Parses out of the
/// `result` field on the audit pass's [`RunResult`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditFinding {
pub severity: String,
pub area: String,
pub body: String,
}
/// Wrapper shape for the audit response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditResponse {
pub findings: Vec<AuditFinding>,
}

View file

@ -4,7 +4,9 @@
//! assembly for LLM calls. The story-independence rule: nothing in
//! this crate knows about any specific story. Every story is rows.
pub mod config;
pub mod db;
pub mod forge;
pub mod ingest;
pub mod models;