11from __future__ import annotations
22
33import json
4+ import time
45from datetime import UTC , datetime , timedelta
56from pathlib import Path
6- from typing import Any
7+ from typing import Any , Iterable
78
89from ..config import get_home_dir
10+ from ..session_reflection .guard import evaluate_archive_guard
911from .parser import parse_codex_jsonl
1012from .store import SessionRecallStore
1113
14+ DISCOVERY_MTIME_WINDOW_SECONDS = 15 * 60
15+ DISCOVERY_MAX_RECENT_FILES = 80
16+
1217
1318def default_db_path (home : str | Path | None = None , state_dir : str | Path | None = None ) -> Path :
1419 if state_dir :
@@ -47,12 +52,15 @@ def archive_from_hook_payload(
4752 * ,
4853 db_path : str | Path | None = None ,
4954 cleanup_payload : bool = False ,
55+ sessions_root : str | Path | None = None ,
5056) -> dict [str , Any ]:
5157 path = Path (payload_path ).expanduser ().resolve ()
58+ resolved_db_path = Path (db_path ).expanduser ().resolve () if db_path else default_db_path ()
59+ home = _home_from_db_path (resolved_db_path )
5260 try :
5361 payload = json .loads (path .read_text (encoding = "utf-8" ))
5462 except Exception as exc : # noqa: BLE001
55- store = SessionRecallStore (db_path or default_db_path () )
63+ store = SessionRecallStore (resolved_db_path )
5664 try :
5765 store .record_error (source_path = str (path ), error = f"{ type (exc ).__name__ } : { exc } " )
5866 finally :
@@ -65,7 +73,7 @@ def archive_from_hook_payload(
6573 except OSError :
6674 pass
6775 if not isinstance (payload , dict ):
68- store = SessionRecallStore (db_path or default_db_path () )
76+ store = SessionRecallStore (resolved_db_path )
6977 try :
7078 store .record_error (source_path = str (path ), error = "payload is not an object" )
7179 finally :
@@ -74,14 +82,116 @@ def archive_from_hook_payload(
7482 session_id = str (payload .get ("session_id" ) or payload .get ("thread_id" ) or "unknown-session" )
7583 transcript_path = str (payload .get ("transcript_path" ) or payload .get ("codex_transcript_path" ) or "" )
7684 cwd = str (payload .get ("cwd" ) or "." )
85+
86+ guard = evaluate_archive_guard (payload , home = home )
87+ if guard .skip :
88+ return {
89+ "status" : "skipped" ,
90+ "reason" : guard .reason ,
91+ "detail" : guard .detail ,
92+ "session_id" : session_id ,
93+ "message_count" : 0 ,
94+ "db_path" : str (resolved_db_path ),
95+ }
96+
7797 if not transcript_path :
78- store = SessionRecallStore (db_path or default_db_path ())
98+ discovery = discover_transcript_for_hook_payload (payload , sessions_root = sessions_root )
99+ if discovery ["status" ] == "found" :
100+ discovered_guard = evaluate_archive_guard ({** payload , "transcript_path" : discovery ["path" ]}, home = home )
101+ if discovered_guard .skip :
102+ return {
103+ "status" : "skipped" ,
104+ "reason" : discovered_guard .reason ,
105+ "detail" : discovered_guard .detail ,
106+ "session_id" : session_id ,
107+ "message_count" : 0 ,
108+ "db_path" : str (resolved_db_path ),
109+ "discovery_status" : discovery ["status" ],
110+ "discovery_reason" : discovery ["reason" ],
111+ "discovered_transcript_path" : discovery ["path" ],
112+ }
113+ result = archive_transcript (
114+ discovery ["path" ],
115+ session_id = session_id ,
116+ cwd = cwd ,
117+ db_path = resolved_db_path ,
118+ )
119+ result ["discovery_status" ] = discovery ["status" ]
120+ result ["discovery_reason" ] = discovery ["reason" ]
121+ result ["discovered_transcript_path" ] = discovery ["path" ]
122+ return result
123+
124+ store = SessionRecallStore (resolved_db_path )
125+ error = f"missing transcript_path; { discovery ['status' ]} "
126+ if discovery .get ("candidate_count" ):
127+ error += f"; candidates={ discovery ['candidate_count' ]} "
128+ keys = "," .join (sorted (str (key ) for key in payload .keys ()))
129+ if keys :
130+ error += f"; payload_keys={ keys } "
79131 try :
80- store .record_error (session_id = session_id , cwd = cwd , error = "missing transcript_path" )
132+ store .record_error (session_id = session_id , cwd = cwd , error = error )
81133 finally :
82134 store .close ()
83- return {"status" : "error" , "session_id" : session_id , "message_count" : 0 , "error" : "missing transcript_path" }
84- return archive_transcript (transcript_path , session_id = session_id , cwd = cwd , db_path = db_path )
135+ return {"status" : "error" , "session_id" : session_id , "message_count" : 0 , "error" : error }
136+ return archive_transcript (transcript_path , session_id = session_id , cwd = cwd , db_path = resolved_db_path )
137+
138+
139+ def discover_transcript_for_hook_payload (
140+ payload : dict [str , Any ],
141+ * ,
142+ sessions_root : str | Path | None = None ,
143+ ) -> dict [str , Any ]:
144+ """Find a high-confidence transcript for a Stop payload missing transcript_path."""
145+ root = Path (sessions_root ).expanduser ().resolve () if sessions_root else Path .home () / ".codex" / "sessions"
146+ if not root .exists ():
147+ return {"status" : "discovery_not_found" , "reason" : "sessions_root_missing" , "path" : "" , "candidate_count" : 0 }
148+
149+ session_id = str (payload .get ("session_id" ) or payload .get ("thread_id" ) or "" )
150+ if session_id :
151+ matches = _unique_existing_files (path for path in root .rglob ("*.jsonl" ) if session_id in path .name )
152+ if len (matches ) == 1 :
153+ return _discovered (matches [0 ], "filename_session_id" )
154+ if len (matches ) > 1 :
155+ return {
156+ "status" : "discovery_ambiguous" ,
157+ "reason" : "filename_session_id" ,
158+ "path" : "" ,
159+ "candidate_count" : len (matches ),
160+ }
161+
162+ recent_files = _recent_transcripts (root )
163+ if session_id :
164+ meta_matches = [
165+ path for path in recent_files
166+ if _session_meta (path ).get ("id" ) == session_id
167+ ]
168+ if len (meta_matches ) == 1 :
169+ return _discovered (meta_matches [0 ], "meta_session_id" )
170+ if len (meta_matches ) > 1 :
171+ return {
172+ "status" : "discovery_ambiguous" ,
173+ "reason" : "meta_session_id" ,
174+ "path" : "" ,
175+ "candidate_count" : len (meta_matches ),
176+ }
177+
178+ cwd = str (payload .get ("cwd" ) or "" )
179+ if cwd :
180+ cwd_matches = [
181+ path for path in recent_files
182+ if _same_path (str (_session_meta (path ).get ("cwd" ) or "" ), cwd )
183+ ]
184+ if len (cwd_matches ) == 1 :
185+ return _discovered (cwd_matches [0 ], "recent_cwd_unique" )
186+ if len (cwd_matches ) > 1 :
187+ return {
188+ "status" : "discovery_ambiguous" ,
189+ "reason" : "recent_cwd_unique" ,
190+ "path" : "" ,
191+ "candidate_count" : len (cwd_matches ),
192+ }
193+
194+ return {"status" : "discovery_not_found" , "reason" : "no_high_confidence_match" , "path" : "" , "candidate_count" : 0 }
85195
86196
87197def backfill_sessions (
@@ -173,6 +283,59 @@ def _session_id_from_filename(path: Path) -> str:
173283 return stem
174284
175285
286+ def _home_from_db_path (db_path : Path ) -> Path :
287+ """Infer CSEP home from the standard session_recall/state.db path."""
288+ if db_path .name == "state.db" and db_path .parent .name == "session_recall" :
289+ return db_path .parent .parent
290+ return get_home_dir ()
291+
292+
293+ def _unique_existing_files (paths : Iterable [Path ]) -> list [Path ]:
294+ return sorted ({path .expanduser ().resolve () for path in paths if path .is_file ()})
295+
296+
297+ def _recent_transcripts (root : Path ) -> list [Path ]:
298+ now = time .time ()
299+ files = [
300+ path for path in root .rglob ("*.jsonl" )
301+ if path .is_file () and now - _file_mtime (path ) <= DISCOVERY_MTIME_WINDOW_SECONDS
302+ ]
303+ return sorted (files , key = lambda path : (_file_mtime (path ), str (path )), reverse = True )[:DISCOVERY_MAX_RECENT_FILES ]
304+
305+
306+ def _session_meta (path : Path ) -> dict [str , Any ]:
307+ try :
308+ with path .open ("r" , encoding = "utf-8" , errors = "replace" ) as handle :
309+ for idx , raw in enumerate (handle ):
310+ if idx >= 32 :
311+ break
312+ try :
313+ entry = json .loads (raw )
314+ except ValueError :
315+ continue
316+ if isinstance (entry , dict ) and entry .get ("type" ) == "session_meta" :
317+ payload = entry .get ("payload" )
318+ return payload if isinstance (payload , dict ) else {}
319+ except OSError :
320+ return {}
321+ return {}
322+
323+
324+ def _same_path (left : str , right : str ) -> bool :
325+ if not left or not right :
326+ return False
327+ return Path (left ).expanduser ().resolve (strict = False ) == Path (right ).expanduser ().resolve (strict = False )
328+
329+
330+ def _discovered (path : Path , reason : str ) -> dict [str , Any ]:
331+ return {
332+ "status" : "found" ,
333+ "reason" : reason ,
334+ "path" : str (path ),
335+ "candidate_count" : 1 ,
336+ }
337+
338+
176339def _file_mtime (path : Path ) -> float :
177340 try :
178341 return path .stat ().st_mtime
0 commit comments