claude-code-tools 1.0.6__py3-none-any.whl → 1.4.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_code_tools/__init__.py +1 -1
- claude_code_tools/action_rpc.py +16 -10
- claude_code_tools/aichat.py +793 -51
- claude_code_tools/claude_continue.py +4 -0
- claude_code_tools/codex_continue.py +48 -0
- claude_code_tools/export_session.py +94 -11
- claude_code_tools/find_claude_session.py +36 -12
- claude_code_tools/find_codex_session.py +33 -18
- claude_code_tools/find_session.py +30 -16
- claude_code_tools/gdoc2md.py +220 -0
- claude_code_tools/md2gdoc.py +549 -0
- claude_code_tools/search_index.py +119 -15
- claude_code_tools/session_menu_cli.py +1 -1
- claude_code_tools/session_utils.py +3 -3
- claude_code_tools/smart_trim.py +18 -8
- claude_code_tools/smart_trim_core.py +4 -2
- claude_code_tools/tmux_cli_controller.py +35 -25
- claude_code_tools/trim_session.py +28 -2
- claude_code_tools-1.4.6.dist-info/METADATA +1112 -0
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/RECORD +31 -24
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/entry_points.txt +2 -0
- docs/linked-in-20260102.md +32 -0
- docs/local-llm-setup.md +286 -0
- docs/reddit-aichat-resume-v2.md +80 -0
- docs/reddit-aichat-resume.md +29 -0
- docs/reddit-aichat.md +79 -0
- docs/rollover-details.md +67 -0
- node_ui/action_config.js +3 -3
- node_ui/menu.js +67 -113
- claude_code_tools/session_tui.py +0 -516
- claude_code_tools-1.0.6.dist-info/METADATA +0 -685
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/WHEEL +0 -0
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import math
|
|
5
|
+
import shutil
|
|
5
6
|
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from datetime import datetime
|
|
9
|
+
from importlib.metadata import version as get_pkg_version
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Any, Optional
|
|
10
12
|
|
|
13
|
+
from claude_code_tools.export_session import _is_meta_user_message
|
|
11
14
|
from claude_code_tools.session_utils import is_valid_session
|
|
12
15
|
|
|
16
|
+
|
|
17
|
+
def _get_package_version() -> str:
|
|
18
|
+
"""Get installed package version for automatic index rebuilding."""
|
|
19
|
+
try:
|
|
20
|
+
return get_pkg_version("claude-code-tools")
|
|
21
|
+
except Exception:
|
|
22
|
+
return "unknown"
|
|
23
|
+
|
|
13
24
|
# Lazy imports to allow module to load even if deps not installed
|
|
14
25
|
try:
|
|
15
26
|
import yaml
|
|
@@ -167,6 +178,7 @@ class SessionIndex:
|
|
|
167
178
|
self.schema_builder.add_text_field("first_msg_content", stored=True)
|
|
168
179
|
self.schema_builder.add_text_field("last_msg_role", stored=True)
|
|
169
180
|
self.schema_builder.add_text_field("last_msg_content", stored=True)
|
|
181
|
+
self.schema_builder.add_text_field("first_user_msg_content", stored=True)
|
|
170
182
|
|
|
171
183
|
# Session type fields (for filtering in TUI)
|
|
172
184
|
self.schema_builder.add_text_field("derivation_type", stored=True)
|
|
@@ -176,6 +188,9 @@ class SessionIndex:
|
|
|
176
188
|
# Use "raw" tokenizer so paths are indexed as single tokens for exact matching
|
|
177
189
|
self.schema_builder.add_text_field("claude_home", stored=True, tokenizer_name="raw")
|
|
178
190
|
|
|
191
|
+
# Custom title field (from /rename command)
|
|
192
|
+
self.schema_builder.add_text_field("custom_title", stored=True)
|
|
193
|
+
|
|
179
194
|
# Searchable content field
|
|
180
195
|
self.schema_builder.add_text_field("content", stored=True)
|
|
181
196
|
|
|
@@ -184,10 +199,43 @@ class SessionIndex:
|
|
|
184
199
|
# Create or open index
|
|
185
200
|
self.index_path.mkdir(parents=True, exist_ok=True)
|
|
186
201
|
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
# Check index version - rebuild if package version changed
|
|
203
|
+
version_file = self.index_path / "VERSION"
|
|
204
|
+
current_version = _get_package_version()
|
|
205
|
+
needs_rebuild = False
|
|
206
|
+
|
|
207
|
+
if version_file.exists():
|
|
208
|
+
try:
|
|
209
|
+
stored_version = version_file.read_text().strip()
|
|
210
|
+
if stored_version != current_version:
|
|
211
|
+
needs_rebuild = True
|
|
212
|
+
print(
|
|
213
|
+
f"Package version changed ({stored_version} -> {current_version}), "
|
|
214
|
+
"rebuilding index..."
|
|
215
|
+
)
|
|
216
|
+
except IOError:
|
|
217
|
+
needs_rebuild = True
|
|
218
|
+
print("Index version file corrupted, rebuilding...")
|
|
219
|
+
elif (self.index_path / "meta.json").exists():
|
|
220
|
+
# Index exists but no VERSION file - legacy index, rebuild
|
|
221
|
+
needs_rebuild = True
|
|
222
|
+
print("Upgrading index to versioned format, rebuilding...")
|
|
223
|
+
|
|
224
|
+
if needs_rebuild:
|
|
225
|
+
# Clear index directory contents (but keep the directory itself)
|
|
226
|
+
for item in self.index_path.iterdir():
|
|
227
|
+
if item.is_file():
|
|
228
|
+
item.unlink()
|
|
229
|
+
elif item.is_dir():
|
|
230
|
+
shutil.rmtree(item)
|
|
231
|
+
# Reset state tracker
|
|
232
|
+
self.state = IndexState(self.index_path / "index_state.json")
|
|
233
|
+
|
|
234
|
+
# Create or open index
|
|
235
|
+
self.index = tantivy.Index(self.schema, path=str(self.index_path))
|
|
236
|
+
|
|
237
|
+
# Write current version
|
|
238
|
+
version_file.write_text(current_version)
|
|
191
239
|
|
|
192
240
|
def _parse_export_file(self, export_path: Path) -> Optional[dict[str, Any]]:
|
|
193
241
|
"""
|
|
@@ -275,10 +323,12 @@ class SessionIndex:
|
|
|
275
323
|
# First and last message fields
|
|
276
324
|
first_msg = metadata.get("first_msg", {}) or {}
|
|
277
325
|
last_msg = metadata.get("last_msg", {}) or {}
|
|
326
|
+
first_user_msg = metadata.get("first_user_msg", {}) or {}
|
|
278
327
|
doc.add_text("first_msg_role", first_msg.get("role", ""))
|
|
279
328
|
doc.add_text("first_msg_content", first_msg.get("content", ""))
|
|
280
329
|
doc.add_text("last_msg_role", last_msg.get("role", ""))
|
|
281
330
|
doc.add_text("last_msg_content", last_msg.get("content", ""))
|
|
331
|
+
doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
|
|
282
332
|
|
|
283
333
|
# Session type fields
|
|
284
334
|
doc.add_text("derivation_type", metadata.get("derivation_type", "") or "")
|
|
@@ -287,6 +337,9 @@ class SessionIndex:
|
|
|
287
337
|
"true" if metadata.get("is_sidechain") else "false"
|
|
288
338
|
)
|
|
289
339
|
|
|
340
|
+
# Custom title (from /rename command)
|
|
341
|
+
doc.add_text("custom_title", metadata.get("customTitle", "") or "")
|
|
342
|
+
|
|
290
343
|
doc.add_text("content", parsed["content"])
|
|
291
344
|
|
|
292
345
|
writer.add_document(doc)
|
|
@@ -356,10 +409,12 @@ class SessionIndex:
|
|
|
356
409
|
# First and last message fields
|
|
357
410
|
first_msg = metadata.get("first_msg", {}) or {}
|
|
358
411
|
last_msg = metadata.get("last_msg", {}) or {}
|
|
412
|
+
first_user_msg = metadata.get("first_user_msg", {}) or {}
|
|
359
413
|
doc.add_text("first_msg_role", first_msg.get("role", ""))
|
|
360
414
|
doc.add_text("first_msg_content", first_msg.get("content", ""))
|
|
361
415
|
doc.add_text("last_msg_role", last_msg.get("role", ""))
|
|
362
416
|
doc.add_text("last_msg_content", last_msg.get("content", ""))
|
|
417
|
+
doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
|
|
363
418
|
|
|
364
419
|
# Session type fields
|
|
365
420
|
doc.add_text(
|
|
@@ -370,6 +425,9 @@ class SessionIndex:
|
|
|
370
425
|
"true" if metadata.get("is_sidechain") else "false"
|
|
371
426
|
)
|
|
372
427
|
|
|
428
|
+
# Custom title (from /rename command)
|
|
429
|
+
doc.add_text("custom_title", metadata.get("customTitle", "") or "")
|
|
430
|
+
|
|
373
431
|
doc.add_text("content", parsed["content"])
|
|
374
432
|
|
|
375
433
|
writer.add_document(doc)
|
|
@@ -426,7 +484,7 @@ class SessionIndex:
|
|
|
426
484
|
|
|
427
485
|
def _extract_session_content(
|
|
428
486
|
self, jsonl_path: Path, agent: str
|
|
429
|
-
) -> tuple[str, int]:
|
|
487
|
+
) -> tuple[str, int, str]:
|
|
430
488
|
"""
|
|
431
489
|
Extract searchable content from a session file.
|
|
432
490
|
|
|
@@ -437,10 +495,11 @@ class SessionIndex:
|
|
|
437
495
|
agent: Agent type ('claude' or 'codex')
|
|
438
496
|
|
|
439
497
|
Returns:
|
|
440
|
-
Tuple of (content_string, user_message_count)
|
|
498
|
+
Tuple of (content_string, user_message_count, custom_title)
|
|
441
499
|
"""
|
|
442
500
|
messages = []
|
|
443
501
|
user_count = 0 # Count only user messages for the "lines" metric
|
|
502
|
+
custom_title = "" # Session name from /rename command
|
|
444
503
|
|
|
445
504
|
try:
|
|
446
505
|
with open(jsonl_path, "r", encoding="utf-8") as f:
|
|
@@ -454,6 +513,11 @@ class SessionIndex:
|
|
|
454
513
|
except json.JSONDecodeError:
|
|
455
514
|
continue
|
|
456
515
|
|
|
516
|
+
# Check for custom-title (from /rename command)
|
|
517
|
+
if data.get("type") == "custom-title":
|
|
518
|
+
custom_title = data.get("customTitle", "")
|
|
519
|
+
continue
|
|
520
|
+
|
|
457
521
|
role: Optional[str] = None
|
|
458
522
|
text: str = ""
|
|
459
523
|
|
|
@@ -467,9 +531,10 @@ class SessionIndex:
|
|
|
467
531
|
message = data.get("message", {})
|
|
468
532
|
content = message.get("content")
|
|
469
533
|
|
|
470
|
-
# Count user messages, but exclude
|
|
471
|
-
# Tool results
|
|
472
|
-
#
|
|
534
|
+
# Count user messages, but exclude:
|
|
535
|
+
# - Tool results (content is list with {"type": "tool_result"})
|
|
536
|
+
# - Meta messages (isMeta: true, e.g., "Caveat:" warnings)
|
|
537
|
+
# - Local command messages (<command-name>, <local-command-stdout>)
|
|
473
538
|
if role == "user":
|
|
474
539
|
is_tool_result = (
|
|
475
540
|
isinstance(content, list)
|
|
@@ -477,7 +542,12 @@ class SessionIndex:
|
|
|
477
542
|
and isinstance(content[0], dict)
|
|
478
543
|
and content[0].get("type") == "tool_result"
|
|
479
544
|
)
|
|
480
|
-
|
|
545
|
+
# Check for meta/non-genuine messages
|
|
546
|
+
text_content = content if isinstance(content, str) else ""
|
|
547
|
+
if (
|
|
548
|
+
not is_tool_result
|
|
549
|
+
and not _is_meta_user_message(data, text_content)
|
|
550
|
+
):
|
|
481
551
|
user_count += 1
|
|
482
552
|
|
|
483
553
|
if not content:
|
|
@@ -496,6 +566,12 @@ class SessionIndex:
|
|
|
496
566
|
elif block.get("type") == "tool_use":
|
|
497
567
|
tool_name = block.get("name", "")
|
|
498
568
|
text += f"[Tool: {tool_name}]\n"
|
|
569
|
+
# Index tool input content
|
|
570
|
+
tool_input = block.get("input", {})
|
|
571
|
+
if isinstance(tool_input, dict):
|
|
572
|
+
for value in tool_input.values():
|
|
573
|
+
if isinstance(value, str) and value:
|
|
574
|
+
text += f"{value}\n"
|
|
499
575
|
elif block.get("type") == "tool_result":
|
|
500
576
|
result = block.get("content", "")
|
|
501
577
|
if isinstance(result, str):
|
|
@@ -511,13 +587,23 @@ class SessionIndex:
|
|
|
511
587
|
continue
|
|
512
588
|
|
|
513
589
|
role = payload.get("role")
|
|
514
|
-
if role == "user":
|
|
515
|
-
user_count += 1
|
|
516
590
|
content = payload.get("content", [])
|
|
517
591
|
|
|
518
592
|
if not isinstance(content, list):
|
|
519
593
|
continue
|
|
520
594
|
|
|
595
|
+
# Extract text first to check for meta patterns
|
|
596
|
+
codex_text = ""
|
|
597
|
+
for block in content:
|
|
598
|
+
if isinstance(block, dict):
|
|
599
|
+
if block.get("type") in ("input_text", "output_text"):
|
|
600
|
+
codex_text += block.get("text", "")
|
|
601
|
+
|
|
602
|
+
# Count user messages, but exclude non-genuine messages
|
|
603
|
+
if role == "user":
|
|
604
|
+
if not _is_meta_user_message({}, codex_text):
|
|
605
|
+
user_count += 1
|
|
606
|
+
|
|
521
607
|
for block in content:
|
|
522
608
|
if not isinstance(block, dict):
|
|
523
609
|
continue
|
|
@@ -532,7 +618,7 @@ class SessionIndex:
|
|
|
532
618
|
except (OSError, IOError):
|
|
533
619
|
pass
|
|
534
620
|
|
|
535
|
-
return "\n\n".join(messages), user_count
|
|
621
|
+
return "\n\n".join(messages), user_count, custom_title
|
|
536
622
|
|
|
537
623
|
def _parse_jsonl_session(self, jsonl_path: Path) -> Optional[dict[str, Any]]:
|
|
538
624
|
"""
|
|
@@ -556,8 +642,14 @@ class SessionIndex:
|
|
|
556
642
|
from claude_code_tools.export_session import extract_session_metadata
|
|
557
643
|
metadata = extract_session_metadata(jsonl_path, agent)
|
|
558
644
|
|
|
559
|
-
# Extract content for full-text search
|
|
560
|
-
content, msg_count = self._extract_session_content(
|
|
645
|
+
# Extract content for full-text search (also extracts custom_title)
|
|
646
|
+
content, msg_count, custom_title = self._extract_session_content(
|
|
647
|
+
jsonl_path, agent
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# Add custom_title to metadata (extracted during content scan)
|
|
651
|
+
if custom_title:
|
|
652
|
+
metadata["customTitle"] = custom_title
|
|
561
653
|
|
|
562
654
|
if msg_count == 0:
|
|
563
655
|
return {"_skip_reason": "empty"}
|
|
@@ -565,6 +657,7 @@ class SessionIndex:
|
|
|
565
657
|
# Map metadata fields to expected format
|
|
566
658
|
first_msg = metadata.get("first_msg") or {"role": "", "content": ""}
|
|
567
659
|
last_msg = metadata.get("last_msg") or {"role": "", "content": ""}
|
|
660
|
+
first_user_msg = metadata.get("first_user_msg") or {"role": "", "content": ""}
|
|
568
661
|
|
|
569
662
|
# Always use filename-derived session_id (the canonical identifier)
|
|
570
663
|
# Internal sessionId field can be stale in forked sessions
|
|
@@ -585,10 +678,12 @@ class SessionIndex:
|
|
|
585
678
|
"is_sidechain": metadata.get("is_sidechain", False),
|
|
586
679
|
"derivation_type": metadata.get("derivation_type", "") or "",
|
|
587
680
|
"session_type": metadata.get("session_type"),
|
|
681
|
+
"customTitle": metadata.get("customTitle", "") or "",
|
|
588
682
|
},
|
|
589
683
|
"content": content,
|
|
590
684
|
"first_msg": first_msg,
|
|
591
685
|
"last_msg": last_msg,
|
|
686
|
+
"first_user_msg": first_user_msg,
|
|
592
687
|
"lines": msg_count,
|
|
593
688
|
"file_path": str(jsonl_path),
|
|
594
689
|
}
|
|
@@ -671,6 +766,7 @@ class SessionIndex:
|
|
|
671
766
|
metadata = parsed["metadata"]
|
|
672
767
|
first_msg = parsed["first_msg"]
|
|
673
768
|
last_msg = parsed["last_msg"]
|
|
769
|
+
first_user_msg = parsed.get("first_user_msg", {}) or {}
|
|
674
770
|
|
|
675
771
|
# Skip helper sessions (SDK/headless sessions used for analysis)
|
|
676
772
|
if metadata.get("session_type") == "helper":
|
|
@@ -714,6 +810,7 @@ class SessionIndex:
|
|
|
714
810
|
doc.add_text("first_msg_content", first_msg.get("content", ""))
|
|
715
811
|
doc.add_text("last_msg_role", last_msg.get("role", ""))
|
|
716
812
|
doc.add_text("last_msg_content", last_msg.get("content", ""))
|
|
813
|
+
doc.add_text("first_user_msg_content", first_user_msg.get("content", ""))
|
|
717
814
|
|
|
718
815
|
# Session type fields
|
|
719
816
|
doc.add_text(
|
|
@@ -724,6 +821,9 @@ class SessionIndex:
|
|
|
724
821
|
"true" if metadata.get("is_sidechain") else "false"
|
|
725
822
|
)
|
|
726
823
|
|
|
824
|
+
# Custom title (from /rename command)
|
|
825
|
+
doc.add_text("custom_title", metadata.get("customTitle", "") or "")
|
|
826
|
+
|
|
727
827
|
# Source home (for filtering by source directory)
|
|
728
828
|
# Detect from path whether this is a Claude or Codex session
|
|
729
829
|
agent = metadata.get("agent", "")
|
|
@@ -933,6 +1033,7 @@ class SessionIndex:
|
|
|
933
1033
|
first_msg_content = doc.get_first("first_msg_content") or ""
|
|
934
1034
|
last_msg_role = doc.get_first("last_msg_role") or ""
|
|
935
1035
|
last_msg_content = doc.get_first("last_msg_content") or ""
|
|
1036
|
+
first_user_msg_content = doc.get_first("first_user_msg_content") or ""
|
|
936
1037
|
|
|
937
1038
|
results.append({
|
|
938
1039
|
"session_id": session_id,
|
|
@@ -950,6 +1051,7 @@ class SessionIndex:
|
|
|
950
1051
|
"first_msg_content": first_msg_content,
|
|
951
1052
|
"last_msg_role": last_msg_role,
|
|
952
1053
|
"last_msg_content": last_msg_content,
|
|
1054
|
+
"first_user_msg_content": first_user_msg_content,
|
|
953
1055
|
})
|
|
954
1056
|
|
|
955
1057
|
if len(results) >= limit:
|
|
@@ -1013,6 +1115,7 @@ class SessionIndex:
|
|
|
1013
1115
|
"first_msg_content": doc.get_first("first_msg_content") or "",
|
|
1014
1116
|
"last_msg_role": doc.get_first("last_msg_role") or "",
|
|
1015
1117
|
"last_msg_content": doc.get_first("last_msg_content") or "",
|
|
1118
|
+
"first_user_msg_content": doc.get_first("first_user_msg_content") or "",
|
|
1016
1119
|
"claude_home": doc.get_first("claude_home") or "",
|
|
1017
1120
|
})
|
|
1018
1121
|
|
|
@@ -1112,6 +1215,7 @@ class SessionIndex:
|
|
|
1112
1215
|
"first_msg_content": doc.get_first("first_msg_content") or "",
|
|
1113
1216
|
"last_msg_role": doc.get_first("last_msg_role") or "",
|
|
1114
1217
|
"last_msg_content": doc.get_first("last_msg_content") or "",
|
|
1218
|
+
"first_user_msg_content": doc.get_first("first_user_msg_content") or "",
|
|
1115
1219
|
"derivation_type": doc.get_first("derivation_type") or "",
|
|
1116
1220
|
"is_sidechain": doc.get_first("is_sidechain") or "false",
|
|
1117
1221
|
}
|
|
@@ -604,7 +604,7 @@ def is_valid_session(filepath: Path) -> bool:
|
|
|
604
604
|
|
|
605
605
|
# Whitelist of resumable message types
|
|
606
606
|
# Claude Code types (require sessionId)
|
|
607
|
-
claude_valid_types = {"user", "assistant", "tool_result", "tool_use"}
|
|
607
|
+
claude_valid_types = {"user", "assistant", "tool_result", "tool_use", "system"}
|
|
608
608
|
# Codex types (conversation content types)
|
|
609
609
|
codex_valid_types = {"event_msg", "response_item", "turn_context"}
|
|
610
610
|
|
|
@@ -1167,7 +1167,7 @@ This session continues from a previous conversation. The prior session log is:
|
|
|
1167
1167
|
|
|
1168
1168
|
{session_file}
|
|
1169
1169
|
|
|
1170
|
-
The file is in JSONL format.
|
|
1170
|
+
The file is in JSONL format. Since it can be large, use appropriate strategies (such as sub-agents if available) to carefully explore it if you need context.
|
|
1171
1171
|
|
|
1172
1172
|
Do not do anything yet. Simply greet the user and await instructions on how they want to continue the work based on the above session."""
|
|
1173
1173
|
else:
|
|
@@ -1182,7 +1182,7 @@ This session continues from a chain of prior conversations. Here are the JSONL s
|
|
|
1182
1182
|
- "trimmed" = Long messages were truncated to free up context. Full content in parent session.
|
|
1183
1183
|
- "rolled over" = Work handed off to fresh session.
|
|
1184
1184
|
|
|
1185
|
-
|
|
1185
|
+
Since these files can be large, use appropriate strategies (such as sub-agents if available) to carefully explore them if you need context.
|
|
1186
1186
|
|
|
1187
1187
|
Do not do anything yet. Simply greet the user and await instructions on how they want to continue the work based on the above sessions."""
|
|
1188
1188
|
|
claude_code_tools/smart_trim.py
CHANGED
|
@@ -12,7 +12,7 @@ from typing import List, Optional
|
|
|
12
12
|
|
|
13
13
|
from claude_code_tools.smart_trim_core import identify_trimmable_lines_cli, SMART_TRIM_THRESHOLD
|
|
14
14
|
from claude_code_tools.trim_session import detect_agent, inject_lineage_into_first_user_message
|
|
15
|
-
from claude_code_tools.session_utils import get_claude_home, resolve_session_path
|
|
15
|
+
from claude_code_tools.session_utils import get_claude_home, get_session_uuid, resolve_session_path
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def trim_lines(
|
|
@@ -334,18 +334,28 @@ def main():
|
|
|
334
334
|
line_indices = [item[0] for item in trimmable]
|
|
335
335
|
|
|
336
336
|
# Determine output file
|
|
337
|
-
output_dir = args.output_dir or session_file.parent
|
|
338
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
339
|
-
|
|
340
337
|
# Use agent type already detected earlier
|
|
341
338
|
if agent == "claude":
|
|
342
|
-
#
|
|
339
|
+
# Claude: output in same directory or specified output_dir
|
|
340
|
+
output_dir = args.output_dir or session_file.parent
|
|
341
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
343
342
|
new_uuid = str(uuid.uuid4())
|
|
344
343
|
output_file = output_dir / f"{new_uuid}.jsonl"
|
|
345
344
|
else:
|
|
346
|
-
# Codex
|
|
347
|
-
|
|
345
|
+
# Codex: new session goes in today's date folder (YYYY/MM/DD)
|
|
346
|
+
now = datetime.datetime.now()
|
|
347
|
+
timestamp = now.strftime("%Y-%m-%dT%H-%M-%S")
|
|
348
|
+
date_path = now.strftime("%Y/%m/%d")
|
|
348
349
|
new_uuid = str(uuid.uuid4())
|
|
350
|
+
|
|
351
|
+
if args.output_dir:
|
|
352
|
+
output_dir = args.output_dir / date_path
|
|
353
|
+
else:
|
|
354
|
+
# Find sessions root by going up from input file (sessions/YYYY/MM/DD/file.jsonl)
|
|
355
|
+
sessions_root = session_file.parent.parent.parent.parent
|
|
356
|
+
output_dir = sessions_root / date_path
|
|
357
|
+
|
|
358
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
349
359
|
output_file = output_dir / f"rollout-{timestamp}-{new_uuid}.jsonl"
|
|
350
360
|
|
|
351
361
|
# Perform trimming (pass descriptions for truncation summaries)
|
|
@@ -407,7 +417,7 @@ def main():
|
|
|
407
417
|
print(f" Tokens saved (est): ~{stats['tokens_saved']:,}")
|
|
408
418
|
print()
|
|
409
419
|
print(f"📄 Output: {output_file}")
|
|
410
|
-
print(f" Session ID: {output_file.
|
|
420
|
+
print(f" Session ID: {get_session_uuid(output_file.name)}")
|
|
411
421
|
|
|
412
422
|
|
|
413
423
|
if __name__ == "__main__":
|
|
@@ -8,6 +8,7 @@ from typing import List, Optional, Any, Dict
|
|
|
8
8
|
from claude_code_tools.session_utils import (
|
|
9
9
|
get_codex_home,
|
|
10
10
|
get_claude_home,
|
|
11
|
+
get_session_uuid,
|
|
11
12
|
encode_claude_project_path,
|
|
12
13
|
mark_session_as_helper,
|
|
13
14
|
)
|
|
@@ -181,8 +182,8 @@ def analyze_session_with_cli(
|
|
|
181
182
|
|
|
182
183
|
session_content = "\n".join(content_parts)
|
|
183
184
|
|
|
184
|
-
# Extract session_id from filename
|
|
185
|
-
session_id = session_file.
|
|
185
|
+
# Extract session_id (UUID) from filename - works for both Claude and Codex formats
|
|
186
|
+
session_id = get_session_uuid(session_file.name)
|
|
186
187
|
|
|
187
188
|
# Determine if this is a Codex session (for output directory placement)
|
|
188
189
|
# Resolve paths to handle ~ and relative paths correctly
|
|
@@ -224,6 +225,7 @@ def analyze_session_with_cli(
|
|
|
224
225
|
result = subprocess.run(
|
|
225
226
|
[
|
|
226
227
|
"claude", "-p",
|
|
228
|
+
"--no-session-persistence",
|
|
227
229
|
"--output-format", "json",
|
|
228
230
|
"--permission-mode", "bypassPermissions",
|
|
229
231
|
prompt,
|
|
@@ -145,13 +145,19 @@ class TmuxCLIController:
|
|
|
145
145
|
|
|
146
146
|
def format_pane_identifier(self, pane_id: str) -> str:
|
|
147
147
|
"""Convert pane ID to session:window.pane format."""
|
|
148
|
+
# Validate pane_id is not empty
|
|
149
|
+
if not pane_id:
|
|
150
|
+
return pane_id
|
|
151
|
+
|
|
148
152
|
try:
|
|
149
153
|
# Get session, window index, and pane index for this pane
|
|
150
154
|
session_output, session_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{session_name}'])
|
|
151
155
|
window_output, window_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{window_index}'])
|
|
152
156
|
pane_output, pane_code = self._run_tmux_command(['display-message', '-t', pane_id, '-p', '#{pane_index}'])
|
|
153
|
-
|
|
154
|
-
|
|
157
|
+
|
|
158
|
+
# Check both return codes AND that outputs are non-empty
|
|
159
|
+
if (session_code == 0 and window_code == 0 and pane_code == 0 and
|
|
160
|
+
session_output and window_output and pane_output):
|
|
155
161
|
return f"{session_output}:{window_output}.{pane_output}"
|
|
156
162
|
else:
|
|
157
163
|
# Fallback to pane ID
|
|
@@ -275,31 +281,36 @@ class TmuxCLIController:
|
|
|
275
281
|
"""
|
|
276
282
|
# Get the current window ID to ensure pane is created in this window
|
|
277
283
|
current_window_id = self.get_current_window_id()
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
|
|
285
|
+
# Build base command
|
|
286
|
+
base_cmd = ['split-window']
|
|
287
|
+
|
|
281
288
|
# Target the specific window where tmux-cli was called from
|
|
282
289
|
if current_window_id:
|
|
283
|
-
|
|
284
|
-
|
|
290
|
+
base_cmd.extend(['-t', current_window_id])
|
|
291
|
+
|
|
285
292
|
if vertical:
|
|
286
|
-
|
|
293
|
+
base_cmd.append('-h')
|
|
287
294
|
else:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
cmd.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
295
|
+
base_cmd.append('-v')
|
|
296
|
+
|
|
297
|
+
# Try -l first (tmux 3.4+), fall back to -p (older versions)
|
|
298
|
+
# tmux 3.4 removed -p, but 3.5+ has both; older versions only have -p
|
|
299
|
+
for size_flag in (['-l', f'{size}%'], ['-p', str(size)]) if size else [[]]:
|
|
300
|
+
cmd = base_cmd.copy()
|
|
301
|
+
if size_flag:
|
|
302
|
+
cmd.extend(size_flag)
|
|
303
|
+
cmd.extend(['-P', '-F', '#{pane_id}'])
|
|
304
|
+
if start_command:
|
|
305
|
+
cmd.append(start_command)
|
|
306
|
+
|
|
307
|
+
output, code = self._run_tmux_command(cmd)
|
|
308
|
+
|
|
309
|
+
# Validate: code must be 0 and output must be a valid pane ID (starts with %)
|
|
310
|
+
if code == 0 and output and output.startswith('%'):
|
|
311
|
+
self.target_pane = output
|
|
312
|
+
return output
|
|
313
|
+
|
|
303
314
|
return None
|
|
304
315
|
|
|
305
316
|
def select_pane(self, pane_id: Optional[str] = None, pane_index: Optional[int] = None):
|
|
@@ -686,7 +697,6 @@ class CLI:
|
|
|
686
697
|
else:
|
|
687
698
|
# Remote mode - pass pane_id directly
|
|
688
699
|
content = self.controller.capture_pane(pane_id=pane, lines=lines)
|
|
689
|
-
print(content)
|
|
690
700
|
return content
|
|
691
701
|
|
|
692
702
|
def interrupt(self, pane: Optional[str] = None):
|
|
@@ -925,4 +935,4 @@ def main():
|
|
|
925
935
|
|
|
926
936
|
|
|
927
937
|
if __name__ == '__main__':
|
|
928
|
-
main()
|
|
938
|
+
main()
|
|
@@ -365,6 +365,9 @@ def create_placeholder(tool_name: str, original_length: int) -> str:
|
|
|
365
365
|
)
|
|
366
366
|
|
|
367
367
|
|
|
368
|
+
MIN_TOKEN_SAVINGS = 300 # Minimum tokens saved to consider trim worthwhile
|
|
369
|
+
|
|
370
|
+
|
|
368
371
|
def trim_and_create_session(
|
|
369
372
|
agent: Optional[str],
|
|
370
373
|
input_file: Path,
|
|
@@ -372,6 +375,7 @@ def trim_and_create_session(
|
|
|
372
375
|
threshold: int,
|
|
373
376
|
output_dir: Optional[Path] = None,
|
|
374
377
|
trim_assistant_messages: Optional[int] = None,
|
|
378
|
+
min_token_savings: int = MIN_TOKEN_SAVINGS,
|
|
375
379
|
) -> dict:
|
|
376
380
|
"""
|
|
377
381
|
Trim tool results and assistant messages, creating a new session file.
|
|
@@ -386,16 +390,19 @@ def trim_and_create_session(
|
|
|
386
390
|
- Positive N: trim first N assistant messages exceeding threshold
|
|
387
391
|
- Negative N: trim all except last abs(N) assistant messages exceeding threshold
|
|
388
392
|
- None: don't trim assistant messages
|
|
393
|
+
min_token_savings: Minimum tokens saved to consider trim worthwhile.
|
|
394
|
+
If savings are below this, deletes output file and sets nothing_to_trim=True.
|
|
389
395
|
|
|
390
396
|
Returns:
|
|
391
397
|
Dict with:
|
|
392
|
-
- session_id: New session UUID
|
|
393
|
-
- output_file: Path to new session file
|
|
398
|
+
- session_id: New session UUID (None if nothing_to_trim)
|
|
399
|
+
- output_file: Path to new session file (None if nothing_to_trim)
|
|
394
400
|
- num_tools_trimmed: Number of tool results trimmed
|
|
395
401
|
- num_assistant_trimmed: Number of assistant messages trimmed
|
|
396
402
|
- chars_saved: Characters saved
|
|
397
403
|
- tokens_saved: Estimated tokens saved
|
|
398
404
|
- detected_agent: Detected agent type
|
|
405
|
+
- nothing_to_trim: True if savings below min_token_savings threshold
|
|
399
406
|
"""
|
|
400
407
|
import json
|
|
401
408
|
from datetime import datetime, timezone
|
|
@@ -471,6 +478,9 @@ def trim_and_create_session(
|
|
|
471
478
|
try:
|
|
472
479
|
# Parse first line and add metadata fields
|
|
473
480
|
first_line_data = json.loads(lines[0])
|
|
481
|
+
# Remove continue_metadata if present - a session is either trimmed OR
|
|
482
|
+
# continued, not both. The trim_metadata.parent_file preserves ancestry.
|
|
483
|
+
first_line_data.pop("continue_metadata", None)
|
|
474
484
|
first_line_data.update(metadata_fields)
|
|
475
485
|
lines[0] = json.dumps(first_line_data) + "\n"
|
|
476
486
|
|
|
@@ -481,6 +491,21 @@ def trim_and_create_session(
|
|
|
481
491
|
# If first line is not valid JSON, leave file as-is
|
|
482
492
|
pass
|
|
483
493
|
|
|
494
|
+
# Check if savings are worth it
|
|
495
|
+
if tokens_saved < min_token_savings:
|
|
496
|
+
# Not worth trimming - delete output file and return nothing_to_trim flag
|
|
497
|
+
output_path.unlink(missing_ok=True)
|
|
498
|
+
return {
|
|
499
|
+
"session_id": None,
|
|
500
|
+
"output_file": None,
|
|
501
|
+
"num_tools_trimmed": num_tools_trimmed,
|
|
502
|
+
"num_assistant_trimmed": num_assistant_trimmed,
|
|
503
|
+
"chars_saved": chars_saved,
|
|
504
|
+
"tokens_saved": tokens_saved,
|
|
505
|
+
"detected_agent": agent,
|
|
506
|
+
"nothing_to_trim": True,
|
|
507
|
+
}
|
|
508
|
+
|
|
484
509
|
# Inject parent session lineage into first user message
|
|
485
510
|
inject_lineage_into_first_user_message(output_path, input_file, agent)
|
|
486
511
|
|
|
@@ -492,6 +517,7 @@ def trim_and_create_session(
|
|
|
492
517
|
"chars_saved": chars_saved,
|
|
493
518
|
"tokens_saved": tokens_saved,
|
|
494
519
|
"detected_agent": agent,
|
|
520
|
+
"nothing_to_trim": False,
|
|
495
521
|
}
|
|
496
522
|
|
|
497
523
|
|