5959from .version import __version__ # noqa: E402
6060from .backends .chroma import ChromaBackend , ChromaCollection , _pin_hnsw_threads # noqa: E402
6161from .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
6364from .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+
246281def _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+
10721222def 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 "
0 commit comments