Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions src/basic_memory/cli/commands/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
Expand All @@ -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; "
Expand Down
28 changes: 28 additions & 0 deletions tests/cli/test_coding_session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 12 additions & 9 deletions tests/cli/test_hook_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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

Expand Down