Skip to content

Commit e266365

Browse files
jpheinclaude
andcommitted
feat(checkpoint-split): route checkpoints to dedicated collection (phases A–C)
Implements the structural fix promoted by the 2026-04-25 Cat 9 A/B (632 vs 3 tokens per query): Stop-hook auto-save checkpoint diary entries no longer share an index with content drawers. Phase A — scaffolding (no behavior change): - _SESSION_RECOVERY_COLLECTION constant + get_session_recovery_collection() in palace.py, mirroring get_collection's shape (cosine + thread-pin). - New tests/test_session_recovery.py: 3 tests covering constant, metadata, multi-collection coexistence on one palace. Phase B — write routing (the behavior change): - tool_diary_write routes topic in _CHECKPOINT_TOPICS to the recovery collection; everything else stays in the main collection. - _get_session_recovery_collection() in mcp_server.py with parallel cache. - conftest._reset_mcp_cache resets the new cache between tests. - 4 tests in TestCheckpointRouting — checkpoint→recovery, auto-save→recovery, general→main, search regression (checkpoints structurally invisible to mempalace_search now, not merely post-filtered). Phase C — new read path: - tool_session_recovery_read reads from the recovery collection only, with optional filters: session_id, agent, since, until, wing, limit. - session_id added as optional metadata field on tool_diary_write so recovery reads can filter by Claude Code session. - Defensive None-metadata handling per the MemPalace#999 / MemPalace#1094 / MemPalace#1201 family. - Registered in TOOLS dict + documented in website/reference/mcp-tools.md (test_no_undocumented_tools regression caught the missing doc; fixed). - 5 tests in TestSessionRecoveryRead — empty case, session_id filter, agent filter, limit, None-metadata defense. Phases D (migration of existing checkpoints out of main collection) and E (palace-daemon startup integration) explicitly deferred — multi-day work, gated on a separate go-ahead. Full suite 1360/1360 pass; 106 benchmark/stress deselected per default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4e5147e commit e266365

6 files changed

Lines changed: 512 additions & 15 deletions

File tree

mempalace/mcp_server.py

Lines changed: 205 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
from .version import __version__ # noqa: E402
6060
from .backends.chroma import ChromaBackend, ChromaCollection, _pin_hnsw_threads # noqa: E402
6161
from .query_sanitizer import sanitize_query # noqa: E402
62-
from .searcher import search_memories # noqa: E402
62+
from .searcher import _CHECKPOINT_TOPICS, search_memories # noqa: E402
63+
from .palace import _SESSION_RECOVERY_COLLECTION # noqa: E402
6364
from .palace_graph import ( # noqa: E402
6465
traverse,
6566
find_tunnels,
@@ -105,6 +106,7 @@ def _parse_args():
105106

106107
_client_cache = None
107108
_collection_cache = None
109+
_recovery_collection_cache = None
108110
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
109111
_palace_db_mtime = 0.0 # mtime of chroma.sqlite3 at cache time
110112

@@ -174,6 +176,7 @@ def _get_client():
174176
global \
175177
_client_cache, \
176178
_collection_cache, \
179+
_recovery_collection_cache, \
177180
_palace_db_inode, \
178181
_palace_db_mtime, \
179182
_metadata_cache, \
@@ -204,6 +207,7 @@ def _get_client():
204207
if _client_cache is None or inode_changed or mtime_changed:
205208
_client_cache = ChromaBackend.make_client(_config.palace_path)
206209
_collection_cache = None
210+
_recovery_collection_cache = None
207211
_metadata_cache = None
208212
_metadata_cache_time = 0
209213
_palace_db_inode = current_inode
@@ -243,6 +247,37 @@ def _get_collection(create=False):
243247
return None
244248

245249

250+
def _get_session_recovery_collection(create=False):
251+
"""Return the session-recovery collection, caching between calls.
252+
253+
Stop-hook checkpoint diary entries route here instead of the main
254+
``mempalace_drawers`` collection so they don't dominate
255+
``mempalace_search`` results. Mirrors :func:`_get_collection`'s
256+
shape (same client cache, same ``_pin_hnsw_threads`` retrofit).
257+
"""
258+
global _recovery_collection_cache
259+
try:
260+
client = _get_client()
261+
if create:
262+
raw = client.get_or_create_collection(
263+
_SESSION_RECOVERY_COLLECTION,
264+
metadata={"hnsw:space": "cosine", "hnsw:num_threads": 1},
265+
)
266+
_pin_hnsw_threads(raw)
267+
_recovery_collection_cache = ChromaCollection(
268+
raw, palace_path=_config.palace_path
269+
)
270+
elif _recovery_collection_cache is None:
271+
raw = client.get_collection(_SESSION_RECOVERY_COLLECTION)
272+
_pin_hnsw_threads(raw)
273+
_recovery_collection_cache = ChromaCollection(
274+
raw, palace_path=_config.palace_path
275+
)
276+
return _recovery_collection_cache
277+
except Exception:
278+
return None
279+
280+
246281
def _no_palace():
247282
return {
248283
"error": "No palace found",
@@ -932,13 +967,25 @@ def tool_kg_stats():
932967
# ==================== AGENT DIARY ====================
933968

934969

935-
def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing: str = ""):
970+
def tool_diary_write(
971+
agent_name: str,
972+
entry: str,
973+
topic: str = "general",
974+
wing: str = "",
975+
session_id: str = "",
976+
):
936977
"""
937978
Write a diary entry for this agent. Entries are timestamped and
938979
accumulate over time in a diary room.
939980
940981
This is the agent's personal journal — observations, thoughts,
941982
what it worked on, what it noticed, what it thinks matters.
983+
984+
When ``topic`` is a checkpoint topic (``checkpoint`` / ``auto-save``)
985+
the entry is routed to the dedicated ``mempalace_session_recovery``
986+
collection so it doesn't dominate ``mempalace_search`` results.
987+
Pass ``session_id`` to enable filtering checkpoints by session via
988+
``mempalace_session_recovery_read``.
942989
"""
943990
try:
944991
agent_name = sanitize_name(agent_name, "agent_name")
@@ -951,7 +998,13 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing:
951998
else:
952999
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
9531000
room = "diary"
954-
col = _get_collection(create=True)
1001+
# Stop-hook auto-save checkpoint entries land in the dedicated
1002+
# session-recovery collection so they don't dominate vector ranking
1003+
# in mempalace_search. Read via mempalace_session_recovery_read.
1004+
if topic in _CHECKPOINT_TOPICS:
1005+
col = _get_session_recovery_collection(create=True)
1006+
else:
1007+
col = _get_collection(create=True)
9551008
if not col:
9561009
return _no_palace()
9571010

@@ -976,21 +1029,22 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing:
9761029
# semantic search quality. For now, store raw AAAK in metadata so it's
9771030
# preserved, and keep the document as-is for embedding (even though
9781031
# compressed AAAK degrades embedding quality).
1032+
meta = {
1033+
"wing": wing,
1034+
"room": room,
1035+
"hall": "hall_diary",
1036+
"topic": topic,
1037+
"type": "diary_entry",
1038+
"agent": agent_name,
1039+
"filed_at": now.isoformat(),
1040+
"date": now.strftime("%Y-%m-%d"),
1041+
}
1042+
if session_id:
1043+
meta["session_id"] = session_id
9791044
col.add(
9801045
ids=[entry_id],
9811046
documents=[entry],
982-
metadatas=[
983-
{
984-
"wing": wing,
985-
"room": room,
986-
"hall": "hall_diary",
987-
"topic": topic,
988-
"type": "diary_entry",
989-
"agent": agent_name,
990-
"filed_at": now.isoformat(),
991-
"date": now.strftime("%Y-%m-%d"),
992-
}
993-
],
1047+
metadatas=[meta],
9941048
)
9951049
logger.info(f"Diary entry: {entry_id}{wing}/diary/{topic}")
9961050
return {
@@ -1069,6 +1123,102 @@ def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
10691123
return {"error": "Failed to read diary entries"}
10701124

10711125

1126+
def tool_session_recovery_read(
1127+
session_id: str = "",
1128+
agent: str = "",
1129+
since: str = "",
1130+
until: str = "",
1131+
wing: str = "",
1132+
limit: int = 50,
1133+
):
1134+
"""
1135+
Read Stop-hook auto-save checkpoint entries from the dedicated
1136+
``mempalace_session_recovery`` collection. Used for session
1137+
recovery, hook auditing, and "what was I doing 2 hours ago" lookup.
1138+
1139+
All filters are optional — empty string / zero means "no filter":
1140+
1141+
- ``session_id``: only entries written under this Claude Code session
1142+
- ``agent``: only entries from this agent (typically ``session-hook``)
1143+
- ``since`` / ``until``: ISO date strings, inclusive bounds on filed_at
1144+
- ``wing``: only entries from this project wing
1145+
- ``limit``: maximum number of entries to return (default 50, max 500)
1146+
1147+
Entries are returned sorted by ``filed_at`` descending (newest first).
1148+
"""
1149+
try:
1150+
if agent:
1151+
agent = sanitize_name(agent, "agent")
1152+
if wing:
1153+
wing = sanitize_name(wing)
1154+
except ValueError as e:
1155+
return {"error": str(e), "entries": [], "total": 0}
1156+
1157+
limit = max(1, min(int(limit), 500))
1158+
col = _get_session_recovery_collection()
1159+
if not col:
1160+
# Recovery collection has never been created — no checkpoints
1161+
# have been written yet via the new routing. Return empty rather
1162+
# than erroring; caller's most likely interpretation is "no
1163+
# session-recovery data exists yet".
1164+
return {"entries": [], "total": 0}
1165+
1166+
# Build metadata where-clause from non-empty filters. ChromaDB needs
1167+
# a single condition or an explicit $and — we assemble accordingly.
1168+
conditions = []
1169+
if session_id:
1170+
conditions.append({"session_id": session_id})
1171+
if agent:
1172+
conditions.append({"agent": agent})
1173+
if wing:
1174+
conditions.append({"wing": wing})
1175+
1176+
where = None
1177+
if len(conditions) == 1:
1178+
where = conditions[0]
1179+
elif len(conditions) > 1:
1180+
where = {"$and": conditions}
1181+
1182+
try:
1183+
kwargs = {"include": ["documents", "metadatas"], "limit": 10000}
1184+
if where is not None:
1185+
kwargs["where"] = where
1186+
results = col.get(**kwargs)
1187+
except Exception:
1188+
logger.exception("session_recovery_read failed")
1189+
return {"error": "Failed to read recovery entries", "entries": [], "total": 0}
1190+
1191+
if not results.get("ids"):
1192+
return {"entries": [], "total": 0}
1193+
1194+
entries = []
1195+
for doc, meta in zip(results["documents"], results["metadatas"]):
1196+
# Defensive: ChromaDB may return None metadata for legacy /
1197+
# partial-write drawers (cf. #999, #1094, #1201). Coerce to {}.
1198+
meta = meta or {}
1199+
filed_at = meta.get("filed_at", "") or ""
1200+
if since and filed_at and filed_at < since:
1201+
continue
1202+
if until and filed_at and filed_at > until:
1203+
continue
1204+
entries.append(
1205+
{
1206+
"date": meta.get("date", ""),
1207+
"timestamp": filed_at,
1208+
"topic": meta.get("topic", ""),
1209+
"agent": meta.get("agent", ""),
1210+
"wing": meta.get("wing", ""),
1211+
"session_id": meta.get("session_id", ""),
1212+
"content": doc,
1213+
}
1214+
)
1215+
1216+
entries.sort(key=lambda x: x["timestamp"], reverse=True)
1217+
entries = entries[:limit]
1218+
1219+
return {"entries": entries, "total": len(entries)}
1220+
1221+
10721222
def tool_hook_settings(silent_save: bool = None, desktop_toast: bool = None):
10731223
"""
10741224
Get or set hook behavior settings.
@@ -1564,6 +1714,46 @@ def tool_reconnect():
15641714
},
15651715
"handler": tool_diary_read,
15661716
},
1717+
"mempalace_session_recovery_read": {
1718+
"description": (
1719+
"Read Stop-hook auto-save checkpoint entries from the dedicated "
1720+
"session-recovery collection. Use for session recovery, hook "
1721+
"auditing, or 'what was I doing 2 hours ago' lookup. Filters are "
1722+
"all optional; empty string / 0 means 'no filter'. Returns "
1723+
"entries newest-first."
1724+
),
1725+
"input_schema": {
1726+
"type": "object",
1727+
"properties": {
1728+
"session_id": {
1729+
"type": "string",
1730+
"description": "Filter by Claude Code session id.",
1731+
},
1732+
"agent": {
1733+
"type": "string",
1734+
"description": "Filter by agent name (typically 'session-hook').",
1735+
},
1736+
"since": {
1737+
"type": "string",
1738+
"description": "ISO datetime string — entries strictly newer than this.",
1739+
},
1740+
"until": {
1741+
"type": "string",
1742+
"description": "ISO datetime string — entries strictly older than this.",
1743+
},
1744+
"wing": {
1745+
"type": "string",
1746+
"description": "Filter by project wing.",
1747+
},
1748+
"limit": {
1749+
"type": "integer",
1750+
"description": "Max entries to return (default 50, max 500).",
1751+
},
1752+
},
1753+
"required": [],
1754+
},
1755+
"handler": tool_session_recovery_read,
1756+
},
15671757
"mempalace_hook_settings": {
15681758
"description": (
15691759
"Get or set hook behavior. silent_save: True = save directly "

mempalace/palace.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,23 @@ def get_closets_collection(palace_path: str, create: bool = True):
7171
return get_collection(palace_path, collection_name="mempalace_closets", create=create)
7272

7373

74+
# Stop-hook auto-save checkpoint diary entries are routed to this
75+
# dedicated collection so they don't dominate ``mempalace_search``
76+
# results in the main ``mempalace_drawers`` collection. Read via the
77+
# ``mempalace_session_recovery_read`` MCP tool. See
78+
# ``docs/superpowers/specs/2026-04-25-checkpoint-collection-split.md``.
79+
_SESSION_RECOVERY_COLLECTION = "mempalace_session_recovery"
80+
81+
82+
def get_session_recovery_collection(palace_path: str, create: bool = True):
83+
"""Get the session-recovery collection — Stop-hook checkpoint storage."""
84+
return get_collection(
85+
palace_path,
86+
collection_name=_SESSION_RECOVERY_COLLECTION,
87+
create=create,
88+
)
89+
90+
7491
CLOSET_CHAR_LIMIT = 1500 # fill closet until ~1500 chars, then start a new one
7592
CLOSET_EXTRACT_WINDOW = 5000 # how many chars of source content to scan for entities/topics
7693

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def _clear_cache():
4444

4545
mcp_server._client_cache = None
4646
mcp_server._collection_cache = None
47+
mcp_server._recovery_collection_cache = None
4748
except (ImportError, AttributeError):
4849
pass
4950

0 commit comments

Comments
 (0)