A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

+178 -2
+23 -2
src/storied/engine.py
··· 20 20 ) 21 21 from storied.mcp_server import start_server as start_mcp_server 22 22 from storied.content import ContentResolver 23 - from storied.log import CampaignLog 23 + from storied.log import CampaignLog, TranscriptLog 24 24 from storied.session import ( 25 25 extract_wiki_links, 26 26 format_session_context, ··· 108 108 # Campaign log for time tracking (world-scoped, shared with MCP server) 109 109 self._campaign_log = CampaignLog(self.world_id, self.base_path) 110 110 111 + # Transcript log for conversation history 112 + self._transcript = TranscriptLog(self.world_id, self.base_path) 113 + 111 114 # Start in-process MCP server (shares CampaignLog with engine) 112 115 self._mcp = start_mcp_server( 113 116 world_id=self.world_id, ··· 161 164 self._context_parts["Log"] = log_context 162 165 parts.append(log_context) 163 166 164 - # 3. Session state 167 + # 3. Recent transcript (last 10 turns of dialogue) 168 + transcript_context = self._transcript.recent_turns( 169 + self._campaign_log.current_day, 170 + ) 171 + if transcript_context: 172 + self._context_parts["Transcript"] = transcript_context 173 + parts.append(transcript_context) 174 + 175 + # 4. Session state 165 176 session = load_session(self.player_id, self.base_path) 166 177 if session: 167 178 session_context = format_session_context(session) ··· 387 398 current_tool_json = "" 388 399 current_tool_name = "" 389 400 deferred_notification = False 401 + dm_text_parts: list[str] = [] 390 402 391 403 for event in stream_with_tools( 392 404 system_prompt=system_prompt, ··· 398 410 ): 399 411 match event: 400 412 case TextDelta(text=text): 413 + dm_text_parts.append(text) 401 414 yield text 402 415 403 416 case ToolStart(name=name): ··· 446 459 "session_id": r.session_id, 447 460 "usage": r.usage, 448 461 }) 462 + 463 + # Write conversation turn to transcript 464 + dm_response = "".join(dm_text_parts) 465 + if dm_response.strip(): 466 + self._transcript.append_turn( 467 + player_input, dm_response, 468 + self._campaign_log.get_current_time(), 469 + ) 449 470 450 471 def reset(self) -> None: 451 472 """Reset the conversation state."""
+69
src/storied/log.py
··· 438 438 """ 439 439 log = load_log(world_id, base_path) 440 440 return log.append_entry(event, duration, advance_time, tags) 441 + 442 + 443 + class TranscriptLog: 444 + """Running transcript of player/DM dialogue. 445 + 446 + One file per game day in worlds/{world}/transcripts/. Each turn is a 447 + markdown block with the game time, player input (blockquoted), and 448 + the DM's response. The last N turns are injected into the DM's 449 + system prompt for conversational memory. 450 + """ 451 + 452 + def __init__(self, world_id: str, base_path: Path | None = None): 453 + self.base_path = base_path or Path.cwd() 454 + self.transcript_dir = self.base_path / "worlds" / world_id / "transcripts" 455 + 456 + def _day_path(self, day: int) -> Path: 457 + if day >= 0: 458 + return self.transcript_dir / f"day+{day:03d}.md" 459 + return self.transcript_dir / f"day{day:04d}.md" 460 + 461 + def append_turn( 462 + self, player_input: str, dm_response: str, game_time: "GameTime", 463 + ) -> None: 464 + """Append a turn to the current day's transcript.""" 465 + # Skip system messages (session starting, save requests, etc.) 466 + if player_input.startswith("["): 467 + return 468 + 469 + self.transcript_dir.mkdir(parents=True, exist_ok=True) 470 + path = self._day_path(game_time.day) 471 + 472 + # Blockquote the player input 473 + quoted = "\n".join(f"> {line}" for line in player_input.splitlines()) 474 + 475 + turn = ( 476 + f"\n### {game_time}\n\n" 477 + f"{quoted}\n\n" 478 + f"{dm_response.strip()}\n" 479 + ) 480 + 481 + with path.open("a") as f: 482 + f.write(turn) 483 + 484 + def recent_turns( 485 + self, current_day: int, n: int = 10, 486 + ) -> str: 487 + """Load the last N turns across recent days for context injection.""" 488 + turns: list[str] = [] 489 + 490 + # Check current day and previous day 491 + for day in range(max(1, current_day - 1), current_day + 1): 492 + path = self._day_path(day) 493 + if not path.exists(): 494 + continue 495 + 496 + content = path.read_text() 497 + # Split on turn headers 498 + sections = content.split("\n### ") 499 + for section in sections: 500 + section = section.strip() 501 + if section: 502 + turns.append(f"### {section}") 503 + 504 + if not turns: 505 + return "" 506 + 507 + # Take the last N 508 + recent = turns[-n:] 509 + return "## Recent Conversation\n\n" + "\n\n".join(recent)
+86
tests/test_log.py
··· 2 2 3 3 import pytest 4 4 5 + from pathlib import Path 6 + 5 7 from storied.log import ( 6 8 CampaignLog, 7 9 Duration, 8 10 GameTime, 9 11 LogEntry, 12 + TranscriptLog, 10 13 load_log, 11 14 log_event, 12 15 ) ··· 254 257 # Verify it was saved 255 258 log = load_log(world_id="test", base_path=tmp_path) 256 259 assert len(log.current_entries) == 1 260 + 261 + 262 + # ── TranscriptLog ──────────────────────────────────────────────────────── 263 + 264 + 265 + @pytest.fixture 266 + def transcript(tmp_path: Path) -> TranscriptLog: 267 + (tmp_path / "worlds" / "test").mkdir(parents=True) 268 + return TranscriptLog("test", tmp_path) 269 + 270 + 271 + class TestTranscriptLog: 272 + def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): 273 + transcript.append_turn( 274 + "I look around", "The tavern is dimly lit.", 275 + GameTime(day=1, hour=8, minute=0), 276 + ) 277 + path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 278 + assert path.exists() 279 + 280 + def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): 281 + transcript.append_turn( 282 + "I look around", "The tavern is dimly lit.", 283 + GameTime(day=1, hour=8, minute=0), 284 + ) 285 + content = (tmp_path / "worlds" / "test" / "transcripts" / "day+001.md").read_text() 286 + assert "> I look around" in content 287 + assert "The tavern is dimly lit." in content 288 + assert "### Day 1, 08:00" in content 289 + 290 + def test_multiple_turns_append(self, transcript: TranscriptLog): 291 + time1 = GameTime(day=1, hour=8, minute=0) 292 + time2 = GameTime(day=1, hour=8, minute=15) 293 + transcript.append_turn("First", "Response one.", time1) 294 + transcript.append_turn("Second", "Response two.", time2) 295 + context = transcript.recent_turns(1) 296 + assert "First" in context 297 + assert "Second" in context 298 + 299 + def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): 300 + transcript.append_turn( 301 + "[Session starting]", "Welcome back!", 302 + GameTime(day=1, hour=8, minute=0), 303 + ) 304 + path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 305 + assert not path.exists() 306 + 307 + def test_recent_turns_limit(self, transcript: TranscriptLog): 308 + for i in range(15): 309 + transcript.append_turn( 310 + f"Turn {i}", f"Response {i}.", 311 + GameTime(day=1, hour=8, minute=i), 312 + ) 313 + context = transcript.recent_turns(1, n=5) 314 + assert "Turn 10" in context 315 + assert "Turn 14" in context 316 + assert "Turn 0" not in context 317 + 318 + def test_recent_turns_empty(self, transcript: TranscriptLog): 319 + assert transcript.recent_turns(1) == "" 320 + 321 + def test_recent_turns_spans_days(self, transcript: TranscriptLog): 322 + transcript.append_turn( 323 + "Yesterday", "Something happened.", 324 + GameTime(day=1, hour=20, minute=0), 325 + ) 326 + transcript.append_turn( 327 + "Today", "Morning arrives.", 328 + GameTime(day=2, hour=8, minute=0), 329 + ) 330 + context = transcript.recent_turns(2) 331 + assert "Yesterday" in context 332 + assert "Today" in context 333 + 334 + def test_includes_display_blocks(self, transcript: TranscriptLog): 335 + dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." 336 + transcript.append_turn( 337 + "Look around", dm_response, 338 + GameTime(day=1, hour=8, minute=0), 339 + ) 340 + context = transcript.recent_turns(1) 341 + assert "```map" in context 342 + assert "+-+" in context