Skip to content

Commit 4adeff9

Browse files
000specificclaude
andcommitted
server/capture: fix on-demand "Save Chat!" (003) for ancestor-launched sessions + Python 3.9 f-strings
Two bugs made 003 (on-demand session capture) non-functional in the GIGANTIC platform layout where Claude Code is launched at the GIGANTIC root (an ancestor of gigantic_project-*) rather than inside gigantic_project-* itself: - Path discovery: the source ~/.claude/projects/<encoded> dir was derived from the gigantic_project-* root, so for ancestor-launched sessions it pointed at a directory that does not exist -> "No Claude session storage found", captured nothing. Now find_claude_jsonl_directory() walks up from the project root to the deepest ancestor whose encoded session dir exists (the launch root), with a new --source-root override for explicit control. Destination is unchanged (gigantic_project-*/research_notebook/research_ai/sessions/). - Invalid f-string format specs: four f-strings used "{x:.1f }" (trailing space in the format spec), which raises ValueError: Invalid format specifier on Python 3.9 -> the capture/log step crashed after copying. Fixed to "{x:.1f}". Verified on species70 layout: --dry-run and a real run now auto-detect the GIGANTIC-root session dir, capture + log correctly (exit 0), and --source-root override resolves explicitly. The PreCompact hook (002) was unaffected (Claude passes it the transcript path directly). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e795b9 commit 4adeff9

1 file changed

Lines changed: 67 additions & 9 deletions

File tree

gigantic_project-COPYME/ai/ai_scripts/003_ai-python-copy_session_jsonls.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#!/usr/bin/env python3
22
# AI: Claude Code | Opus 4.7 (1M context) | 2026 May 25 | Purpose: On-demand "Save Chat!" raw-copy of every Claude Code session JSONL for this project into research_notebook/research_ai/sessions/ as lossless gzipped permanent archives
3+
# AI: Claude Code | Opus 4.8 (1M context) | 2026 June 28 | Fix: auto-detect Claude's session dir when sessions were launched at an ANCESTOR of the gigantic_project-* root (e.g. the GIGANTIC platform root); add --source-root override; fix invalid f-string format specs ({x:.1f }) that crashed capture on Python 3.9
34
# Human: Eric Edsinger
45

56
"""
@@ -18,13 +19,20 @@
1819
recent capture
1920
2021
Usage:
21-
python3 ai/ai_scripts/003_ai-python-copy_session_jsonls.py [--dry-run]
22+
python3 ai/ai_scripts/003_ai-python-copy_session_jsonls.py [--dry-run] [--source-root DIR]
2223
23-
--dry-run Print what would be captured without writing anything.
24+
--dry-run Print what would be captured without writing anything.
25+
--source-root DIR The directory Claude Code was LAUNCHED from (used only to
26+
locate ~/.claude/projects/<encoded>). Override auto-detection
27+
when the launch root differs from the gigantic_project-* root.
2428
2529
The script:
26-
1. Locates Claude's per-project JSONL directory based on this project's path
27-
(~/.claude/projects/<encoded-path>/)
30+
1. Locates Claude's per-project JSONL directory. Claude keys it on the directory
31+
Claude Code was LAUNCHED from, which may be the gigantic_project-* root
32+
(outside-user fork) OR an ancestor such as the GIGANTIC platform root (Eric's
33+
layout). Auto-detected by walking up from the project root to the deepest
34+
ancestor whose ~/.claude/projects/<encoded> directory exists; override with
35+
--source-root.
2836
2. Scans every *.jsonl session file
2937
3. For each session: reads session_id, model, and last-message timestamp
3038
4. Constructs destination filename matching the hook's format:
@@ -81,6 +89,43 @@ def get_claude_project_jsonl_directory( project_root: Path ) -> Path:
8189
return Path.home() / '.claude' / 'projects' / encoded_path
8290

8391

92+
def find_claude_jsonl_directory( project_root: Path, source_root_override=None ) -> Path:
93+
"""
94+
Locate Claude Code's per-project JSONL directory (~/.claude/projects/<encoded>).
95+
96+
Claude keys this directory on the path Claude Code was LAUNCHED from, which is
97+
not always the gigantic_project-* root:
98+
- Outside-user fork ("Pattern B"): launched inside the gigantic_project-*
99+
directory -> encoded( project_root ) matches.
100+
- GIGANTIC platform layout ("Pattern A", Eric): launched at the GIGANTIC
101+
parent (an ANCESTOR of gigantic_project-*) -> encoded( project_root ) does
102+
NOT exist; the real directory is keyed to the ancestor.
103+
104+
If source_root_override is given, use encoded( that path ) directly. Otherwise
105+
walk up from project_root and return the DEEPEST ancestor whose encoded
106+
~/.claude/projects directory exists (the launch root for this project). Falls
107+
back to encoded( project_root ) if none exists (caller handles 'not found').
108+
"""
109+
110+
if source_root_override is not None:
111+
override_directory = get_claude_project_jsonl_directory( Path( source_root_override ).resolve() )
112+
if not override_directory.exists():
113+
raise RuntimeError(
114+
f"--source-root { source_root_override }: no Claude session directory at "
115+
f"{ override_directory }. Pass the directory Claude Code was launched from."
116+
)
117+
return override_directory
118+
119+
candidate = project_root.resolve()
120+
while True:
121+
candidate_directory = get_claude_project_jsonl_directory( candidate )
122+
if candidate_directory.exists():
123+
return candidate_directory
124+
if candidate == candidate.parent:
125+
return get_claude_project_jsonl_directory( project_root )
126+
candidate = candidate.parent
127+
128+
84129
def read_session_metadata( jsonl_path: Path ) -> dict:
85130
"""
86131
Extract session_id, model, and last-message timestamp from a JSONL file
@@ -170,18 +215,29 @@ def append_to_capture_log( log_path: Path, captured: list ):
170215
f"| { capture[ 'session_short_id' ] }... "
171216
f"| { capture[ 'model' ] } "
172217
f"| save_chat "
173-
f"| { capture[ 'source_size_mb' ]:.1f } MB "
218+
f"| { capture[ 'source_size_mb' ]:.1f} MB "
174219
f"| { capture[ 'filename' ] } |\n"
175220
)
176221
log_file.write( output )
177222

