From ae4642f9392b24602edbc528e3c346bc3f3ad5ea Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Mon, 3 Aug 2026 18:49:09 -0400 Subject: [PATCH] Replay the transcript's tail and the session brief at session start Plan 0012 milestone 3, most of plan 0011 step 5. A Dm built over a world with a record no longer starts blank: the last twelve exchanges of the transcript replay into the history behind the system message, and the entities named by the last replayed narration and by session.md seed the first turn's entity notes. The brief's body also joins every turn's system prompt, read fresh from disk, so a hand edit between sessions or between turns reaches the next prompt. Session start only reads: a replayed wikilink that resolves to nothing establishes no stub, and an unreadable transcript fails construction instead of opening a session that has lost its record. Prompt assembly moves to dm/prompt.rs and the replay lives in dm/session_start.rs, keeping dm/mod.rs under the file length limit. Step 5's aside routing waits for the aside tool (0013), so its checkbox stays open. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HvctyUUkzw7PcNrjCGG6dF --- src/dm/dm_session_start_tests.rs | 277 +++++++++++++++++++++++++++++++ src/dm/mod.rs | 87 ++++------ src/dm/prompt.rs | 78 +++++++++ src/dm/session-brief.md | 3 + src/dm/session_start.rs | 134 +++++++++++++++ src/dm/session_start_tests.rs | 135 +++++++++++++++ src/entities/mod.rs | 62 +++++-- 7 files changed, 712 insertions(+), 64 deletions(-) create mode 100644 src/dm/dm_session_start_tests.rs create mode 100644 src/dm/prompt.rs create mode 100644 src/dm/session-brief.md create mode 100644 src/dm/session_start.rs create mode 100644 src/dm/session_start_tests.rs diff --git a/src/dm/dm_session_start_tests.rs b/src/dm/dm_session_start_tests.rs new file mode 100644 index 0000000..0415198 --- /dev/null +++ b/src/dm/dm_session_start_tests.rs @@ -0,0 +1,277 @@ +//! Tests for session start: the tail of a world's transcript replayed +//! into a new session's history, and the session brief that says where +//! the story stands. + +use std::fs; +use std::sync::Arc; +use std::sync::mpsc::Receiver; + +use tempfile::TempDir; + +use super::tests::{CapturedRequest, dm_with_campaign, fake_server, sent_messages, sse_response}; +use super::{Campaign, Config, Dm}; +use crate::knowledge::fixtures; + +/// A world whose `transcript/0001.md` holds `body` under one game-time +/// heading, as a finished session leaves it. +fn world_with_transcript(body: &str) -> TempDir { + let world = TempDir::new().unwrap(); + fs::create_dir_all(world.path().join("transcript")).unwrap(); + fs::write( + world.path().join("transcript/0001.md"), + format!("# Day 1\n\n## #d1-0000\n{body}"), + ) + .unwrap(); + world +} + +/// Writes a note at `relative` under `world` with `body` as its prose. +fn write_note(world: &TempDir, relative: &str, body: &str) { + let file = world.path().join(relative); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write( + file, + format!("---\nkind: person\naliases: []\n---\n\n{body}\n"), + ) + .unwrap(); +} + +/// The system prompt of the next request the fake server captured. +fn next_system_prompt(requests: &Receiver) -> String { + sent_messages(&requests.recv().unwrap())[0]["content"] + .as_str() + .unwrap() + .to_string() +} + +/// A transcript body of `count` player lines, each answered by one line +/// of narration. +fn exchanges(count: usize) -> String { + (1..=count) + .map(|number| format!("player> line {number}\n\nreply {number}\n")) + .collect::>() + .join("\n") +} + +#[test] +fn a_written_transcript_replays_into_the_first_requests_messages() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nYou meet Vera at the bar.\n\n\ + player> I sit down.\n\nShe pours you a drink.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I drink.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages[1]["role"], "user"); + assert_eq!(messages[1]["content"], "I enter the tavern."); + assert_eq!(messages[2]["role"], "assistant"); + assert_eq!(messages[2]["content"], "You meet Vera at the bar."); + assert_eq!(messages[3]["role"], "user"); + assert_eq!(messages[3]["content"], "I sit down."); + assert_eq!(messages[4]["role"], "assistant"); + assert_eq!(messages[4]["content"], "She pours you a drink."); + assert_eq!(messages[5]["content"], "I drink."); +} + +#[test] +fn a_transcript_longer_than_the_limit_replays_only_its_tail() { + let world = world_with_transcript(&exchanges(super::session_start::REPLAYED_EXCHANGES + 3)); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("line 16").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + // The system message, twelve exchanges of two messages each, and the + // line the player just typed. + assert_eq!( + messages.len(), + super::session_start::REPLAYED_EXCHANGES * 2 + 2 + ); + assert_eq!(messages[1]["content"], "line 4"); +} + +#[test] +fn the_first_prompt_carries_the_notes_of_the_last_replayed_narration() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nYou meet [[people/Joseph Black]] by the fire.\n\n\ + player> I sit down.\n\n[[Vera Blackwater]] pours you a drink.\n", + ); + write_note( + &world, + "entities/Vera Blackwater.md", + "Vera keeps the anchor.", + ); + write_note( + &world, + "people/Joseph Black.md", + "Joseph owes the wrong men.", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I drink.").unwrap(); + + server.join().unwrap(); + let prompt = next_system_prompt(&requests); + assert!(prompt.contains("Vera keeps the anchor.")); + assert!(!prompt.contains("Joseph owes the wrong men.")); +} + +#[test] +fn a_session_brief_injects_its_body_and_its_linked_notes() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + write_note( + &world, + "people/Joseph Black.md", + "Joseph owes the wrong men.", + ); + fs::write( + world.path().join("session.md"), + "The party is mid-ambush. [[people/Joseph Black]] has the ledger.\n", + ) + .unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + dm.turn("I keep looking.").unwrap(); + + server.join().unwrap(); + let first = next_system_prompt(&requests); + assert!(first.contains("The party is mid-ambush.")); + assert!(first.contains("### people/Joseph Black")); + assert!(first.contains("Joseph owes the wrong men.")); + // The brief is not a one-time seed: it rides on every turn's prompt, + // however far the conversation has moved past it. + assert!(next_system_prompt(&requests).contains("The party is mid-ambush.")); +} + +#[test] +fn a_brief_edited_between_turns_reaches_the_next_prompt() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + fs::write(world.path().join("session.md"), "The party rides north.\n").unwrap(); + let (url, requests, server) = fake_server(vec![ + sse_response("data: [DONE]\n\n"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + fs::write(world.path().join("session.md"), "The party turned back.\n").unwrap(); + dm.turn("I keep looking.").unwrap(); + + server.join().unwrap(); + requests.recv().unwrap(); + let second = next_system_prompt(&requests); + assert!(second.contains("The party turned back.")); + assert!(!second.contains("The party rides north.")); +} + +#[test] +fn an_empty_brief_injects_nothing() { + let world = world_with_transcript("player> I ride on.\n\nThe road bends north.\n"); + fs::write(world.path().join("session.md"), "---\nkind: brief\n---\n\n").unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I look around.").unwrap(); + + server.join().unwrap(); + assert!(!next_system_prompt(&requests).contains("Where the story stands")); +} + +#[test] +fn an_unresolvable_link_in_replayed_narration_establishes_nothing() { + let world = world_with_transcript( + "player> I look around.\n\n[[Nobody At All]] watches from the corner.\n", + ); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I stare back.").unwrap(); + + server.join().unwrap(); + assert!(!world.path().join("entities/Nobody At All.md").exists()); + assert!(!next_system_prompt(&requests).contains("### Nobody At All")); +} + +/// The milestone's whole story in one test: a world whose last session +/// ended naming Vera, a brief written after it that links her, and a +/// restart that opens with all of it in hand. +#[test] +fn a_restart_opens_with_the_last_turns_the_brief_and_the_note_they_share() { + let world = world_with_transcript( + "player> I enter the tavern.\n\nThe fire is low.\n\n\ + player> I look for the innkeeper.\n\n\ + [[Vera Blackwater]] watches the door without appearing to.\n", + ); + write_note( + &world, + "entities/Vera Blackwater.md", + "Vera keeps the Rusty Anchor and a knife under its bar.", + ); + fs::write( + world.path().join("session.md"), + "The party is at the Rusty Anchor. [[Vera Blackwater|Vera]] has not \ + answered for the burned warehouse.\n", + ) + .unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I ask her about the warehouse.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages[1]["content"], "I enter the tavern."); + assert_eq!(messages[4]["role"], "assistant"); + let prompt = messages[0]["content"].as_str().unwrap(); + assert!(prompt.contains("has not answered for the burned warehouse.")); + assert!(prompt.contains("### entities/Vera Blackwater")); + assert!(prompt.contains("Vera keeps the Rusty Anchor and a knife under its bar.")); +} + +#[test] +fn an_empty_world_replays_nothing_and_still_turns() { + let world = TempDir::new().unwrap(); + let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); + let mut dm = dm_with_campaign(url, &world); + + dm.turn("I open the door.").unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1]["content"], "I open the door."); +} + +#[test] +fn a_transcript_that_cannot_be_read_fails_the_dm_at_startup() { + let world = TempDir::new().unwrap(); + fs::create_dir_all(world.path().join("transcript")).unwrap(); + fs::write(world.path().join("transcript/0001.md"), [0xFF, 0xFE]).unwrap(); + + let error = Dm::new( + Config { + api_base: "http://127.0.0.1:0".to_string(), + api_key: "sk-test".to_string(), + model: "gpt-4o-mini".to_string(), + }, + Arc::new(fixtures::mount(&[])), + &[], + Some(Campaign::open(world.path()).unwrap()), + ) + .err() + .expect("a transcript that cannot be read must fail the dm"); + + assert!(error.contains("0001.md")); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs index ee7800f..8ba8f8f 100644 --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -2,7 +2,7 @@ //! round loop that runs one player turn. use std::fmt; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use rand::rngs::StdRng; @@ -14,7 +14,7 @@ use crate::campaign::Campaign; use crate::chat::{ChatError, Client, Message, Role, StreamItem}; use crate::config::Config; use crate::context::ContextStack; -use crate::entities::{ScanState, Scanner, note_body, seed_resolver}; +use crate::entities::{ScanState, Scanner, seed_resolver}; use crate::knowledge::Mount; use tools::Tool; use tools::Toolbox; @@ -25,7 +25,9 @@ use tools::mark::MarkTool; use tools::read::ReadTool; use tools::recall::RecallTool; +mod prompt; mod report; +mod session_start; pub mod tools; /// Told to the model in place of its tools on a withheld round: it has @@ -33,10 +35,6 @@ pub mod tools; /// return next turn. const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); -/// The preamble ahead of the entity notes appended to the system prompt: -/// the notes may trail the conversation, and the conversation wins. -const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); - /// How many consecutive tool-only rounds, rounds that called tools and /// narrated nothing, are allowed before the tools are withheld for one /// round. @@ -114,8 +112,9 @@ pub enum TurnDelta { /// A dungeon master session: a chat client, the tools it can call /// mid-turn, the context stack that builds the system prompt each turn, -/// and the history of the conversation so far. The campaign it records to -/// lives on the session-scoped bus as a listener, not here. +/// and the history of the conversation so far, which opens with the tail +/// of the last session's transcript when the world has one. The campaign +/// it records to lives on the session-scoped bus as a listener, not here. pub struct Dm { client: Client, toolbox: Toolbox, @@ -130,9 +129,10 @@ pub struct Dm { /// on [`Self::bus`]. The `Dm` reads the completed set here to build /// the next turn's prompt. pub scan: Arc>, - /// The world root notes live under, the same one [`Self::bus`]'s - /// scanner writes stubs to. `None` when there is no campaign, in - /// which case a turn's prompt carries no entity notes at all. + /// The world root the notes and the session brief live under, the + /// same one [`Self::bus`]'s scanner writes stubs to. `None` when + /// there is no campaign, in which case a turn's prompt carries + /// neither a brief nor any entity notes. entity_root: Option, } @@ -188,7 +188,14 @@ impl Dm { /// with a seeded dice tool or a fake one. `campaign` is where /// narration is recorded; a `None` records nothing. /// - /// Fails when `context` cannot compose its first prompt. + /// With a campaign, the session picks up where the last one left off: + /// every note on disk registers with the resolver, the tail of the + /// transcript replays into the history behind the system message, and + /// the entities that tail and the session brief name are the ones the + /// first turn's prompt carries. See [`session_start`]. + /// + /// Fails when `context` cannot compose its first prompt, or when the + /// campaign's transcript cannot be read. pub fn with_toolbox( config: Config, toolbox: Toolbox, @@ -200,7 +207,7 @@ impl Dm { api_key: config.api_key, model: config.model, }; - let history = vec![system_message(&context.prompt()?)]; + let mut history = vec![system_message(&context.prompt()?)]; // The session-scoped bus carries the wikilink scanner always, and // the campaign listener when a campaign is present. The screen @@ -212,11 +219,16 @@ impl Dm { let entity_root = campaign .as_ref() .map(|campaign| campaign.world().to_path_buf()); - if let Some(root) = &entity_root { + if let Some(campaign) = &campaign { + let mut scan = scan.lock().unwrap(); // Every note already on disk registers before the first turn, // so a bare wikilink resolves to it instead of establishing a // duplicate stub. - seed_resolver(root, &mut scan.lock().unwrap()); + seed_resolver(campaign.world(), &mut scan); + // The last session's tail becomes this session's opening + // history, and whatever its last narration and the session + // brief name becomes the first turn's entity notes. + history.extend(session_start::resume(campaign, &mut scan)?); } let mut bus = TurnBus::new(); bus.add(Box::new(Scanner::new( @@ -299,11 +311,11 @@ impl Dm { // edit to a fragment reaches this turn. The context entries come // from the mount, which stays as it was scanned at startup. let prompt = self.context.prompt().map_err(TurnError::Context)?; - // The entities the previous turn referenced carry their notes into - // this turn's prompt; the scan lock is held only long enough to - // read the completed set. + // The session brief and the notes of the entities the previous + // turn referenced ride on the end of the prompt; the scan lock is + // held only long enough to read the completed set. let scan = self.scan.lock().unwrap(); - let prompt = append_entity_notes(prompt, self.entity_root.as_deref(), &scan); + let prompt = prompt::compose(prompt, self.entity_root.as_deref(), &scan); drop(scan); self.history[0] = system_message(&prompt); @@ -409,39 +421,6 @@ impl Dm { } } -/// Appends the notes of `scan`'s completed entities to `prompt`, so the -/// next turn keeps straight whatever the DM named last turn. Each note -/// is read fresh from `root`, and an entity with no note, or an empty -/// one, contributes nothing: it was established with no world, or it has -/// none to contribute. Nothing is appended, not even -/// [`ENTITY_NOTES_PREAMBLE`], when `root` is `None` or no completed -/// entity has a note. -fn append_entity_notes(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { - let Some(root) = root else { - return prompt; - }; - let mut notes = String::new(); - for entity in scan.completed() { - let Some(body) = note_body(root, &entity.address) else { - continue; - }; - if !notes.is_empty() { - notes.push_str("\n\n"); - } - notes.push_str("### "); - notes.push_str(&entity.address); - notes.push('\n'); - notes.push_str(&body); - } - if notes.is_empty() { - return prompt; - } - format!( - "{prompt}\n\n---\n\n{}\n\n{notes}", - ENTITY_NOTES_PREAMBLE.trim() - ) -} - /// A `Role::System` message with `content`. fn system_message(content: &str) -> Message { Message { @@ -483,3 +462,7 @@ mod tool_round_tests; #[cfg(test)] #[path = "dm_entity_notes_tests.rs"] mod entity_notes_tests; + +#[cfg(test)] +#[path = "dm_session_start_tests.rs"] +mod session_start_tests; diff --git a/src/dm/prompt.rs b/src/dm/prompt.rs new file mode 100644 index 0000000..9b2039a --- /dev/null +++ b/src/dm/prompt.rs @@ -0,0 +1,78 @@ +//! The turn's system prompt: what the context stack composes, plus the +//! session state that rides on the end of it. +//! +//! Two sections follow the context stack's own prompt. The session brief +//! says where the story stood when the last session ended. The entity +//! notes say what the world knows about whatever the DM named last turn. +//! Both are read fresh from disk every turn, so an edit between two turns +//! reaches the second one. + +use std::path::Path; + +use crate::entities::{ScanState, brief_body, note_body}; + +/// The preamble ahead of the session brief: this is where the story +/// stood after the last session, and the conversation wins once it moves +/// past that. +const SESSION_BRIEF_PREAMBLE: &str = include_str!("session-brief.md"); + +/// The preamble ahead of the entity notes: the notes may trail the +/// conversation, and the conversation wins. +const ENTITY_NOTES_PREAMBLE: &str = include_str!("entity-notes.md"); + +/// `prompt` with the session's own state appended: the session brief +/// first, then the notes of `scan`'s completed entities. +/// +/// Both come from the world at `root`. A `None` root is a session with +/// no world, which has neither a brief nor notes to read, so its prompt +/// is the context stack's alone. +pub(super) fn compose(prompt: String, root: Option<&Path>, scan: &ScanState) -> String { + let Some(root) = root else { + return prompt; + }; + let prompt = append_session_brief(prompt, root); + append_entity_notes(prompt, root, scan) +} + +/// Appends the world's `session.md` to `prompt`, so the DM knows where +/// the story stands however far back the replayed history reaches. A +/// world with no brief, or an empty one, contributes nothing, not even +/// [`SESSION_BRIEF_PREAMBLE`]. +fn append_session_brief(prompt: String, root: &Path) -> String { + match brief_body(root) { + Some(body) => section(prompt, SESSION_BRIEF_PREAMBLE, &body), + None => prompt, + } +} + +/// Appends the notes of `scan`'s completed entities to `prompt`, so the +/// next turn keeps straight whatever the DM named last turn. Each note is +/// read fresh from `root`, and an entity with no note, or an empty one, +/// contributes nothing: it was established with no world, or it has none +/// to contribute. Nothing is appended, not even +/// [`ENTITY_NOTES_PREAMBLE`], when no completed entity has a note. +fn append_entity_notes(prompt: String, root: &Path, scan: &ScanState) -> String { + let mut notes = String::new(); + for entity in scan.completed() { + let Some(body) = note_body(root, &entity.address) else { + continue; + }; + if !notes.is_empty() { + notes.push_str("\n\n"); + } + notes.push_str("### "); + notes.push_str(&entity.address); + notes.push('\n'); + notes.push_str(&body); + } + if notes.is_empty() { + return prompt; + } + section(prompt, ENTITY_NOTES_PREAMBLE, ¬es) +} + +/// `prompt` with one more section on the end: a rule, then `preamble`, +/// then `body`. +fn section(prompt: String, preamble: &str, body: &str) -> String { + format!("{prompt}\n\n---\n\n{}\n\n{body}", preamble.trim()) +} diff --git a/src/dm/session-brief.md b/src/dm/session-brief.md new file mode 100644 index 0000000..d7c6ac2 --- /dev/null +++ b/src/dm/session-brief.md @@ -0,0 +1,3 @@ +## Where the story stands + +This is the brief for the story so far, written after the last session ended: who is where, what is mid-flight, and what is unresolved. It describes the moment this session starts from, not what happens next. Once the conversation moves past it, the conversation wins. diff --git a/src/dm/session_start.rs b/src/dm/session_start.rs new file mode 100644 index 0000000..2a2d99c --- /dev/null +++ b/src/dm/session_start.rs @@ -0,0 +1,134 @@ +//! Session start: the tail of the transcript, read back as the +//! conversation it was written from. +//! +//! A session that opens on a world with a record does not start blank. +//! [`resume`] reads the transcript's last exchanges into the message +//! history, and marks the entities the last narration and the session +//! brief name, so the first turn's prompt carries their notes. +//! +//! Nothing here writes. A wikilink that resolves to nothing establishes +//! no stub, and the transcript itself is only read: what the record says +//! is what the last session put there. + +use crate::campaign::Campaign; +use crate::chat::{Message, Role}; +use crate::entities::{ScanState, brief_body}; + +/// The prefix the transcript stamps on what the player typed. +const PLAYER_PREFIX: &str = "player> "; + +/// How many exchanges, a player line and the narration that answered it, +/// replay into a new session's history. +/// +/// The knob that trades how far back the DM can see against the tokens +/// every turn of the session then pays to resend. The rest of the +/// record is still reachable through `recall`, and the session brief +/// carries the arc the replayed turns fall short of. +pub(super) const REPLAYED_EXCHANGES: usize = 12; + +/// Picks `campaign` up where the last session left off, and returns the +/// messages to append to the history after the system message, oldest +/// first. +/// +/// The entities the last replayed narration names join `scan`'s +/// completed set, so the first turn's prompt carries their notes. Only +/// the last narration counts: an entity that fell out of the story +/// several turns ago falls out of the prompt too, which is the same rule +/// a running session follows turn to turn. The session brief is read the +/// same way, and carries whatever the replayed turns are too short to +/// reach. +/// +/// Fails when the transcript cannot be read. A session that silently +/// started blank over an unreadable record would look like amnesia. +pub(super) fn resume(campaign: &Campaign, scan: &mut ScanState) -> Result, String> { + let sections = campaign.transcript_entries()?; + let bodies: Vec<&str> = sections.iter().map(|entry| entry.body.as_str()).collect(); + let messages = tail(reconstruct(&bodies.join("\n\n"))); + + if let Some(narration) = messages + .iter() + .rev() + .find(|message| matches!(message.role, Role::Assistant)) + { + scan.seed_completed(&narration.content); + } + if let Some(brief) = brief_body(campaign.world()) { + scan.seed_completed(&brief); + } + Ok(messages) +} + +/// The conversation that produced `text`, the transcript's section +/// bodies run together. +/// +/// A line that starts with `player> ` is what the player typed, and +/// consecutive ones join into one user message. Every other line is what +/// the DM put on the screen, narration and the event lines from its marks +/// alike, and they run together into one assistant message until the next +/// player line. A blank line between two lines of one speaker stays as a +/// paragraph break, and a longer run of them collapses to one. +/// +/// A transcript that opens with narration, from a mark before the player +/// said anything, opens the conversation with an assistant message. +fn reconstruct(text: &str) -> Vec { + let mut messages: Vec = Vec::new(); + let mut speaking: Option = None; + let mut content = String::new(); + let mut blank = false; + + for line in text.lines() { + if line.trim().is_empty() { + blank = true; + continue; + } + let (role, said) = match line.strip_prefix(PLAYER_PREFIX) { + Some(typed) => (Role::User, typed), + None => (Role::Assistant, line), + }; + if speaking.as_ref() == Some(&role) { + content.push_str(if blank { "\n\n" } else { "\n" }); + } else { + if let Some(previous) = speaking { + messages.push(message(previous, std::mem::take(&mut content))); + } + speaking = Some(role); + } + content.push_str(said); + blank = false; + } + if let Some(previous) = speaking { + messages.push(message(previous, content)); + } + messages +} + +/// The last [`REPLAYED_EXCHANGES`] exchanges of `messages`, counting one +/// exchange from each player line. Narration older than the first +/// replayed player line goes with it, so the tail starts on something the +/// player said. +fn tail(mut messages: Vec) -> Vec { + let starts: Vec = messages + .iter() + .enumerate() + .filter(|(_, message)| matches!(message.role, Role::User)) + .map(|(index, _)| index) + .collect(); + if starts.len() <= REPLAYED_EXCHANGES { + return messages; + } + messages.split_off(starts[starts.len() - REPLAYED_EXCHANGES]) +} + +/// A plain message with `role` and `content` and no tool fields. +fn message(role: Role, content: String) -> Message { + Message { + role, + content, + tool_calls: None, + tool_call_id: None, + } +} + +#[cfg(test)] +#[path = "session_start_tests.rs"] +mod tests; diff --git a/src/dm/session_start_tests.rs b/src/dm/session_start_tests.rs new file mode 100644 index 0000000..445a945 --- /dev/null +++ b/src/dm/session_start_tests.rs @@ -0,0 +1,135 @@ +//! Tests for reconstructing a conversation from a transcript, the odd +//! shapes a day file can hold: an event line between two narration +//! paragraphs, two player lines in a row, and narration before the +//! player has said anything. + +use super::*; + +/// The `(role, content)` of each reconstructed message, flat enough to +/// compare in one assertion. +fn conversation(text: &str) -> Vec<(Role, String)> { + reconstruct(text) + .into_iter() + .map(|message| (message.role, message.content)) + .collect() +} + +/// A transcript of `exchanges` player lines, each answered by one line of +/// narration, laid out the way `Transcript::append` writes them. +fn exchanges(count: usize) -> String { + (1..=count) + .map(|number| format!("player> line {number}\n\nreply {number}\n")) + .collect::>() + .join("\n") +} + +#[test] +fn one_exchange_reconstructs_as_a_user_message_then_an_assistant_message() { + assert_eq!( + conversation("player> I open the door.\n\nYou see a hall."), + vec![ + (Role::User, "I open the door.".to_string()), + (Role::Assistant, "You see a hall.".to_string()), + ] + ); +} + +#[test] +fn an_event_line_between_narration_paragraphs_joins_one_assistant_message() { + assert_eq!( + conversation( + "player> I wait.\n\nThe bell rings.\n\nThe watch changes.\n\nYou are alone again." + ), + vec![ + (Role::User, "I wait.".to_string()), + ( + Role::Assistant, + "The bell rings.\n\nThe watch changes.\n\nYou are alone again.".to_string() + ), + ] + ); +} + +#[test] +fn consecutive_player_lines_join_one_user_message() { + assert_eq!( + conversation("player> I wait.\n\nplayer> I wait longer.\n\nDawn comes."), + vec![ + (Role::User, "I wait.\n\nI wait longer.".to_string()), + (Role::Assistant, "Dawn comes.".to_string()), + ] + ); +} + +#[test] +fn narration_before_any_player_line_opens_the_conversation() { + assert_eq!( + conversation("The caravan sets out.\n\nplayer> I ride ahead."), + vec![ + (Role::Assistant, "The caravan sets out.".to_string()), + (Role::User, "I ride ahead.".to_string()), + ] + ); +} + +#[test] +fn a_run_of_blank_lines_collapses_to_one_paragraph_break() { + assert_eq!( + conversation("player> I look.\n\n\n\nA door.\n\n\n\nA window."), + vec![ + (Role::User, "I look.".to_string()), + (Role::Assistant, "A door.\n\nA window.".to_string()), + ] + ); +} + +#[test] +fn narration_wrapped_over_two_lines_keeps_its_single_break() { + assert_eq!( + conversation("player> I look.\n\nA door stands open,\nand a lamp burns past it."), + vec![ + (Role::User, "I look.".to_string()), + ( + Role::Assistant, + "A door stands open,\nand a lamp burns past it.".to_string() + ), + ] + ); +} + +#[test] +fn an_empty_transcript_reconstructs_no_messages() { + assert_eq!(conversation(""), vec![]); +} + +#[test] +fn fewer_exchanges_than_the_limit_all_replay() { + let messages = tail(reconstruct(&exchanges(REPLAYED_EXCHANGES))); + + assert_eq!(messages.len(), REPLAYED_EXCHANGES * 2); + assert_eq!(messages[0].content, "line 1"); +} + +#[test] +fn more_exchanges_than_the_limit_replay_only_the_last_of_them() { + let messages = tail(reconstruct(&exchanges(REPLAYED_EXCHANGES + 3))); + + assert_eq!(messages.len(), REPLAYED_EXCHANGES * 2); + assert_eq!(messages[0].content, "line 4"); + assert_eq!( + messages.last().unwrap().content, + format!("reply {}", REPLAYED_EXCHANGES + 3) + ); +} + +#[test] +fn narration_older_than_the_first_replayed_player_line_is_dropped_with_it() { + let text = format!( + "The caravan sets out.\n\n{}", + exchanges(REPLAYED_EXCHANGES + 1) + ); + + let messages = tail(reconstruct(&text)); + + assert_eq!(messages[0].content, "line 2"); +} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 63f0c39..5cbe830 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -13,11 +13,19 @@ //! checks an entity's aliases, so a retired address or an alternate name //! still finds its way home. When a reference matches nothing, the //! engine auto-establishes a stub at that address, and the kind-unknown -//! home for a bare name is the `entities/` bucket. [`seed_resolver`] -//! walks the world root at startup and registers every note already on -//! disk, so a bare name cannot re-establish an entity that already -//! exists. [`note_body`] reads an entity's note fresh from disk, for the -//! `Dm` to inject into the next turn's prompt. +//! home for a bare name is the `entities/` bucket. [`note_body`] reads +//! an entity's note fresh from disk, and [`brief_body`] reads the +//! session brief the same way, for the `Dm` to inject into the next +//! turn's prompt. +//! +//! A session that opens on a world with a record starts with entities +//! already in play. [`seed_resolver`] walks the world root and registers +//! every note already on disk, so a bare name cannot re-establish an +//! entity that already exists. [`ScanState::seed_completed`] then takes +//! the replayed narration and the session brief and marks whatever they +//! name as the entities the first turn's prompt carries. Neither writes +//! anything: a reference that resolves to nothing is skipped rather than +//! established. use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -156,6 +164,23 @@ impl ScanState { .iter() .filter_map(|address| self.entities.get(&address.to_lowercase())) } + + /// Puts the entities `text`'s wikilinks resolve to into the + /// completed set, so the next prompt carries their notes. This is + /// how a session starts with entities already in play: the last + /// replayed narration and the session brief go through here before + /// the first turn. + /// + /// A reference that resolves to nothing is skipped, and no stub is + /// established for it. Session start reads the record, and reading + /// the record never writes to the world. + pub(crate) fn seed_completed(&mut self, text: &str) { + for address in extract_wikilinks(text) { + if let Resolution::Found(found) = self.resolve(&address) { + self.completed.insert(found); + } + } + } } /// The final segment of an address, the entity's name. @@ -276,14 +301,27 @@ fn stub_path(root: &Path, address: &str) -> PathBuf { root.join(relative).with_extension("md") } -/// The address's note body, read fresh from disk under `root`, with its -/// frontmatter stripped and its whitespace trimmed. `None` when the note -/// file does not exist or its body is empty: an entity established with -/// no world has no note, and its name already lives in the conversation -/// history. A file with no frontmatter delimiter is read whole, since a -/// hand-written note may skip the frontmatter entirely. +/// The address's note body, read fresh from disk under `root`. `None` +/// when the note file does not exist or its body is empty: an entity +/// established with no world has no note, and its name already lives in +/// the conversation history. pub(crate) fn note_body(root: &Path, address: &str) -> Option { - let contents = fs::read_to_string(stub_path(root, address)).ok()?; + body_of(&stub_path(root, address)) +} + +/// The session brief's body, `session.md` at `root`, read fresh from +/// disk. `None` when the world has no brief yet or the brief is empty. +pub(crate) fn brief_body(root: &Path) -> Option { + body_of(&root.join(SESSION_BRIEF)) +} + +/// The prose of the markdown file at `path`, with its frontmatter +/// stripped and its whitespace trimmed. `None` when the file cannot be +/// read or its body is empty. A file with no frontmatter delimiter is +/// read whole, since a hand-written note or brief may skip the +/// frontmatter entirely. +fn body_of(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; let body = match split_frontmatter(&contents) { Ok((_frontmatter, body)) => body, Err(_) => contents, -- 2.51.2