From 1f81754238bc976511b368571caf89e415a30f84 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Sat, 28 Mar 2026 09:21:08 -0400 Subject: [PATCH] Add running game transcript so the DM remembers what was said The DM was losing conversational details when Claude's session history got long or truncated on resume. The campaign log tracks events and time, the session tracks current state, but neither captures the actual dialogue. Now each player/DM exchange is written to a per-day transcript file in worlds/{world}/transcripts/. The last 10 turns are injected into the system prompt as "Recent Conversation", giving the DM short-term conversational memory independent of Claude's session history. Transcripts are also naturally searchable via recall since they live under the world directory. System messages like [Session starting] are filtered out. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/storied/engine.py | 25 ++++++++++++- src/storied/log.py | 69 ++++++++++++++++++++++++++++++++++ tests/test_log.py | 86 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/storied/engine.py b/src/storied/engine.py index 89a2958..4a4c2d4 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -20,7 +20,7 @@ from storied.claude import ( ) from storied.mcp_server import start_server as start_mcp_server from storied.content import ContentResolver -from storied.log import CampaignLog +from storied.log import CampaignLog, TranscriptLog from storied.session import ( extract_wiki_links, format_session_context, @@ -108,6 +108,9 @@ class DMEngine: # Campaign log for time tracking (world-scoped, shared with MCP server) self._campaign_log = CampaignLog(self.world_id, self.base_path) + # Transcript log for conversation history + self._transcript = TranscriptLog(self.world_id, self.base_path) + # Start in-process MCP server (shares CampaignLog with engine) self._mcp = start_mcp_server( world_id=self.world_id, @@ -161,7 +164,15 @@ class DMEngine: self._context_parts["Log"] = log_context parts.append(log_context) - # 3. Session state + # 3. Recent transcript (last 10 turns of dialogue) + transcript_context = self._transcript.recent_turns( + self._campaign_log.current_day, + ) + if transcript_context: + self._context_parts["Transcript"] = transcript_context + parts.append(transcript_context) + + # 4. Session state session = load_session(self.player_id, self.base_path) if session: session_context = format_session_context(session) @@ -387,6 +398,7 @@ class DMEngine: current_tool_json = "" current_tool_name = "" deferred_notification = False + dm_text_parts: list[str] = [] for event in stream_with_tools( system_prompt=system_prompt, @@ -398,6 +410,7 @@ class DMEngine: ): match event: case TextDelta(text=text): + dm_text_parts.append(text) yield text case ToolStart(name=name): @@ -447,6 +460,14 @@ class DMEngine: "usage": r.usage, }) + # Write conversation turn to transcript + dm_response = "".join(dm_text_parts) + if dm_response.strip(): + self._transcript.append_turn( + player_input, dm_response, + self._campaign_log.get_current_time(), + ) + def reset(self) -> None: """Reset the conversation state.""" self._session_id = None diff --git a/src/storied/log.py b/src/storied/log.py index e8c3f0b..935ea1c 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -438,3 +438,72 @@ def log_event( """ log = load_log(world_id, base_path) return log.append_entry(event, duration, advance_time, tags) + + +class TranscriptLog: + """Running transcript of player/DM dialogue. + + One file per game day in worlds/{world}/transcripts/. Each turn is a + markdown block with the game time, player input (blockquoted), and + the DM's response. The last N turns are injected into the DM's + system prompt for conversational memory. + """ + + def __init__(self, world_id: str, base_path: Path | None = None): + self.base_path = base_path or Path.cwd() + self.transcript_dir = self.base_path / "worlds" / world_id / "transcripts" + + def _day_path(self, day: int) -> Path: + if day >= 0: + return self.transcript_dir / f"day+{day:03d}.md" + return self.transcript_dir / f"day{day:04d}.md" + + def append_turn( + self, player_input: str, dm_response: str, game_time: "GameTime", + ) -> None: + """Append a turn to the current day's transcript.""" + # Skip system messages (session starting, save requests, etc.) + if player_input.startswith("["): + return + + self.transcript_dir.mkdir(parents=True, exist_ok=True) + path = self._day_path(game_time.day) + + # Blockquote the player input + quoted = "\n".join(f"> {line}" for line in player_input.splitlines()) + + turn = ( + f"\n### {game_time}\n\n" + f"{quoted}\n\n" + f"{dm_response.strip()}\n" + ) + + with path.open("a") as f: + f.write(turn) + + def recent_turns( + self, current_day: int, n: int = 10, + ) -> str: + """Load the last N turns across recent days for context injection.""" + turns: list[str] = [] + + # Check current day and previous day + for day in range(max(1, current_day - 1), current_day + 1): + path = self._day_path(day) + if not path.exists(): + continue + + content = path.read_text() + # Split on turn headers + sections = content.split("\n### ") + for section in sections: + section = section.strip() + if section: + turns.append(f"### {section}") + + if not turns: + return "" + + # Take the last N + recent = turns[-n:] + return "## Recent Conversation\n\n" + "\n\n".join(recent) diff --git a/tests/test_log.py b/tests/test_log.py index 55918c1..ea74242 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -2,11 +2,14 @@ import pytest +from pathlib import Path + from storied.log import ( CampaignLog, Duration, GameTime, LogEntry, + TranscriptLog, load_log, log_event, ) @@ -254,3 +257,86 @@ class TestConvenienceFunctions: # Verify it was saved log = load_log(world_id="test", base_path=tmp_path) assert len(log.current_entries) == 1 + + +# ── TranscriptLog ──────────────────────────────────────────────────────── + + +@pytest.fixture +def transcript(tmp_path: Path) -> TranscriptLog: + (tmp_path / "worlds" / "test").mkdir(parents=True) + return TranscriptLog("test", tmp_path) + + +class TestTranscriptLog: + def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): + transcript.append_turn( + "I look around", "The tavern is dimly lit.", + GameTime(day=1, hour=8, minute=0), + ) + path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" + assert path.exists() + + def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): + transcript.append_turn( + "I look around", "The tavern is dimly lit.", + GameTime(day=1, hour=8, minute=0), + ) + content = (tmp_path / "worlds" / "test" / "transcripts" / "day+001.md").read_text() + assert "> I look around" in content + assert "The tavern is dimly lit." in content + assert "### Day 1, 08:00" in content + + def test_multiple_turns_append(self, transcript: TranscriptLog): + time1 = GameTime(day=1, hour=8, minute=0) + time2 = GameTime(day=1, hour=8, minute=15) + transcript.append_turn("First", "Response one.", time1) + transcript.append_turn("Second", "Response two.", time2) + context = transcript.recent_turns(1) + assert "First" in context + assert "Second" in context + + def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): + transcript.append_turn( + "[Session starting]", "Welcome back!", + GameTime(day=1, hour=8, minute=0), + ) + path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" + assert not path.exists() + + def test_recent_turns_limit(self, transcript: TranscriptLog): + for i in range(15): + transcript.append_turn( + f"Turn {i}", f"Response {i}.", + GameTime(day=1, hour=8, minute=i), + ) + context = transcript.recent_turns(1, n=5) + assert "Turn 10" in context + assert "Turn 14" in context + assert "Turn 0" not in context + + def test_recent_turns_empty(self, transcript: TranscriptLog): + assert transcript.recent_turns(1) == "" + + def test_recent_turns_spans_days(self, transcript: TranscriptLog): + transcript.append_turn( + "Yesterday", "Something happened.", + GameTime(day=1, hour=20, minute=0), + ) + transcript.append_turn( + "Today", "Morning arrives.", + GameTime(day=2, hour=8, minute=0), + ) + context = transcript.recent_turns(2) + assert "Yesterday" in context + assert "Today" in context + + def test_includes_display_blocks(self, transcript: TranscriptLog): + dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." + transcript.append_turn( + "Look around", dm_response, + GameTime(day=1, hour=8, minute=0), + ) + context = transcript.recent_turns(1) + assert "```map" in context + assert "+-+" in context -- 2.51.2