178223

224+
def parse_source_root_argument( argv ) -> str:
225+
"""Read the optional --source-root VALUE (or --source-root=VALUE) from argv."""
226+
for index, token in enumerate( argv ):
227+
if token == '--source-root' and index + 1 < len( argv ):
228+
return argv[ index + 1 ]
229+
if token.startswith( '--source-root=' ):
230+
return token.split( '=', 1 )[ 1 ]
231+
return None
232+
233+
179234
def main():
180235
dry_run = '--dry-run' in sys.argv
236+
source_root_override = parse_source_root_argument( sys.argv )
181237

182238
script_directory = Path( __file__ ).resolve().parent
183239
project_root = find_gigantic_project_root( script_directory )
184-
claude_jsonl_directory = get_claude_project_jsonl_directory( project_root )
240+
claude_jsonl_directory = find_claude_jsonl_directory( project_root, source_root_override )
185241

186242
print( f"\n=== Save Chat!{ ' (DRY RUN — no files written)' if dry_run else '' } ===" )
187243
print( f" Project root: { project_root }" )
@@ -193,6 +249,8 @@ def main():
193249
print( f" or if the AI session is rooted somewhere other than this project's root." )
194250
print( f" Claude Code stores transcripts continuously once a session is active;" )
195251
print( f" if you just opened the session, send a few messages first then re-run." )
252+
print( f" If the session was launched from a different directory, pass it with" )
253+
print( f" --source-root <launch directory>." )
196254
sys.exit( 0 )
197255

198256
session_jsonls = sorted( claude_jsonl_directory.glob( '*.jsonl' ) )
@@ -229,7 +287,7 @@ def main():
229287
source_size_mb = jsonl_path.stat().st_size / ( 1024 * 1024 )
230288

231289
if dry_run:
232-
print( f" ~ would write: { output_filename } ({ source_size_mb:.1f } MB source)" )
290+
print( f" ~ would write: { output_filename } ({ source_size_mb:.1f} MB source)" )
233291
captured.append( {
234292
'filename': output_filename,
235293
'session_short_id': metadata[ 'session_id' ][ :8 ],
@@ -254,7 +312,7 @@ def main():
254312
'source_size_mb': source_size_mb,
255313
'destination_size_mb': destination_size_mb,
256314
} )
257-
print( f" + { output_filename } ({ source_size_mb:.1f } MB -> { destination_size_mb:.1f } MB gzipped)" )
315+
print( f" + { output_filename } ({ source_size_mb:.1f} MB -> { destination_size_mb:.1f} MB gzipped)" )
258316
except Exception as exception:
259317
print( f" ! Error processing { jsonl_path.name }: { exception }" )
260318
error_count += 1
@@ -269,7 +327,7 @@ def main():
269327
print( f" Errors: { error_count }" )
270328
if captured and not dry_run:
271329
total_destination_mb = sum( c[ 'destination_size_mb' ] for c in captured )
272-
print( f" Total written: { total_destination_mb:.1f } MB (gzipped)" )
330+
print( f" Total written: { total_destination_mb:.1f} MB (gzipped)" )
273331
print( f"\nDone. Captures are lossless gzipped JSONL — never edit or delete." )
274332

275333

0 commit comments

Comments
 (0)