From 713ba41977f33a8dc8553f165e17c1f2d4419581 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 13 May 2026 12:01:29 -0700 Subject: [PATCH] v0.3 step 1: migration 0004 + authors module + web form panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0004 — authors + author_revisions + stories.author_id + stories.author_revision_id + stories.cross_story_memory + author_corpus. Soul versioning built in from day one per cobb's locked decisions: - authors.id immutable identity (slug + display_name + tagline + model) - author_revisions tracks each soul revision with n monotonic - Partial unique index 'idx_author_revisions_current' enforces exactly one is_current=true per author - stories.author_revision_id pins to the exact soul used at gen time (so 'this was the Orson Black active when chapter 8 was written' is always recoverable) - author_corpus tracks 'authored' + 'read' relationships for the v0.3 cross-story memory toggle skald-core::authors module — CRUD: get_by_slug, get_with_current_revision, get_current_revision, get_revision, create_or_get (idempotent), add_revision (transactional, demotes prior is_current=true), assign_to_story (also touches author_corpus). Web v0.1 forms (the second feedback bucket — 'no way to make new stories', 'no options for sequels'): handlers + form panels + POST routes for /stories/new and /stories/:id/continue. Both create a story stub with status='seed'; actual generation will be fired by 'skald continue' (next commit) walking seed rows. Norse visual revamp + mobile collapse deferred — vetting full gen is the priority per cobb's 'green light for v0.3'. Coming back to the aesthetic after the pipeline works end-to-end against a real Orson Black-authored Chapter 8 of Coast-Down. --- migrations/0004_authors.sql | 100 ++++++++++++++ skald-core/src/authors.rs | 263 ++++++++++++++++++++++++++++++++++++ skald-core/src/lib.rs | 1 + skald/src/web.rs | 166 ++++++++++++++++++++++- 4 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 migrations/0004_authors.sql create mode 100644 skald-core/src/authors.rs diff --git a/migrations/0004_authors.sql b/migrations/0004_authors.sql new file mode 100644 index 0000000..828d255 --- /dev/null +++ b/migrations/0004_authors.sql @@ -0,0 +1,100 @@ +-- Authors as personas with souls (v0.3). +-- +-- Each story has a named author whose soul (SOUL.md-style markdown +-- blob) IS the LLM's system prompt. Soul replaces opus's default +-- base prompt via clawdforge's system_mode='replace' so the model +-- BECOMES the author, not "Claude playing the author." +-- +-- Souls are versioned: authors.id is the immutable identity, while +-- author_revisions tracks soul edits over time. Stories pin to a +-- specific revision so "this was the version of Orson Black active +-- when chapter 8 of Coast-Down was written" is always recoverable. + +CREATE TABLE authors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, -- "orson-black" + display_name TEXT NOT NULL, -- "Orson Black" + persona_tagline TEXT, -- "Orwell but more rebel + pissed off" + -- Model the author writes with. Skald is opinionated: always + -- opus max effort. Stored per-author so we can later vary + -- (e.g. test sonnet for a faster-iterating author). + model TEXT NOT NULL DEFAULT 'opus', + -- Flag for the operator: this is a synthetic literary persona, + -- not a real human author. Display somewhere obvious. + is_synthetic BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One row per soul revision. `n` is monotonic per author. Exactly +-- one revision per author is_current=true at any time (partial +-- unique index). +CREATE TABLE author_revisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE, + n INTEGER NOT NULL, + -- The soul: SOUL.md-style markdown blob. Replaces claude's base + -- system prompt when this revision is used. + soul TEXT NOT NULL, + -- Optional override of the default scaffold prompt that wraps + -- the soul. Default scaffold lives in skald-core::forge. + system_template TEXT, + -- Tools the author can call during gen. Empty default — fiction + -- writing doesn't normally need them. Researcher-bent authors + -- could opt in for WebSearch / Read. + tools TEXT[] NOT NULL DEFAULT '{}', + -- Human note: "first cut", "tightened voice after Chapter 8 read" + note TEXT, + is_current BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (author_id, n) +); + +-- At most one current revision per author. +CREATE UNIQUE INDEX idx_author_revisions_current + ON author_revisions(author_id) WHERE is_current = true; + +CREATE INDEX idx_author_revisions_author ON author_revisions(author_id); + +-- Auto-touch authors.updated_at on author or revision change. +CREATE OR REPLACE FUNCTION touch_author_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE authors SET updated_at = now() + WHERE id = COALESCE(NEW.author_id, OLD.author_id); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER author_revisions_touch_author + AFTER INSERT OR UPDATE OR DELETE ON author_revisions + FOR EACH ROW + EXECUTE FUNCTION touch_author_updated_at(); + +-- Stories carry author identity + the exact revision used at gen +-- time. author_id is also denormalized on the row (instead of +-- joining through revision) so common "all stories by Orson" +-- queries don't need the join. +ALTER TABLE stories + ADD COLUMN author_id UUID REFERENCES authors(id) ON DELETE SET NULL, + ADD COLUMN author_revision_id UUID REFERENCES author_revisions(id) ON DELETE SET NULL, + -- Per-story toggle for cross-story memory. When true, + -- ContinuationContext::assemble pulls characters / canon / + -- summaries from EVERY story in the author's corpus, not just + -- the parent chain. + ADD COLUMN cross_story_memory BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX idx_stories_author ON stories(author_id); + +-- Track which stories an author has access to. Auto-populated via +-- app code on every story creation (role='authored'); operator can +-- mark other stories as 'read' for cross-corpus memory. +CREATE TABLE author_corpus ( + author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE, + story_id UUID NOT NULL REFERENCES stories(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('authored', 'read')), + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (author_id, story_id) +); + +CREATE INDEX idx_author_corpus_author ON author_corpus(author_id); diff --git a/skald-core/src/authors.rs b/skald-core/src/authors.rs new file mode 100644 index 0000000..0708fda --- /dev/null +++ b/skald-core/src/authors.rs @@ -0,0 +1,263 @@ +//! Authors module. Owns the read/write paths for the persona layer. +//! +//! An [`Author`] is the immutable identity (slug, display_name, model). +//! A [`Revision`] is the versioned soul content — exactly one revision +//! per author is marked `is_current = true` at any time. +//! +//! When skald-the-binary runs a forge pass, it pulls the current +//! revision via [`get_with_current_revision`], stuffs the soul into +//! the system prompt with `SystemMode::Replace`, and the model +//! becomes the author for the duration of the call. + +use anyhow::{Context, bail}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Author { + pub id: Uuid, + pub slug: String, + pub display_name: String, + pub persona_tagline: Option, + pub model: String, + pub is_synthetic: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Revision { + pub id: Uuid, + pub author_id: Uuid, + pub n: i32, + pub soul: String, + pub system_template: Option, + pub tools: Vec, + pub note: Option, + pub is_current: bool, + pub created_at: DateTime, +} + +/// An author + their currently-active revision. Most pipeline calls +/// want both at once. +#[derive(Debug, Clone)] +pub struct AuthorWithRevision { + pub author: Author, + pub revision: Revision, +} + +/// Fetch an author by slug. Returns None if no such author. +pub async fn get_by_slug(pool: &PgPool, slug: &str) -> anyhow::Result> { + let row: Option<(Uuid, String, String, Option, String, bool, DateTime, DateTime)> = + sqlx::query_as( + "SELECT id, slug, display_name, persona_tagline, model, is_synthetic, + created_at, updated_at + FROM authors WHERE slug = $1", + ) + .bind(slug) + .fetch_optional(pool) + .await?; + Ok(row.map( + |(id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at)| { + Author { + id, + slug, + display_name, + persona_tagline, + model, + is_synthetic, + created_at, + updated_at, + } + }, + )) +} + +/// Fetch an author + their current revision in one call. +pub async fn get_with_current_revision( + pool: &PgPool, + slug: &str, +) -> anyhow::Result> { + let Some(author) = get_by_slug(pool, slug).await? else { + return Ok(None); + }; + let revision = get_current_revision(pool, author.id) + .await? + .with_context(|| format!("author {slug} has no current revision"))?; + Ok(Some(AuthorWithRevision { author, revision })) +} + +/// Fetch the current revision for an author. +pub async fn get_current_revision( + pool: &PgPool, + author_id: Uuid, +) -> anyhow::Result> { + let row: Option<(Uuid, Uuid, i32, String, Option, Vec, Option, bool, DateTime)> = + sqlx::query_as( + "SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at + FROM author_revisions WHERE author_id = $1 AND is_current = true", + ) + .bind(author_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| { + Revision { + id, + author_id, + n, + soul, + system_template, + tools, + note, + is_current, + created_at, + } + })) +} + +/// Fetch a specific revision by id (e.g. the one pinned on a story). +pub async fn get_revision(pool: &PgPool, revision_id: Uuid) -> anyhow::Result> { + let row: Option<(Uuid, Uuid, i32, String, Option, Vec, Option, bool, DateTime)> = + sqlx::query_as( + "SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at + FROM author_revisions WHERE id = $1", + ) + .bind(revision_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| { + Revision { + id, + author_id, + n, + soul, + system_template, + tools, + note, + is_current, + created_at, + } + })) +} + +/// Create a new author. Idempotent on slug — if an author with this +/// slug exists, returns it instead. +pub async fn create_or_get( + pool: &PgPool, + slug: &str, + display_name: &str, + persona_tagline: Option<&str>, +) -> anyhow::Result { + if let Some(existing) = get_by_slug(pool, slug).await? { + return Ok(existing); + } + sqlx::query( + "INSERT INTO authors (slug, display_name, persona_tagline) + VALUES ($1, $2, $3)", + ) + .bind(slug) + .bind(display_name) + .bind(persona_tagline) + .execute(pool) + .await?; + get_by_slug(pool, slug) + .await? + .with_context(|| format!("author {slug} just created but not found")) +} + +/// Add a new revision to an author and mark it current. The previous +/// current revision (if any) gets flipped to is_current=false. +pub async fn add_revision( + pool: &PgPool, + author_id: Uuid, + soul: &str, + system_template: Option<&str>, + tools: &[String], + note: Option<&str>, +) -> anyhow::Result { + let mut tx = pool.begin().await?; + + // Demote the current revision if there is one. + sqlx::query("UPDATE author_revisions SET is_current = false WHERE author_id = $1 AND is_current = true") + .bind(author_id) + .execute(&mut *tx) + .await?; + + // Next n. + let next_n: i32 = sqlx::query_scalar( + "SELECT COALESCE(MAX(n), 0) + 1 FROM author_revisions WHERE author_id = $1", + ) + .bind(author_id) + .fetch_one(&mut *tx) + .await?; + + let revision_id: Uuid = sqlx::query_scalar( + "INSERT INTO author_revisions + (author_id, n, soul, system_template, tools, note, is_current) + VALUES ($1, $2, $3, $4, $5, $6, true) + RETURNING id", + ) + .bind(author_id) + .bind(next_n) + .bind(soul) + .bind(system_template) + .bind(tools) + .bind(note) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + get_revision(pool, revision_id) + .await? + .with_context(|| format!("revision {revision_id} just inserted but not found")) +} + +/// Assign an author + revision to an existing story. Pins the +/// revision so future re-reads always know which version wrote +/// what. Also adds a row to author_corpus marking the author as +/// having authored this story. +pub async fn assign_to_story( + pool: &PgPool, + story_id: Uuid, + author_id: Uuid, + revision_id: Uuid, +) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + + let affected = sqlx::query( + "UPDATE stories + SET author_id = $1, author_revision_id = $2 + WHERE id = $3", + ) + .bind(author_id) + .bind(revision_id) + .bind(story_id) + .execute(&mut *tx) + .await?; + if affected.rows_affected() == 0 { + bail!("story {story_id} not found"); + } + + sqlx::query( + "INSERT INTO author_corpus (author_id, story_id, role) + VALUES ($1, $2, 'authored') + ON CONFLICT (author_id, story_id) DO NOTHING", + ) + .bind(author_id) + .bind(story_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + // DB-touching tests live in skald/tests/integration.rs against a + // real ephemeral postgres. Unit tests would mostly check trivial + // mappings. +} diff --git a/skald-core/src/lib.rs b/skald-core/src/lib.rs index 93972f7..8936a6a 100644 --- a/skald-core/src/lib.rs +++ b/skald-core/src/lib.rs @@ -4,6 +4,7 @@ //! assembly for LLM calls. The story-independence rule: nothing in //! this crate knows about any specific story. Every story is rows. +pub mod authors; pub mod config; pub mod context; pub mod db; diff --git a/skald/src/web.rs b/skald/src/web.rs index a87070d..9733005 100644 --- a/skald/src/web.rs +++ b/skald/src/web.rs @@ -13,10 +13,12 @@ use std::sync::Arc; use axum::Router; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::response::Html; +use axum::response::{Html, Redirect}; use axum::routing::get; +use axum::Form; use chrono::{DateTime, Utc}; use maud::{DOCTYPE, Markup, html}; +use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; @@ -28,7 +30,9 @@ pub struct WebState { pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) + .route("/stories/new", get(new_story_form).post(new_story_create)) .route("/stories/{id}", get(story_detail)) + .route("/stories/{id}/continue", get(continue_form).post(continue_create)) .route("/stories/{id}/chapters/{n}", get(chapter_view)) .route("/stories/{id}/runs", get(runs_view)) .with_state(Arc::new(state)) @@ -92,6 +96,122 @@ async fn index(State(state): State>) -> Html { Html(render_shell(&stories, None, welcome_panel()).into_string()) } +#[derive(Debug, Deserialize)] +pub struct NewStoryForm { + title: String, + #[serde(default)] + prompt: String, + #[serde(default)] + word_count_target: String, +} + +async fn new_story_form(State(state): State>) -> Html { + let stories = fetch_stories(&state.pool).await; + Html(render_shell(&stories, None, new_story_panel(None)).into_string()) +} + +async fn new_story_create( + State(state): State>, + Form(form): Form, +) -> Result { + let title = form.title.trim(); + if title.is_empty() { + return Err((StatusCode::BAD_REQUEST, "title is required".into())); + } + let target: Option = form.word_count_target.trim().parse().ok(); + let prompt = if form.prompt.trim().is_empty() { + None + } else { + Some(form.prompt.trim().to_string()) + }; + + let id: Uuid = sqlx::query_scalar( + "INSERT INTO stories (title, status, prompt, word_count_target) + VALUES ($1, 'seed', $2, $3) + RETURNING id", + ) + .bind(title) + .bind(prompt.as_deref()) + .bind(target) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + sqlx::query("UPDATE stories SET root_story_id = id WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Redirect::to(&format!("/stories/{}", id))) +} + +#[derive(Debug, Deserialize)] +pub struct ContinueForm { + title: String, + #[serde(default)] + direction: String, + #[serde(default)] + word_count_target: String, +} + +async fn continue_form( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let stories = fetch_stories(&state.pool).await; + let parent = stories + .iter() + .find(|s| s.id == id) + .cloned() + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Html( + render_shell(&stories, Some(id), continue_panel(&parent)).into_string(), + )) +} + +async fn continue_create( + State(state): State>, + Path(parent_id): Path, + Form(form): Form, +) -> Result { + let title = form.title.trim(); + if title.is_empty() { + return Err((StatusCode::BAD_REQUEST, "title is required".into())); + } + let target: Option = form.word_count_target.trim().parse().ok(); + let direction = if form.direction.trim().is_empty() { + None + } else { + Some(form.direction.trim().to_string()) + }; + + let root_id: Uuid = sqlx::query_scalar( + "SELECT COALESCE(root_story_id, id) FROM stories WHERE id = $1", + ) + .bind(parent_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "parent story not found".into()))?; + + let id: Uuid = sqlx::query_scalar( + "INSERT INTO stories (title, status, prompt, word_count_target, parent_story_id, root_story_id) + VALUES ($1, 'seed', $2, $3, $4, $5) + RETURNING id", + ) + .bind(title) + .bind(direction.as_deref()) + .bind(target) + .bind(parent_id) + .bind(root_id) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Redirect::to(&format!("/stories/{}", id))) +} + async fn story_detail( State(state): State>, Path(id): Path, @@ -243,6 +363,50 @@ fn render_shell(stories: &[StoryRow], current: Option, main: Markup) -> Ma } } +fn new_story_panel(err: Option<&str>) -> Markup { + html! { + article.form-panel { + h1 { "New story" } + p.muted { "Create a story stub. Actual generation lands when v0.3 wires the gen pipeline." } + @if let Some(e) = err { p.error { (e) } } + form method="post" action="/stories/new" { + label { "Title" + input type="text" name="title" required; + } + label { "Seed prompt (optional)" + textarea name="prompt" rows="4" placeholder="What is this story about?" {} + } + label { "Target word count (optional)" + input type="number" name="word_count_target" min="500" step="500"; + } + button type="submit" { "Create" } + } + } + } +} + +fn continue_panel(parent: &StoryRow) -> Markup { + html! { + article.form-panel { + a.back href=(format!("/stories/{}", parent.id)) { "← back to " (parent.title) } + h1 { "Continue: " (parent.title) } + p.muted { "Creates a child story stub with parent_story_id = " code { (parent.id) } ". Generation queued for v0.3." } + form method="post" action=(format!("/stories/{}/continue", parent.id)) { + label { "Title for the sequel" + input type="text" name="title" required value=(format!("{} — continued", parent.title)); + } + label { "Direction (optional)" + textarea name="direction" rows="4" placeholder="What should the sequel explore?" {} + } + label { "Target word count (optional)" + input type="number" name="word_count_target" min="500" step="500"; + } + button type="submit" { "Queue sequel" } + } + } + } +} + fn welcome_panel() -> Markup { html! { div.welcome {