claude-code-tools 1.0.6__py3-none-any.whl → 1.4.1__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.
Files changed (32) hide show
  1. claude_code_tools/__init__.py +1 -1
  2. claude_code_tools/action_rpc.py +16 -10
  3. claude_code_tools/aichat.py +793 -51
  4. claude_code_tools/claude_continue.py +4 -0
  5. claude_code_tools/codex_continue.py +48 -0
  6. claude_code_tools/export_session.py +9 -5
  7. claude_code_tools/find_claude_session.py +36 -12
  8. claude_code_tools/find_codex_session.py +33 -18
  9. claude_code_tools/find_session.py +30 -16
  10. claude_code_tools/gdoc2md.py +220 -0
  11. claude_code_tools/md2gdoc.py +549 -0
  12. claude_code_tools/search_index.py +83 -9
  13. claude_code_tools/session_menu_cli.py +1 -1
  14. claude_code_tools/session_utils.py +3 -3
  15. claude_code_tools/smart_trim.py +18 -8
  16. claude_code_tools/smart_trim_core.py +4 -2
  17. claude_code_tools/tmux_cli_controller.py +35 -25
  18. claude_code_tools/trim_session.py +28 -2
  19. claude_code_tools-1.4.1.dist-info/METADATA +1113 -0
  20. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/RECORD +30 -24
  21. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/entry_points.txt +2 -0
  22. docs/local-llm-setup.md +286 -0
  23. docs/reddit-aichat-resume-v2.md +80 -0
  24. docs/reddit-aichat-resume.md +29 -0
  25. docs/reddit-aichat.md +79 -0
  26. docs/rollover-details.md +67 -0
  27. node_ui/action_config.js +3 -3
  28. node_ui/menu.js +67 -113
  29. claude_code_tools/session_tui.py +0 -516
  30. claude_code_tools-1.0.6.dist-info/METADATA +0 -685
  31. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/WHEEL +0 -0
  32. {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,14 +2,24 @@
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
 
11
13
  from claude_code_tools.session_utils import is_valid_session
12
14
 
15
+
16
+ def _get_package_version() -> str:
17
+ """Get installed package version for automatic index rebuilding."""
18
+ try:
19
+ return get_pkg_version("claude-code-tools")
20
+ except Exception:
21
+ return "unknown"
22
+
13
23
  # Lazy imports to allow module to load even if deps not installed
14
24
  try:
15
25
  import yaml
@@ -176,6 +186,9 @@ class SessionIndex:
176
186
  # Use "raw" tokenizer so paths are indexed as single tokens for exact matching
177
187
  self.schema_builder.add_text_field("claude_home", stored=True, tokenizer_name="raw")
178
188
 
189
+ # Custom title field (from /rename command)
190
+ self.schema_builder.add_text_field("custom_title", stored=True)
191
+
179
192
  # Searchable content field
180
193
  self.schema_builder.add_text_field("content", stored=True)
181
194
 
@@ -184,10 +197,43 @@ class SessionIndex:
184
197
  # Create or open index
185
198
  self.index_path.mkdir(parents=True, exist_ok=True)
186
199
 
187
- if (self.index_path / "meta.json").exists():
188
- self.index = tantivy.Index(self.schema, path=str(self.index_path))
189
- else:
190
- self.index = tantivy.Index(self.schema, path=str(self.index_path))
200
+ # Check index version - rebuild if package version changed
201
+ version_file = self.index_path / "VERSION"
202
+ current_version = _get_package_version()
203
+ needs_rebuild = False
204
+
205
+ if version_file.exists():
206
+ try:
207
+ stored_version = version_file.read_text().strip()
208
+ if stored_version != current_version:
209
+ needs_rebuild = True
210
+ print(
211
+ f"Package version changed ({stored_version} -> {current_version}), "
212
+ "rebuilding index..."
213
+ )
214
+ except IOError:
215
+ needs_rebuild = True
216
+ print("Index version file corrupted, rebuilding...")
217
+ elif (self.index_path / "meta.json").exists():
218
+ # Index exists but no VERSION file - legacy index, rebuild
219
+ needs_rebuild = True
220
+ print("Upgrading index to versioned format, rebuilding...")
221
+
222
+ if needs_rebuild:
223
+ # Clear index directory contents (but keep the directory itself)
224
+ for item in self.index_path.iterdir():
225
+ if item.is_file():
226
+ item.unlink()
227
+ elif item.is_dir():
228
+ shutil.rmtree(item)
229
+ # Reset state tracker
230
+ self.state = IndexState(self.index_path / "index_state.json")
231
+
232
+ # Create or open index
233
+ self.index = tantivy.Index(self.schema, path=str(self.index_path))
234
+
235
+ # Write current version
236
+ version_file.write_text(current_version)
191
237
 
192
238
  def _parse_export_file(self, export_path: Path) -> Optional[dict[str, Any]]:
193
239
  """
@@ -287,6 +333,9 @@ class SessionIndex:
287
333
  "true" if metadata.get("is_sidechain") else "false"
288
334
  )
289
335
 
336
+ # Custom title (from /rename command)
337
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
338
+
290
339
  doc.add_text("content", parsed["content"])
291
340
 
292
341
  writer.add_document(doc)
@@ -370,6 +419,9 @@ class SessionIndex:
370
419
  "true" if metadata.get("is_sidechain") else "false"
371
420
  )
372
421
 
422
+ # Custom title (from /rename command)
423
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
424
+
373
425
  doc.add_text("content", parsed["content"])
374
426
 
375
427
  writer.add_document(doc)
@@ -426,7 +478,7 @@ class SessionIndex:
426
478
 
427
479
  def _extract_session_content(
428
480
  self, jsonl_path: Path, agent: str
429
- ) -> tuple[str, int]:
481
+ ) -> tuple[str, int, str]:
430
482
  """
431
483
  Extract searchable content from a session file.
432
484
 
@@ -437,10 +489,11 @@ class SessionIndex:
437
489
  agent: Agent type ('claude' or 'codex')
438
490
 
439
491
  Returns:
440
- Tuple of (content_string, user_message_count)
492
+ Tuple of (content_string, user_message_count, custom_title)
441
493
  """
442
494
  messages = []
443
495
  user_count = 0 # Count only user messages for the "lines" metric
496
+ custom_title = "" # Session name from /rename command
444
497
 
445
498
  try:
446
499
  with open(jsonl_path, "r", encoding="utf-8") as f:
@@ -454,6 +507,11 @@ class SessionIndex:
454
507
  except json.JSONDecodeError:
455
508
  continue
456
509
 
510
+ # Check for custom-title (from /rename command)
511
+ if data.get("type") == "custom-title":
512
+ custom_title = data.get("customTitle", "")
513
+ continue
514
+
457
515
  role: Optional[str] = None
458
516
  text: str = ""
459
517
 
@@ -496,6 +554,12 @@ class SessionIndex:
496
554
  elif block.get("type") == "tool_use":
497
555
  tool_name = block.get("name", "")
498
556
  text += f"[Tool: {tool_name}]\n"
557
+ # Index tool input content
558
+ tool_input = block.get("input", {})
559
+ if isinstance(tool_input, dict):
560
+ for value in tool_input.values():
561
+ if isinstance(value, str) and value:
562
+ text += f"{value}\n"
499
563
  elif block.get("type") == "tool_result":
500
564
  result = block.get("content", "")
501
565
  if isinstance(result, str):
@@ -532,7 +596,7 @@ class SessionIndex:
532
596
  except (OSError, IOError):
533
597
  pass
534
598
 
535
- return "\n\n".join(messages), user_count
599
+ return "\n\n".join(messages), user_count, custom_title
536
600
 
537
601
  def _parse_jsonl_session(self, jsonl_path: Path) -> Optional[dict[str, Any]]:
538
602
  """
@@ -556,8 +620,14 @@ class SessionIndex:
556
620
  from claude_code_tools.export_session import extract_session_metadata
557
621
  metadata = extract_session_metadata(jsonl_path, agent)
558
622
 
559
- # Extract content for full-text search
560
- content, msg_count = self._extract_session_content(jsonl_path, agent)
623
+ # Extract content for full-text search (also extracts custom_title)
624
+ content, msg_count, custom_title = self._extract_session_content(
625
+ jsonl_path, agent
626
+ )
627
+
628
+ # Add custom_title to metadata (extracted during content scan)
629
+ if custom_title:
630
+ metadata["customTitle"] = custom_title
561
631
 
562
632
  if msg_count == 0:
563
633
  return {"_skip_reason": "empty"}
@@ -585,6 +655,7 @@ class SessionIndex:
585
655
  "is_sidechain": metadata.get("is_sidechain", False),
586
656
  "derivation_type": metadata.get("derivation_type", "") or "",
587
657
  "session_type": metadata.get("session_type"),
658
+ "customTitle": metadata.get("customTitle", "") or "",
588
659
  },
