From 354485e269fea5af102bee17f148771c307d49ba Mon Sep 17 00:00:00 2001 From: Shunkleburger <128416007+Shunkleburger@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:08:19 -0700 Subject: [PATCH] fix(cli): recall general checkpoints at session start Signed-off-by: Shunkleburger <128416007+Shunkleburger@users.noreply.github.com> --- src/basic_memory/cli/commands/hook.py | 51 ++++++++++++++++++------ tests/cli/test_coding_session_context.py | 28 +++++++++++++ tests/cli/test_hook_command.py | 21 +++++----- 3 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index 66595af4a..a5f252c4c 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -80,6 +80,7 @@ class Harness(str, Enum): # Cap how many shared projects we read per session — bounds latency and output. MAX_SHARED = 6 CODING_SESSION_PROFILE = "coding" +CURRENT_DECISION_STATUSES = ("active", "open") DEFAULT_CAPTURE_EVENTS = True CODEX_DEFAULT_CHECKPOINT_ON_COMPACT = True CODEX_CHECKPOINT_PROMPT = ( @@ -631,22 +632,44 @@ async def _gather_context( ) ) # General checkpoints are a lower-priority path because coding_session - # results carry repository identity and are therefore merged first. - session_queries.append( - _query(project, note_types=list(profile.recall_session_types), after_date=timeframe) - ) + # results carry repository identity and are therefore merged first. A + # generic checkpoint has no repository boundary, so only recall it for a + # general session rather than leaking another checkout into coding context. + recall_session_types = list(profile.recall_session_types) + if repository is None: + recall_session_types.append("checkpoint") + session_queries.append(_query(project, note_types=recall_session_types, after_date=timeframe)) + decision_queries = [ + _query(project, note_types=["decision"], status=status) + for status in CURRENT_DECISION_STATUSES + ] + shared_decision_queries = [ + _query(ref, note_types=["decision"], status=status) + for ref in shared_refs + for status in CURRENT_DECISION_STATUSES + ] results = await asyncio.gather( _query(project, note_types=["task"], status="active"), - _query(project, note_types=["decision"], status="open"), + *decision_queries, *session_queries, - *[_query(ref, note_types=["decision"], status="open") for ref in shared_refs], + *shared_decision_queries, ) - session_end = 2 + len(session_queries) + decision_end = 1 + len(decision_queries) + session_end = decision_end + len(session_queries) + shared = { + ref: _merge_search_results( + results[ + session_end + index * len(CURRENT_DECISION_STATUSES) : session_end + + (index + 1) * len(CURRENT_DECISION_STATUSES) + ] + ) + for index, ref in enumerate(shared_refs) + } return _BriefContext( tasks=results[0], - decisions=results[1], - sessions=_merge_search_results(results[2:session_end]), - shared=dict(zip(shared_refs, results[session_end:])), + decisions=_merge_search_results(results[1:decision_end]), + sessions=_merge_search_results(results[decision_end:session_end]), + shared=shared, ) @@ -780,7 +803,11 @@ def _build_brief( if task_rows: data_lines += ["", f"## Active tasks ({len(task_rows)})", *map(_label, task_rows)] if decision_rows: - data_lines += ["", f"## Open decisions ({len(decision_rows)})", *map(_label, decision_rows)] + data_lines += [ + "", + f"## Current decisions ({len(decision_rows)})", + *map(_label, decision_rows), + ] if session_rows: session_lines = [ line @@ -802,7 +829,7 @@ def _build_brief( if shared_sections: data_lines += ["", "## From shared projects (read-only)"] for ref, items in shared_sections: - data_lines += [f"### {_readable(ref)} — open decisions", *map(_label, items)] + data_lines += [f"### {_readable(ref)} — current decisions", *map(_label, items)] data_lines += [ "", "_Shared-project context is read-only. Your captures stay in this project; " diff --git a/tests/cli/test_coding_session_context.py b/tests/cli/test_coding_session_context.py index f04ea5797..93f448440 100644 --- a/tests/cli/test_coding_session_context.py +++ b/tests/cli/test_coding_session_context.py @@ -186,6 +186,34 @@ async def fake_query(project: str | None, **filters: object) -> dict[str, Any]: assert all(query.get("note_types") != ["codex_session", "session"] for query in queries) +def test_general_recall_includes_checkpoints_and_current_decisions() -> None: + queries: list[dict[str, object]] = [] + + async def fake_query(project: str | None, **filters: object) -> dict[str, Any]: + queries.append({"project": project, **filters}) + if filters.get("note_types") == ["codex_session", "checkpoint"]: + return {"results": [{"title": "Recent checkpoint", "permalink": "codex/recent"}]} + if filters.get("note_types") == ["decision"] and filters.get("status") == "active": + return {"results": [{"title": "Active decision", "permalink": "decisions/active"}]} + if filters.get("note_types") == ["decision"] and filters.get("status") == "open": + return {"results": [{"title": "Open decision", "permalink": "decisions/open"}]} + return {"results": []} + + profile = hook_module.PROFILES[hook_module.Harness.codex] + with patch.object(hook_module, "_query", side_effect=fake_query): + context = asyncio.run(hook_module._gather_context(profile, "demo", "7d", [])) + + assert [row["title"] for row in hook_module._rows(context.sessions)] == ["Recent checkpoint"] + assert [row["title"] for row in hook_module._rows(context.decisions)] == [ + "Active decision", + "Open decision", + ] + assert any(query.get("note_types") == ["codex_session", "checkpoint"] for query in queries) + assert { + query.get("status") for query in queries if query.get("note_types") == ["decision"] + } == {"active", "open"} + + def test_coding_recall_requires_configured_repository() -> None: profile = hook_module.PROFILES[hook_module.Harness.codex] with patch.object(hook_module, "_gather_context") as gather_context: diff --git a/tests/cli/test_hook_command.py b/tests/cli/test_hook_command.py index c3750f497..07fed09d5 100644 --- a/tests/cli/test_hook_command.py +++ b/tests/cli/test_hook_command.py @@ -174,7 +174,8 @@ def test_session_start_configured_but_unreachable_signals_status( def test_session_start_brief_is_fenced_and_labeled(bm_home: Path, claude_project: Path) -> None: results = [ _search_result("Ship login fix"), # active tasks - _search_result("Use SQLite WAL"), # open decisions + _search_result("Use SQLite WAL"), # active decisions + SEARCH_EMPTY, # open decisions _search_result("Session 2026-07-14"), # recent sessions ] with patch("basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, side_effect=results): @@ -192,7 +193,7 @@ def test_session_start_brief_is_fenced_and_labeled(bm_home: Path, claude_project fenced = result.stdout.split("`````")[1] assert "## Active tasks (1)" in fenced assert "- Ship login fix — notes/ship-login-fix" in fenced - assert "## Open decisions (1)" in fenced + assert "## Current decisions (1)" in fenced assert "## Recent sessions (1) — where you left off" in fenced # Placement guidance and the recall prompt stay outside the fence. assert "## Where to write" in result.stdout @@ -269,7 +270,7 @@ async def fake_search(**kwargs): assert "reading 2 shared project(s)" in result.stdout assert "## From shared projects (read-only)" in result.stdout - assert "### team-notes — open decisions" in result.stdout + assert "### team-notes — current decisions" in result.stdout assert "Decision from platform" in result.stdout assert "decisions in decisions/" in result.stdout @@ -441,13 +442,15 @@ def test_session_start_codex_profile(bm_home: Path, tmp_path: Path) -> None: assert result.exit_code == 0 # Codex recalls durable checkpoints, not locally archived lifecycle trace. - session_query = mock_search.await_args_list[2].kwargs - assert session_query["note_types"] == ["codex_session"] + session_query = mock_search.await_args_list[3].kwargs + assert session_query["note_types"] == ["codex_session", "checkpoint"] assert session_query["after_date"] == "7d" assert "codex/" in result.stdout -def test_session_start_codex_does_not_query_lifecycle_trace(bm_home: Path, tmp_path: Path) -> None: +def test_session_start_codex_recalls_checkpoints_not_lifecycle_trace( + bm_home: Path, tmp_path: Path +) -> None: project = tmp_path / "codex-proj" (project / ".codex").mkdir(parents=True) (project / ".codex" / "basic-memory.json").write_text( @@ -465,7 +468,7 @@ def test_session_start_codex_does_not_query_lifecycle_trace(bm_home: Path, tmp_p ) assert result.exit_code == 0 - assert mock_search.await_args_list[2].kwargs["note_types"] == ["codex_session"] + assert mock_search.await_args_list[3].kwargs["note_types"] == ["codex_session", "checkpoint"] def test_session_start_pi_profile_reads_project_config(bm_home: Path, tmp_path: Path) -> None: @@ -487,9 +490,9 @@ def test_session_start_pi_profile_reads_project_config(bm_home: Path, tmp_path: ) assert result.exit_code == 0 - session_query = mock_search.await_args_list[2].kwargs + session_query = mock_search.await_args_list[3].kwargs assert session_query["project"] == "demo" - assert session_query["note_types"] == ["pi_session"] + assert session_query["note_types"] == ["pi_session", "checkpoint"] assert session_query["after_date"] == "2d" assert "pi/sessions/" in result.stdout