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:
parent
79f4393f00
commit
8cbc9e04eb
17 changed files with 3340 additions and 19 deletions
33
skald-core/src/config.rs
Normal file
33
skald-core/src/config.rs
Normal 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
203
skald-core/src/forge.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue