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