589
660
  "content": content,
590
661
  "first_msg": first_msg,
@@ -724,6 +795,9 @@ class SessionIndex:
724
795
  "true" if metadata.get("is_sidechain") else "false"
725
796
  )
726
797
 
798
+ # Custom title (from /rename command)
799
+ doc.add_text("custom_title", metadata.get("customTitle", "") or "")
800
+
727
801
  # Source home (for filtering by source directory)
728
802
  # Detect from path whether this is a Claude or Codex session
729
803
  agent = metadata.get("agent", "")
@@ -424,7 +424,7 @@ Examples:
424
424
  }
425
425
 
426
426
  def handler(session_dict_in, action, kwargs=None):
427
- execute_action(
427
+ return execute_action(
428
428
  action,
429
429
  agent,
430
430
  session_file,
@@ -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. You can use sub-agents to explore it if needed.
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
- You can use sub-agents to explore these files if you need context.
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
 
@@ -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
- # Generate new UUID for trimmed session
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 style
347
- timestamp = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
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.stem}")
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.stem
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
- if session_code == 0 and window_code == 0 and pane_code == 0:
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
- cmd = ['split-window']
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
- cmd.extend(['-t', current_window_id])
284
-
290
+ base_cmd.extend(['-t', current_window_id])
291
+
285
292
  if vertical:
286
- cmd.append('-h')
293
+ base_cmd.append('-h')
287
294
  else:
288
- cmd.append('-v')
289
-
290
- if size:
291
- cmd.extend(['-p', str(size)])
292
-
293
- cmd.extend(['-P', '-F', '#{pane_id}'])
294
-
295
- if start_command:
296
- cmd.append(start_command)
297
-
298
- output, code = self._run_tmux_command(cmd)
299
-
300
- if code == 0:
301
- self.target_pane = output
302
- return output
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