diff --git a/.codex/skills/synapt/SKILL.md b/.codex/skills/synapt/SKILL.md index fd4c5c86..7957241b 100644 --- a/.codex/skills/synapt/SKILL.md +++ b/.codex/skills/synapt/SKILL.md @@ -24,6 +24,7 @@ recall_quick recall_search recall_context recall_sessions +recall_resume recall_files ``` diff --git a/src/synapt/recall/server.py b/src/synapt/recall/server.py index f50ccbac..ea0b3b85 100644 --- a/src/synapt/recall/server.py +++ b/src/synapt/recall/server.py @@ -565,6 +565,79 @@ def recall_sessions( return "\n".join(lines) +def recall_resume( + session_id: str | None = None, + turns: int = 10, +) -> str: + """Show the tail of the most recent session so a fresh session can pick up where it stopped. + + Answers by POSITION (the last N turns), not by relevance -- which is what a + cold "where did we leave off" needs and what search cannot give. Pairs the + tail with the journal entry that session wrote, if any, and labels the + index-freshness verdict so a stale view is never mistaken for a complete one. + + Use at session start, alongside recall_journal: the journal is what the + previous session chose to write, this is what actually happened last. They + diverge whenever work continued after the journal was written. + + Same surface as the `synapt resume` CLI. + + Args: + session_id: Session to resume (prefix accepted). Default: the newest session. + turns: Number of tail turns to show (default 10). + """ + from synapt.recall.journal import _journal_path + from synapt.recall.resume import ( + ResumeError, + build_resume_view, + format_resume, + load_resume_index, + ) + from synapt.recall.sharding import is_sharded + + index_dir = project_index_dir() + if ( + not (index_dir / "recall.db").exists() + and not (index_dir / "chunks.jsonl").exists() + and not is_sharded(index_dir) + ): + return f"No index found at {index_dir}. Run `synapt recall setup` first." + + index = load_resume_index(index_dir) + try: + try: + view = build_resume_view( + index, + session_id=session_id, + limit=turns, + journal_path=_journal_path(), + ) + except ResumeError as exc: + if not index._session_order: + return "No sessions indexed yet. Nothing to resume." + return f"Resume failed: {exc}" + finally: + db = getattr(index, "_db", None) + if db is not None: + db.close() + + # Freshness is attached after the view is built (same contract as the CLI): + # it can only change what the reader is told, never what is shown. A failure + # to compute it leaves the verdict None, rendered as NOT CHECKED, not fresh. + try: + import dataclasses + + from synapt.recall.freshness import check_index_freshness + + result = check_index_freshness(None, index_dir=index_dir) + if not result.stale and not view.turns: + result = check_index_freshness(None, index_dir=index_dir, deep=True) + view = dataclasses.replace(view, freshness=result) + except Exception: + pass + + return format_resume(view) + def recall_build(incremental: bool = True) -> str: """Build or rebuild the transcript index from auto-discovered sources. @@ -2419,6 +2492,7 @@ def register_tools(mcp) -> None: mcp.tool()(_with_directive_check(recall_quick)) mcp.tool()(_with_directive_check(recall_files)) mcp.tool()(_with_directive_check(recall_sessions)) + mcp.tool()(_with_directive_check(recall_resume)) mcp.tool()(recall_build) mcp.tool()(recall_setup) mcp.tool()(recall_export) diff --git a/tests/recall/test_server_resume.py b/tests/recall/test_server_resume.py new file mode 100644 index 00000000..b897208b --- /dev/null +++ b/tests/recall/test_server_resume.py @@ -0,0 +1,106 @@ +"""recall_resume: the `synapt resume` surface exposed as an MCP tool. + +Mirrors test_server_sessions.py: the tool must use the bounded resume index, +never construct the full index, and always close the DB it opened. +""" + +from unittest.mock import Mock, patch + + +def test_recall_resume_reports_missing_index(monkeypatch, tmp_path): + from synapt.recall import server + + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + out = server.recall_resume() + assert out.startswith("No index found at") + assert "synapt recall setup" in out + + +def test_recall_resume_empty_index_is_honest_empty_not_error(monkeypatch, tmp_path): + from synapt.recall import server + from synapt.recall.resume import ResumeError + + index = Mock() + index._session_order = [] + index._db = Mock() + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index) as load, + patch("synapt.recall.resume.build_resume_view", side_effect=ResumeError("nothing")), + patch.object( + server, + "_get_index", + side_effect=AssertionError("resume constructed the full index"), + ), + ): + assert server.recall_resume() == "No sessions indexed yet. Nothing to resume." + + load.assert_called_once_with(tmp_path) + index._db.close.assert_called_once_with() + + +def test_recall_resume_unresolved_session_is_an_error_not_empty(monkeypatch, tmp_path): + from synapt.recall import server + from synapt.recall.resume import ResumeError + + index = Mock() + index._session_order = ["abc"] + index._db = Mock() + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index), + patch("synapt.recall.resume.build_resume_view", side_effect=ResumeError("no such session zzz")), + ): + out = server.recall_resume(session_id="zzz") + + assert out == "Resume failed: no such session zzz" + index._db.close.assert_called_once_with() + + +def test_recall_resume_passes_session_and_turns_and_renders(monkeypatch, tmp_path): + from synapt.recall import server + + index = Mock() + index._session_order = ["abc"] + index._db = Mock() + view = Mock() + view.turns = [object()] + (tmp_path / "recall.db").touch() + monkeypatch.setattr(server, "project_index_dir", lambda: tmp_path) + with ( + patch("synapt.recall.journal._journal_path", return_value=tmp_path / "journal.jsonl"), + patch("synapt.recall.resume.load_resume_index", return_value=index), + patch("synapt.recall.resume.build_resume_view", return_value=view) as build, + patch("synapt.recall.freshness.check_index_freshness", side_effect=RuntimeError("no fs")), + patch("synapt.recall.resume.format_resume", return_value="RENDERED") as fmt, + ): + assert server.recall_resume(session_id="ab", turns=3) == "RENDERED" + + kwargs = build.call_args.kwargs + assert kwargs["session_id"] == "ab" + assert kwargs["limit"] == 3 + assert kwargs["journal_path"] == tmp_path / "journal.jsonl" + # Freshness failure must not break the tool: the view renders unchanged. + fmt.assert_called_once_with(view) + index._db.close.assert_called_once_with() + + +def test_recall_resume_is_registered_with_directive_check(): + from synapt.recall import server + + registered = [] + + class FakeMCP: + def tool(self): + def deco(fn): + registered.append(getattr(fn, "__name__", repr(fn))) + return fn + return deco + + server.register_tools(FakeMCP()) + assert "recall_resume" in registered + assert "recall_sessions" in registered