cchat 0.1.0__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.
- cchat-0.1.0.dist-info/METADATA +127 -0
- cchat-0.1.0.dist-info/RECORD +6 -0
- cchat-0.1.0.dist-info/WHEEL +4 -0
- cchat-0.1.0.dist-info/entry_points.txt +3 -0
- cchat-0.1.0.dist-info/licenses/LICENSE +21 -0
- cchat.py +1588 -0
cchat.py
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Browse and search Claude Code conversation history from the terminal."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
# CONSTANTS
|
|
21
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
24
|
+
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
|
25
|
+
DEFAULT_TURNS = 5
|
|
26
|
+
MAX_WORKERS = 4
|
|
27
|
+
|
|
28
|
+
# Entry types that participate in the UUID tree
|
|
29
|
+
TREE_TYPES = {"user", "assistant", "system", "progress"}
|
|
30
|
+
|
|
31
|
+
# Entry types that are flat metadata (no UUID, not in tree)
|
|
32
|
+
METADATA_TYPES = {"summary", "file-history-snapshot", "queue-operation", "custom-title"}
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
# DATA CLASSES
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ToolSummary:
|
|
41
|
+
name: str
|
|
42
|
+
input_data: dict
|
|
43
|
+
|
|
44
|
+
def one_line(self) -> str:
|
|
45
|
+
"""Format tool call as a concise one-liner."""
|
|
46
|
+
inp = self.input_data
|
|
47
|
+
if self.name == "Read":
|
|
48
|
+
path = inp.get("file_path", "?")
|
|
49
|
+
return f"[Read] {_short_path(path)}"
|
|
50
|
+
elif self.name == "Write":
|
|
51
|
+
path = inp.get("file_path", "?")
|
|
52
|
+
return f"[Write] {_short_path(path)}"
|
|
53
|
+
elif self.name == "Edit":
|
|
54
|
+
path = inp.get("file_path", "?")
|
|
55
|
+
return f"[Edit] {_short_path(path)}"
|
|
56
|
+
elif self.name == "Bash":
|
|
57
|
+
cmd = inp.get("command", "?")
|
|
58
|
+
desc = inp.get("description", "")
|
|
59
|
+
label = desc if desc else (cmd[:60] + "..." if len(cmd) > 60 else cmd)
|
|
60
|
+
return f"[Bash] {label}"
|
|
61
|
+
elif self.name == "Glob":
|
|
62
|
+
pattern = inp.get("pattern", "?")
|
|
63
|
+
return f"[Glob] {pattern}"
|
|
64
|
+
elif self.name == "Grep":
|
|
65
|
+
pattern = inp.get("pattern", "?")
|
|
66
|
+
return f"[Grep] {pattern}"
|
|
67
|
+
elif self.name == "Task":
|
|
68
|
+
desc = inp.get("description", "?")
|
|
69
|
+
return f"[Task] {desc}"
|
|
70
|
+
elif self.name == "WebFetch":
|
|
71
|
+
url = inp.get("url", "?")
|
|
72
|
+
return f"[WebFetch] {url[:60]}"
|
|
73
|
+
elif self.name == "WebSearch":
|
|
74
|
+
query = inp.get("query", "?")
|
|
75
|
+
return f"[WebSearch] {query}"
|
|
76
|
+
elif self.name == "TodoWrite" or self.name == "TaskCreate":
|
|
77
|
+
return f"[{self.name}]"
|
|
78
|
+
else:
|
|
79
|
+
# Generic
|
|
80
|
+
summary = json.dumps(inp)
|
|
81
|
+
if len(summary) > 60:
|
|
82
|
+
summary = summary[:60] + "..."
|
|
83
|
+
return f"[{self.name}] {summary}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Turn:
|
|
88
|
+
"""A conversation turn: one user message + full assistant response."""
|
|
89
|
+
user_text: str
|
|
90
|
+
assistant_text: str
|
|
91
|
+
tool_calls: list # list[ToolSummary]
|
|
92
|
+
timestamp: str
|
|
93
|
+
uuid: str
|
|
94
|
+
is_compact_summary: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class RawMessage:
|
|
99
|
+
"""A single raw message for --raw mode."""
|
|
100
|
+
role: str # "user", "assistant", "user (tool_result)", "assistant (tool)", "system", "thinking"
|
|
101
|
+
content: str
|
|
102
|
+
timestamp: str
|
|
103
|
+
uuid: str
|
|
104
|
+
entry_type: str # original entry type
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class SessionMeta:
|
|
109
|
+
session_id: str
|
|
110
|
+
summary: str
|
|
111
|
+
first_prompt: str
|
|
112
|
+
message_count: int
|
|
113
|
+
created: str
|
|
114
|
+
modified: str
|
|
115
|
+
path: Optional[Path] = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class BranchPoint:
|
|
120
|
+
parent_uuid: str
|
|
121
|
+
active_child_uuid: str
|
|
122
|
+
alternative_uuids: list # list[str]
|
|
123
|
+
line_index: int # file position of the parent entry
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
127
|
+
# UTILITIES
|
|
128
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _short_path(path: str, max_parts: int = 3) -> str:
|
|
132
|
+
"""Shorten a file path for display."""
|
|
133
|
+
parts = Path(path).parts
|
|
134
|
+
if len(parts) <= max_parts:
|
|
135
|
+
return path
|
|
136
|
+
return ".../" + "/".join(parts[-max_parts:])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_timestamp(ts: str) -> datetime:
|
|
140
|
+
"""Parse ISO timestamp, returning datetime.min on failure."""
|
|
141
|
+
if not ts:
|
|
142
|
+
return datetime.min
|
|
143
|
+
try:
|
|
144
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
145
|
+
except (ValueError, TypeError):
|
|
146
|
+
return datetime.min
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _truncate(text: str, max_len: int) -> str:
|
|
150
|
+
"""Truncate text with ellipsis."""
|
|
151
|
+
if max_len <= 0 or len(text) <= max_len:
|
|
152
|
+
return text
|
|
153
|
+
return text[:max_len] + "..."
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
157
|
+
# PROJECT RESOLUTION
|
|
158
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ProjectResolver:
|
|
162
|
+
"""Find and manage project directories."""
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def get_project_key(cwd: Path) -> str:
|
|
166
|
+
"""Convert cwd to Claude's project directory name format."""
|
|
167
|
+
abs_path = str(cwd.resolve())
|
|
168
|
+
return abs_path.replace("/", "-")
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def find_project_dir(cwd: Path) -> Optional[Path]:
|
|
172
|
+
"""Find Claude project directory for a working directory."""
|
|
173
|
+
project_key = ProjectResolver.get_project_key(cwd)
|
|
174
|
+
project_path = PROJECTS_DIR / project_key
|
|
175
|
+
|
|
176
|
+
if project_path.exists():
|
|
177
|
+
return project_path
|
|
178
|
+
|
|
179
|
+
# Case-insensitive match (WSL path casing can vary)
|
|
180
|
+
if PROJECTS_DIR.exists():
|
|
181
|
+
for d in PROJECTS_DIR.iterdir():
|
|
182
|
+
if d.is_dir() and d.name.lower() == project_key.lower():
|
|
183
|
+
return d
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def find_project_dir_for_path(project_path: str) -> Optional[Path]:
|
|
188
|
+
"""Find project dir from a user-provided path string."""
|
|
189
|
+
# Normalize the path
|
|
190
|
+
p = Path(project_path).resolve()
|
|
191
|
+
return ProjectResolver.find_project_dir(p)
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def list_all_projects() -> list[dict]:
|
|
195
|
+
"""List all project directories with metadata."""
|
|
196
|
+
if not PROJECTS_DIR.exists():
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
projects = []
|
|
200
|
+
for d in sorted(PROJECTS_DIR.iterdir()):
|
|
201
|
+
if not d.is_dir():
|
|
202
|
+
continue
|
|
203
|
+
# Count session files (exclude agent- files and subdirectories)
|
|
204
|
+
session_files = [
|
|
205
|
+
f for f in d.glob("*.jsonl")
|
|
206
|
+
if not f.name.startswith("agent-")
|
|
207
|
+
]
|
|
208
|
+
if not session_files:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Decode project path from dir name
|
|
212
|
+
decoded_path = d.name.replace("-", "/", 1) # first dash is the leading /
|
|
213
|
+
# Actually the format is: -mnt-c-Users-... where each - is a /
|
|
214
|
+
decoded_path = d.name.replace("-", "/")
|
|
215
|
+
if decoded_path.startswith("/"):
|
|
216
|
+
pass # already correct
|
|
217
|
+
else:
|
|
218
|
+
decoded_path = "/" + decoded_path
|
|
219
|
+
|
|
220
|
+
latest_mtime = max(f.stat().st_mtime for f in session_files)
|
|
221
|
+
projects.append({
|
|
222
|
+
"dir": d,
|
|
223
|
+
"name": d.name,
|
|
224
|
+
"decoded_path": decoded_path,
|
|
225
|
+
"session_count": len(session_files),
|
|
226
|
+
"latest_modified": datetime.fromtimestamp(latest_mtime),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
projects.sort(key=lambda p: p["latest_modified"], reverse=True)
|
|
230
|
+
return projects
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def get_project_dir_or_exit(project_override: Optional[str] = None) -> Path:
|
|
234
|
+
"""Get project directory or exit with error."""
|
|
235
|
+
if project_override:
|
|
236
|
+
project_dir = ProjectResolver.find_project_dir_for_path(project_override)
|
|
237
|
+
if not project_dir:
|
|
238
|
+
# Try direct name match
|
|
239
|
+
candidate = PROJECTS_DIR / project_override
|
|
240
|
+
if candidate.exists():
|
|
241
|
+
return candidate
|
|
242
|
+
# Try partial match
|
|
243
|
+
if PROJECTS_DIR.exists():
|
|
244
|
+
for d in PROJECTS_DIR.iterdir():
|
|
245
|
+
if d.is_dir() and project_override.lower() in d.name.lower():
|
|
246
|
+
return d
|
|
247
|
+
print(f"Error: No project found for '{project_override}'", file=sys.stderr)
|
|
248
|
+
print("Use 'cchat projects' to list available projects.", file=sys.stderr)
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
return project_dir
|
|
251
|
+
|
|
252
|
+
cwd = Path.cwd()
|
|
253
|
+
project_dir = ProjectResolver.find_project_dir(cwd)
|
|
254
|
+
if not project_dir:
|
|
255
|
+
print(f"Error: No Claude project found for {cwd}", file=sys.stderr)
|
|
256
|
+
print(f"Expected: {PROJECTS_DIR / ProjectResolver.get_project_key(cwd)}", file=sys.stderr)
|
|
257
|
+
print("Use 'cchat projects' to list available projects.", file=sys.stderr)
|
|
258
|
+
print("Use '--project PATH' to specify a different project.", file=sys.stderr)
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
return project_dir
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
264
|
+
# SESSION INDEX
|
|
265
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class SessionIndex:
|
|
269
|
+
"""Fast session metadata with sessions-index.json + fallback."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, project_dir: Path):
|
|
272
|
+
self.project_dir = project_dir
|
|
273
|
+
self._index_cache: Optional[dict] = None
|
|
274
|
+
|
|
275
|
+
def _load_index(self) -> dict:
|
|
276
|
+
"""Load sessions-index.json if present."""
|
|
277
|
+
idx_path = self.project_dir / "sessions-index.json"
|
|
278
|
+
if idx_path.exists():
|
|
279
|
+
try:
|
|
280
|
+
with open(idx_path, "r", encoding="utf-8") as f:
|
|
281
|
+
data = json.load(f)
|
|
282
|
+
return {e["sessionId"]: e for e in data.get("entries", [])}
|
|
283
|
+
except (json.JSONDecodeError, KeyError):
|
|
284
|
+
pass
|
|
285
|
+
return {}
|
|
286
|
+
|
|
287
|
+
def _get_index(self) -> dict:
|
|
288
|
+
if self._index_cache is None:
|
|
289
|
+
self._index_cache = self._load_index()
|
|
290
|
+
return self._index_cache
|
|
291
|
+
|
|
292
|
+
def get_metadata(self, session_id: str, jsonl_path: Path) -> SessionMeta:
|
|
293
|
+
"""Get session metadata. Fast path uses index, slow path reads file header."""
|
|
294
|
+
idx = self._get_index()
|
|
295
|
+
|
|
296
|
+
if session_id in idx:
|
|
297
|
+
entry = idx[session_id]
|
|
298
|
+
return SessionMeta(
|
|
299
|
+
session_id=session_id,
|
|
300
|
+
summary=entry.get("summary", ""),
|
|
301
|
+
first_prompt=entry.get("firstPrompt", "")[:200],
|
|
302
|
+
message_count=entry.get("messageCount", 0),
|
|
303
|
+
created=entry.get("created", ""),
|
|
304
|
+
modified=entry.get("modified", ""),
|
|
305
|
+
path=jsonl_path,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Slow path: read file header
|
|
309
|
+
summary = ""
|
|
310
|
+
first_prompt = ""
|
|
311
|
+
custom_title = ""
|
|
312
|
+
try:
|
|
313
|
+
with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
|
|
314
|
+
for i, line in enumerate(f):
|
|
315
|
+
if i > 50:
|
|
316
|
+
break
|
|
317
|
+
try:
|
|
318
|
+
d = json.loads(line)
|
|
319
|
+
t = d.get("type")
|
|
320
|
+
if t == "summary" and not summary:
|
|
321
|
+
summary = d.get("summary", "")
|
|
322
|
+
elif t == "custom-title":
|
|
323
|
+
custom_title = d.get("customTitle", d.get("title", ""))
|
|
324
|
+
elif t == "user" and not first_prompt:
|
|
325
|
+
content = d.get("message", {}).get("content")
|
|
326
|
+
if isinstance(content, str) and content.strip():
|
|
327
|
+
first_prompt = content[:200]
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
continue
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# Quick message count: count lines with "user" or "assistant" type
|
|
334
|
+
# (fast string search, no full JSON parse)
|
|
335
|
+
msg_count = 0
|
|
336
|
+
try:
|
|
337
|
+
with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
|
|
338
|
+
for line in f:
|
|
339
|
+
if '"type":"user"' in line or '"type":"assistant"' in line:
|
|
340
|
+
msg_count += 1
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
stat = jsonl_path.stat()
|
|
345
|
+
return SessionMeta(
|
|
346
|
+
session_id=session_id,
|
|
347
|
+
summary=custom_title or summary,
|
|
348
|
+
first_prompt=first_prompt,
|
|
349
|
+
message_count=msg_count,
|
|
350
|
+
created="",
|
|
351
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
352
|
+
path=jsonl_path,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def list_sessions(self, limit: int = 10) -> list[SessionMeta]:
|
|
356
|
+
"""List recent sessions sorted by modification time."""
|
|
357
|
+
files = sorted(
|
|
358
|
+
[f for f in self.project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
|
|
359
|
+
key=lambda x: x.stat().st_mtime,
|
|
360
|
+
reverse=True,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if not files:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
results = []
|
|
367
|
+
for f in files[:limit]:
|
|
368
|
+
session_id = f.stem
|
|
369
|
+
meta = self.get_metadata(session_id, f)
|
|
370
|
+
results.append(meta)
|
|
371
|
+
|
|
372
|
+
return results
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
376
|
+
# SESSION LOADER
|
|
377
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class Session:
|
|
381
|
+
"""Lazy-loaded session with active path extraction and compaction stitching."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, jsonl_path: Path):
|
|
384
|
+
self.path = jsonl_path
|
|
385
|
+
self.session_id = jsonl_path.stem
|
|
386
|
+
self._entries: Optional[list] = None
|
|
387
|
+
self._by_uuid: Optional[dict] = None
|
|
388
|
+
self._children: Optional[dict] = None
|
|
389
|
+
self._entry_positions: Optional[dict] = None # uuid -> file line index
|
|
390
|
+
|
|
391
|
+
def _load(self):
|
|
392
|
+
"""Single-pass load of all entries."""
|
|
393
|
+
entries = []
|
|
394
|
+
by_uuid = {}
|
|
395
|
+
positions = {}
|
|
396
|
+
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
|
|
397
|
+
for i, line in enumerate(f):
|
|
398
|
+
line = line.strip()
|
|
399
|
+
if not line:
|
|
400
|
+
continue
|
|
401
|
+
try:
|
|
402
|
+
entry = json.loads(line)
|
|
403
|
+
entry["_line"] = i # track file position
|
|
404
|
+
entries.append(entry)
|
|
405
|
+
uuid = entry.get("uuid")
|
|
406
|
+
if uuid:
|
|
407
|
+
by_uuid[uuid] = entry
|
|
408
|
+
positions[uuid] = i
|
|
409
|
+
except json.JSONDecodeError:
|
|
410
|
+
continue
|
|
411
|
+
self._entries = entries
|
|
412
|
+
self._by_uuid = by_uuid
|
|
413
|
+
self._entry_positions = positions
|
|
414
|
+
|
|
415
|
+
@property
|
|
416
|
+
def entries(self) -> list:
|
|
417
|
+
if self._entries is None:
|
|
418
|
+
self._load()
|
|
419
|
+
return self._entries
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def by_uuid(self) -> dict:
|
|
423
|
+
if self._by_uuid is None:
|
|
424
|
+
self._load()
|
|
425
|
+
return self._by_uuid
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def entry_positions(self) -> dict:
|
|
429
|
+
if self._entry_positions is None:
|
|
430
|
+
self._load()
|
|
431
|
+
return self._entry_positions
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def children(self) -> dict:
|
|
435
|
+
"""Children map: parent_uuid -> [child_uuids]. Built on demand."""
|
|
436
|
+
if self._children is None:
|
|
437
|
+
self._children = defaultdict(list)
|
|
438
|
+
for entry in self.entries:
|
|
439
|
+
uuid = entry.get("uuid")
|
|
440
|
+
parent = entry.get("parentUuid")
|
|
441
|
+
if uuid and parent:
|
|
442
|
+
self._children[parent].append(uuid)
|
|
443
|
+
return self._children
|
|
444
|
+
|
|
445
|
+
def active_path(self, stitch: bool = True) -> list[dict]:
|
|
446
|
+
"""
|
|
447
|
+
Extract the active conversation path.
|
|
448
|
+
|
|
449
|
+
1. Find the last entry with a UUID (by file position)
|
|
450
|
+
2. Walk backward via parentUuid
|
|
451
|
+
3. At compact_boundary entries, optionally stitch via logicalParentUuid
|
|
452
|
+
4. Return path in root-to-leaf order
|
|
453
|
+
"""
|
|
454
|
+
# Find the last UUID entry that is NOT a sidechain and NOT progress
|
|
455
|
+
last_entry = None
|
|
456
|
+
for entry in reversed(self.entries):
|
|
457
|
+
uuid = entry.get("uuid")
|
|
458
|
+
if not uuid:
|
|
459
|
+
continue
|
|
460
|
+
if entry.get("isSidechain"):
|
|
461
|
+
continue
|
|
462
|
+
last_entry = entry
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
if not last_entry:
|
|
466
|
+
return []
|
|
467
|
+
|
|
468
|
+
# Walk backward
|
|
469
|
+
raw_path = []
|
|
470
|
+
current_uuid = last_entry.get("uuid")
|
|
471
|
+
visited = set()
|
|
472
|
+
|
|
473
|
+
while current_uuid and current_uuid not in visited:
|
|
474
|
+
visited.add(current_uuid)
|
|
475
|
+
entry = self.by_uuid.get(current_uuid)
|
|
476
|
+
if not entry:
|
|
477
|
+
if stitch and raw_path:
|
|
478
|
+
# Broken parent link (e.g., context continuation).
|
|
479
|
+
# Bridge by finding the last UUID entry before the
|
|
480
|
+
# earliest entry in our path so far.
|
|
481
|
+
earliest_line = min(
|
|
482
|
+
e.get("_line", float("inf")) for e in raw_path
|
|
483
|
+
)
|
|
484
|
+
fallback = None
|
|
485
|
+
for e in reversed(self.entries):
|
|
486
|
+
if e.get("_line", 0) >= earliest_line:
|
|
487
|
+
continue
|
|
488
|
+
if e.get("uuid") and e.get("type") != "progress":
|
|
489
|
+
fallback = e
|
|
490
|
+
break
|
|
491
|
+
if fallback and fallback["uuid"] not in visited:
|
|
492
|
+
current_uuid = fallback["uuid"]
|
|
493
|
+
continue
|
|
494
|
+
break
|
|
495
|
+
|
|
496
|
+
raw_path.append(entry)
|
|
497
|
+
|
|
498
|
+
if entry.get("subtype") == "compact_boundary":
|
|
499
|
+
if stitch:
|
|
500
|
+
# Jump to the entry before compaction
|
|
501
|
+
logical_parent = entry.get("logicalParentUuid")
|
|
502
|
+
if logical_parent and logical_parent in self.by_uuid:
|
|
503
|
+
current_uuid = logical_parent
|
|
504
|
+
else:
|
|
505
|
+
# logicalParentUuid target missing — fallback:
|
|
506
|
+
# find the last UUID entry before this compact_boundary
|
|
507
|
+
# in file order (it's part of the pre-compaction tree)
|
|
508
|
+
cb_line = entry.get("_line", float("inf"))
|
|
509
|
+
fallback = None
|
|
510
|
+
for e in reversed(self.entries):
|
|
511
|
+
if e.get("_line", 0) >= cb_line:
|
|
512
|
+
continue
|
|
513
|
+
if e.get("uuid") and e.get("type") != "progress":
|
|
514
|
+
fallback = e
|
|
515
|
+
break
|
|
516
|
+
if fallback:
|
|
517
|
+
current_uuid = fallback["uuid"]
|
|
518
|
+
else:
|
|
519
|
+
break
|
|
520
|
+
else:
|
|
521
|
+
# No stitching, stop at compaction boundary
|
|
522
|
+
break
|
|
523
|
+
else:
|
|
524
|
+
current_uuid = entry.get("parentUuid")
|
|
525
|
+
|
|
526
|
+
raw_path.reverse()
|
|
527
|
+
return raw_path
|
|
528
|
+
|
|
529
|
+
def branch_points(self) -> list[BranchPoint]:
|
|
530
|
+
"""
|
|
531
|
+
Find true user-initiated branch points (excluding mechanical fan-out).
|
|
532
|
+
|
|
533
|
+
A true branch is where a parent has multiple children that aren't just:
|
|
534
|
+
- tool_use fan-out (assistant+tool_use -> {next_assistant, tool_result})
|
|
535
|
+
- progress entry forks (progress + tool_result sharing parent)
|
|
536
|
+
"""
|
|
537
|
+
active_set = set()
|
|
538
|
+
for entry in self.active_path():
|
|
539
|
+
uuid = entry.get("uuid")
|
|
540
|
+
if uuid:
|
|
541
|
+
active_set.add(uuid)
|
|
542
|
+
|
|
543
|
+
branch_points = []
|
|
544
|
+
checked_parents = set()
|
|
545
|
+
|
|
546
|
+
for entry in self.active_path():
|
|
547
|
+
parent_uuid = entry.get("parentUuid")
|
|
548
|
+
if not parent_uuid or parent_uuid in checked_parents:
|
|
549
|
+
continue
|
|
550
|
+
checked_parents.add(parent_uuid)
|
|
551
|
+
|
|
552
|
+
child_uuids = self.children.get(parent_uuid, [])
|
|
553
|
+
if len(child_uuids) <= 1:
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
# Check if this is mechanical fan-out
|
|
557
|
+
if self._is_mechanical_fork(parent_uuid, child_uuids):
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
# Real branch: find alternatives not on active path
|
|
561
|
+
alternatives = [u for u in child_uuids if u not in active_set]
|
|
562
|
+
if not alternatives:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
parent_entry = self.by_uuid.get(parent_uuid)
|
|
566
|
+
branch_points.append(BranchPoint(
|
|
567
|
+
parent_uuid=parent_uuid,
|
|
568
|
+
active_child_uuid=entry.get("uuid", ""),
|
|
569
|
+
alternative_uuids=alternatives,
|
|
570
|
+
line_index=parent_entry.get("_line", 0) if parent_entry else 0,
|
|
571
|
+
))
|
|
572
|
+
|
|
573
|
+
return branch_points
|
|
574
|
+
|
|
575
|
+
def _is_mechanical_fork(self, parent_uuid: str, child_uuids: list) -> bool:
|
|
576
|
+
"""
|
|
577
|
+
Returns True if the fork is a mechanical artifact, not a real user branch.
|
|
578
|
+
|
|
579
|
+
Mechanical forks:
|
|
580
|
+
1. tool_use fan-out: assistant(tool_use) -> {assistant(next block), user(tool_result)}
|
|
581
|
+
2. progress fork: {progress, user(tool_result)} sharing assistant(tool_use) parent
|
|
582
|
+
3. Multi-tool fan-out: multiple tool_results sharing same assistant parent
|
|
583
|
+
"""
|
|
584
|
+
parent_entry = self.by_uuid.get(parent_uuid)
|
|
585
|
+
if not parent_entry:
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
parent_type = parent_entry.get("type")
|
|
589
|
+
|
|
590
|
+
# Check child types
|
|
591
|
+
child_types = set()
|
|
592
|
+
child_has_tool_result = False
|
|
593
|
+
child_has_progress = False
|
|
594
|
+
for uuid in child_uuids:
|
|
595
|
+
child = self.by_uuid.get(uuid)
|
|
596
|
+
if not child:
|
|
597
|
+
continue
|
|
598
|
+
ct = child.get("type")
|
|
599
|
+
child_types.add(ct)
|
|
600
|
+
|
|
601
|
+
if ct == "user":
|
|
602
|
+
content = child.get("message", {}).get("content")
|
|
603
|
+
if isinstance(content, list):
|
|
604
|
+
child_has_tool_result = True
|
|
605
|
+
elif ct == "progress":
|
|
606
|
+
child_has_progress = True
|
|
607
|
+
|
|
608
|
+
# Pattern 1: assistant parent with tool_use, children are {assistant, user(tool_result)}
|
|
609
|
+
if parent_type == "assistant":
|
|
610
|
+
blocks = parent_entry.get("message", {}).get("content", [])
|
|
611
|
+
has_tool_use = any(
|
|
612
|
+
isinstance(b, dict) and b.get("type") == "tool_use"
|
|
613
|
+
for b in blocks
|
|
614
|
+
)
|
|
615
|
+
if has_tool_use:
|
|
616
|
+
# Any fork from a tool_use assistant is mechanical
|
|
617
|
+
return True
|
|
618
|
+
|
|
619
|
+
# Pattern 2: progress entries mixed with other children
|
|
620
|
+
if child_has_progress:
|
|
621
|
+
# If all non-progress children are the same, it's mechanical
|
|
622
|
+
non_progress = [u for u in child_uuids
|
|
623
|
+
if self.by_uuid.get(u, {}).get("type") != "progress"]
|
|
624
|
+
if len(non_progress) <= 1:
|
|
625
|
+
return True
|
|
626
|
+
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
631
|
+
# MESSAGE EXTRACTION
|
|
632
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def group_into_turns(raw_path: list[dict], mode: str = "text",
|
|
636
|
+
include_compact_summaries: bool = False) -> list[Turn]:
|
|
637
|
+
"""
|
|
638
|
+
Group raw path entries into conversation turns.
|
|
639
|
+
|
|
640
|
+
A turn = one user text message + the full assistant response
|
|
641
|
+
(all text blocks concatenated across consecutive assistant entries).
|
|
642
|
+
|
|
643
|
+
mode='text': user text + assistant text only
|
|
644
|
+
mode='tools': also collect tool call summaries
|
|
645
|
+
mode='raw': not used here (see extract_raw_messages)
|
|
646
|
+
"""
|
|
647
|
+
turns = []
|
|
648
|
+
current_turn: Optional[Turn] = None
|
|
649
|
+
|
|
650
|
+
for entry in raw_path:
|
|
651
|
+
entry_type = entry.get("type")
|
|
652
|
+
|
|
653
|
+
# Skip non-conversation entries
|
|
654
|
+
if entry_type in ("progress", "file-history-snapshot",
|
|
655
|
+
"queue-operation", "custom-title", "summary"):
|
|
656
|
+
continue
|
|
657
|
+
if entry_type == "system":
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
if entry_type == "user":
|
|
661
|
+
msg = entry.get("message", {})
|
|
662
|
+
content = msg.get("content")
|
|
663
|
+
|
|
664
|
+
# Extract text from user messages
|
|
665
|
+
user_text = None
|
|
666
|
+
if isinstance(content, str) and content.strip():
|
|
667
|
+
user_text = content
|
|
668
|
+
elif isinstance(content, list):
|
|
669
|
+
# List content may have text blocks alongside tool_results
|
|
670
|
+
text_parts = []
|
|
671
|
+
for block in content:
|
|
672
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
673
|
+
t = block.get("text", "").strip()
|
|
674
|
+
if t:
|
|
675
|
+
text_parts.append(t)
|
|
676
|
+
if text_parts:
|
|
677
|
+
user_text = "\n".join(text_parts)
|
|
678
|
+
|
|
679
|
+
if user_text:
|
|
680
|
+
is_compact = bool(entry.get("isCompactSummary"))
|
|
681
|
+
|
|
682
|
+
# Skip compact summaries unless requested
|
|
683
|
+
if is_compact and not include_compact_summaries:
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
# Save previous turn
|
|
687
|
+
if current_turn is not None:
|
|
688
|
+
turns.append(current_turn)
|
|
689
|
+
|
|
690
|
+
current_turn = Turn(
|
|
691
|
+
user_text=user_text,
|
|
692
|
+
assistant_text="",
|
|
693
|
+
tool_calls=[],
|
|
694
|
+
timestamp=entry.get("timestamp", ""),
|
|
695
|
+
uuid=entry.get("uuid", ""),
|
|
696
|
+
is_compact_summary=is_compact,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
elif entry_type == "assistant" and current_turn is not None:
|
|
700
|
+
blocks = entry.get("message", {}).get("content", [])
|
|
701
|
+
for block in blocks:
|
|
702
|
+
if not isinstance(block, dict):
|
|
703
|
+
continue
|
|
704
|
+
btype = block.get("type")
|
|
705
|
+
|
|
706
|
+
if btype == "text":
|
|
707
|
+
text = block.get("text", "")
|
|
708
|
+
if text.strip():
|
|
709
|
+
if current_turn.assistant_text:
|
|
710
|
+
current_turn.assistant_text += "\n" + text
|
|
711
|
+
else:
|
|
712
|
+
current_turn.assistant_text = text
|
|
713
|
+
|
|
714
|
+
elif btype == "tool_use" and mode == "tools":
|
|
715
|
+
current_turn.tool_calls.append(ToolSummary(
|
|
716
|
+
name=block.get("name", "?"),
|
|
717
|
+
input_data=block.get("input", {}),
|
|
718
|
+
))
|
|
719
|
+
|
|
720
|
+
# Don't forget the last turn
|
|
721
|
+
if current_turn is not None:
|
|
722
|
+
turns.append(current_turn)
|
|
723
|
+
|
|
724
|
+
return turns
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def extract_raw_messages(raw_path: list[dict], truncate_len: int = 500) -> list[RawMessage]:
|
|
728
|
+
"""
|
|
729
|
+
Extract ALL messages from raw path for --raw mode.
|
|
730
|
+
Includes tool calls, tool results, thinking blocks, system entries.
|
|
731
|
+
"""
|
|
732
|
+
do_truncate = truncate_len > 0
|
|
733
|
+
messages = []
|
|
734
|
+
|
|
735
|
+
for entry in raw_path:
|
|
736
|
+
entry_type = entry.get("type")
|
|
737
|
+
|
|
738
|
+
# Skip non-content entries
|
|
739
|
+
if entry_type in ("file-history-snapshot", "queue-operation",
|
|
740
|
+
"custom-title", "summary"):
|
|
741
|
+
continue
|
|
742
|
+
if entry_type == "progress":
|
|
743
|
+
continue
|
|
744
|
+
|
|
745
|
+
if entry_type == "system":
|
|
746
|
+
subtype = entry.get("subtype", "")
|
|
747
|
+
if subtype in ("compact_boundary", "microcompact_boundary"):
|
|
748
|
+
content = entry.get("content", "")
|
|
749
|
+
meta = ""
|
|
750
|
+
if subtype == "compact_boundary":
|
|
751
|
+
cm = entry.get("compactMetadata", {})
|
|
752
|
+
meta = f" (trigger={cm.get('trigger', '?')}, preTokens={cm.get('preTokens', '?')})"
|
|
753
|
+
elif subtype == "microcompact_boundary":
|
|
754
|
+
cm = entry.get("microcompactMetadata", {})
|
|
755
|
+
meta = f" (trigger={cm.get('trigger', '?')}, saved={cm.get('tokensSaved', '?')} tokens)"
|
|
756
|
+
messages.append(RawMessage(
|
|
757
|
+
role=f"system ({subtype})",
|
|
758
|
+
content=f"{content}{meta}",
|
|
759
|
+
timestamp=entry.get("timestamp", ""),
|
|
760
|
+
uuid=entry.get("uuid", ""),
|
|
761
|
+
entry_type=entry_type,
|
|
762
|
+
))
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
if entry_type == "user":
|
|
766
|
+
msg = entry.get("message", {})
|
|
767
|
+
content = msg.get("content")
|
|
768
|
+
|
|
769
|
+
if isinstance(content, str):
|
|
770
|
+
if content.strip():
|
|
771
|
+
role = "user"
|
|
772
|
+
if entry.get("isCompactSummary"):
|
|
773
|
+
role = "user (compact_summary)"
|
|
774
|
+
messages.append(RawMessage(
|
|
775
|
+
role=role,
|
|
776
|
+
content=content,
|
|
777
|
+
timestamp=entry.get("timestamp", ""),
|
|
778
|
+
uuid=entry.get("uuid", ""),
|
|
779
|
+
entry_type=entry_type,
|
|
780
|
+
))
|
|
781
|
+
elif isinstance(content, list):
|
|
782
|
+
# Tool result
|
|
783
|
+
parts = []
|
|
784
|
+
for item in content:
|
|
785
|
+
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
786
|
+
tool_id = item.get("tool_use_id", "?")[:16]
|
|
787
|
+
result_content = ""
|
|
788
|
+
rc = item.get("content")
|
|
789
|
+
if isinstance(rc, str):
|
|
790
|
+
result_content = rc
|
|
791
|
+
elif isinstance(rc, list):
|
|
792
|
+
for sub in rc:
|
|
793
|
+
if isinstance(sub, dict) and sub.get("type") == "text":
|
|
794
|
+
result_content += sub.get("text", "")
|
|
795
|
+
if do_truncate and len(result_content) > truncate_len:
|
|
796
|
+
result_content = result_content[:truncate_len] + "..."
|
|
797
|
+
is_err = item.get("is_error", False)
|
|
798
|
+
err_marker = " ERROR" if is_err else ""
|
|
799
|
+
parts.append(f"[tool_result {tool_id}{err_marker}]\n{result_content}")
|
|
800
|
+
if parts:
|
|
801
|
+
messages.append(RawMessage(
|
|
802
|
+
role="user (tool_result)",
|
|
803
|
+
content="\n".join(parts),
|
|
804
|
+
timestamp=entry.get("timestamp", ""),
|
|
805
|
+
uuid=entry.get("uuid", ""),
|
|
806
|
+
entry_type=entry_type,
|
|
807
|
+
))
|
|
808
|
+
|
|
809
|
+
elif entry_type == "assistant":
|
|
810
|
+
blocks = entry.get("message", {}).get("content", [])
|
|
811
|
+
parts = []
|
|
812
|
+
has_tool = False
|
|
813
|
+
|
|
814
|
+
for block in blocks:
|
|
815
|
+
if not isinstance(block, dict):
|
|
816
|
+
continue
|
|
817
|
+
btype = block.get("type")
|
|
818
|
+
|
|
819
|
+
if btype == "text":
|
|
820
|
+
text = block.get("text", "")
|
|
821
|
+
if text.strip():
|
|
822
|
+
parts.append(text)
|
|
823
|
+
|
|
824
|
+
elif btype == "tool_use":
|
|
825
|
+
has_tool = True
|
|
826
|
+
name = block.get("name", "?")
|
|
827
|
+
tool_id = block.get("id", "")[:16]
|
|
828
|
+
inp = json.dumps(block.get("input", {}), indent=2)
|
|
829
|
+
tool_input_len = max(100, truncate_len * 3 // 5) if do_truncate else 0
|
|
830
|
+
if do_truncate and len(inp) > tool_input_len:
|
|
831
|
+
inp = inp[:tool_input_len] + "..."
|
|
832
|
+
parts.append(f"[tool_use: {name} ({tool_id})]\n{inp}")
|
|
833
|
+
|
|
834
|
+
elif btype == "thinking":
|
|
835
|
+
thinking = block.get("thinking", "")
|
|
836
|
+
if thinking.strip():
|
|
837
|
+
thinking_len = max(100, truncate_len * 2 // 5) if do_truncate else 0
|
|
838
|
+
if do_truncate and len(thinking) > thinking_len:
|
|
839
|
+
thinking = thinking[:thinking_len] + "..."
|
|
840
|
+
parts.append(f"[thinking]\n{thinking}")
|
|
841
|
+
|
|
842
|
+
if parts:
|
|
843
|
+
role = "assistant (tool)" if has_tool else "assistant"
|
|
844
|
+
messages.append(RawMessage(
|
|
845
|
+
role=role,
|
|
846
|
+
content="\n\n".join(parts),
|
|
847
|
+
timestamp=entry.get("timestamp", ""),
|
|
848
|
+
uuid=entry.get("uuid", ""),
|
|
849
|
+
entry_type=entry_type,
|
|
850
|
+
))
|
|
851
|
+
|
|
852
|
+
return messages
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
856
|
+
# OUTPUT FORMATTING
|
|
857
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def format_turn(turn: Turn, index: int, total: int,
|
|
861
|
+
show_tools: bool = False, show_timestamp: bool = False) -> str:
|
|
862
|
+
"""Format a Turn for display."""
|
|
863
|
+
lines = []
|
|
864
|
+
|
|
865
|
+
# User message
|
|
866
|
+
header = f"[{index}/{total}]"
|
|
867
|
+
if show_timestamp and turn.timestamp:
|
|
868
|
+
ts = _parse_timestamp(turn.timestamp)
|
|
869
|
+
if ts != datetime.min:
|
|
870
|
+
header += f" {ts.strftime('%H:%M:%S')}"
|
|
871
|
+
lines.append(f"{header} USER")
|
|
872
|
+
lines.append("─" * 60)
|
|
873
|
+
if turn.is_compact_summary:
|
|
874
|
+
lines.append("[Compaction Summary]")
|
|
875
|
+
lines.append(turn.user_text)
|
|
876
|
+
lines.append("")
|
|
877
|
+
|
|
878
|
+
# Tool calls (if --tools mode)
|
|
879
|
+
if show_tools and turn.tool_calls:
|
|
880
|
+
for tc in turn.tool_calls:
|
|
881
|
+
lines.append(f" > {tc.one_line()}")
|
|
882
|
+
lines.append("")
|
|
883
|
+
|
|
884
|
+
# Assistant response
|
|
885
|
+
if turn.assistant_text:
|
|
886
|
+
lines.append(f"[{index}/{total}] ASSISTANT")
|
|
887
|
+
if show_tools and turn.tool_calls:
|
|
888
|
+
lines.append(f"({len(turn.tool_calls)} tool calls)")
|
|
889
|
+
lines.append("─" * 60)
|
|
890
|
+
lines.append(turn.assistant_text)
|
|
891
|
+
lines.append("")
|
|
892
|
+
|
|
893
|
+
return "\n".join(lines)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def format_raw_message(msg: RawMessage, index: int, total: int,
|
|
897
|
+
show_timestamp: bool = True) -> str:
|
|
898
|
+
"""Format a RawMessage for display."""
|
|
899
|
+
header = f"[{index}/{total}] {msg.role.upper()}"
|
|
900
|
+
if show_timestamp and msg.timestamp:
|
|
901
|
+
ts = _parse_timestamp(msg.timestamp)
|
|
902
|
+
if ts != datetime.min:
|
|
903
|
+
header += f" ({ts.strftime('%H:%M:%S')})"
|
|
904
|
+
if msg.uuid:
|
|
905
|
+
header += f" uuid={msg.uuid[:12]}"
|
|
906
|
+
|
|
907
|
+
return f"{header}\n{'─' * 60}\n{msg.content}\n"
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def format_turns_json(turns: list[Turn], session_id: str, total: int,
|
|
911
|
+
start_index: int) -> str:
|
|
912
|
+
"""Format turns as JSON."""
|
|
913
|
+
result = {
|
|
914
|
+
"session_id": session_id,
|
|
915
|
+
"total_turns": total,
|
|
916
|
+
"turns": [],
|
|
917
|
+
}
|
|
918
|
+
for i, turn in enumerate(turns):
|
|
919
|
+
t = {
|
|
920
|
+
"index": start_index + i,
|
|
921
|
+
"user": {
|
|
922
|
+
"text": turn.user_text,
|
|
923
|
+
"uuid": turn.uuid,
|
|
924
|
+
"timestamp": turn.timestamp,
|
|
925
|
+
"is_compact_summary": turn.is_compact_summary,
|
|
926
|
+
},
|
|
927
|
+
"assistant": {
|
|
928
|
+
"text": turn.assistant_text,
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
if turn.tool_calls:
|
|
932
|
+
t["assistant"]["tool_calls"] = [
|
|
933
|
+
{"name": tc.name, "summary": tc.one_line()}
|
|
934
|
+
for tc in turn.tool_calls
|
|
935
|
+
]
|
|
936
|
+
result["turns"].append(t)
|
|
937
|
+
return json.dumps(result, indent=2, ensure_ascii=False)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def format_raw_json(messages: list[RawMessage], session_id: str) -> str:
|
|
941
|
+
"""Format raw messages as JSON."""
|
|
942
|
+
result = {
|
|
943
|
+
"session_id": session_id,
|
|
944
|
+
"total_messages": len(messages),
|
|
945
|
+
"messages": [
|
|
946
|
+
{
|
|
947
|
+
"role": msg.role,
|
|
948
|
+
"content": msg.content,
|
|
949
|
+
"uuid": msg.uuid,
|
|
950
|
+
"timestamp": msg.timestamp,
|
|
951
|
+
}
|
|
952
|
+
for msg in messages
|
|
953
|
+
],
|
|
954
|
+
}
|
|
955
|
+
return json.dumps(result, indent=2, ensure_ascii=False)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
959
|
+
# CLIPBOARD
|
|
960
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
964
|
+
"""Copy text to clipboard using clip.exe (WSL)."""
|
|
965
|
+
try:
|
|
966
|
+
process = subprocess.Popen(
|
|
967
|
+
["clip.exe"],
|
|
968
|
+
stdin=subprocess.PIPE,
|
|
969
|
+
shell=False,
|
|
970
|
+
)
|
|
971
|
+
process.communicate(input=text.encode("utf-16-le"))
|
|
972
|
+
return process.returncode == 0
|
|
973
|
+
except Exception as e:
|
|
974
|
+
print(f"Error copying to clipboard: {e}", file=sys.stderr)
|
|
975
|
+
return False
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
979
|
+
# SESSION RESOLUTION HELPERS
|
|
980
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def resolve_session(project_dir: Path, session_arg: Optional[str]) -> Path:
|
|
984
|
+
"""Resolve session argument to a JSONL file path."""
|
|
985
|
+
if session_arg is None:
|
|
986
|
+
# Latest session by mtime
|
|
987
|
+
files = sorted(
|
|
988
|
+
[f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
|
|
989
|
+
key=lambda x: x.stat().st_mtime,
|
|
990
|
+
reverse=True,
|
|
991
|
+
)
|
|
992
|
+
if not files:
|
|
993
|
+
print("No sessions found.", file=sys.stderr)
|
|
994
|
+
sys.exit(1)
|
|
995
|
+
return files[0]
|
|
996
|
+
|
|
997
|
+
# Numeric index: Nth from list
|
|
998
|
+
if session_arg.isdigit():
|
|
999
|
+
idx = int(session_arg)
|
|
1000
|
+
index = SessionIndex(project_dir)
|
|
1001
|
+
sessions = index.list_sessions(idx)
|
|
1002
|
+
if idx <= len(sessions):
|
|
1003
|
+
return sessions[idx - 1].path
|
|
1004
|
+
else:
|
|
1005
|
+
print(f"Error: Session index {session_arg} out of range (have {len(sessions)})", file=sys.stderr)
|
|
1006
|
+
sys.exit(1)
|
|
1007
|
+
|
|
1008
|
+
# UUID prefix match
|
|
1009
|
+
matches = list(project_dir.glob(f"{session_arg}*.jsonl"))
|
|
1010
|
+
matches = [m for m in matches if not m.name.startswith("agent-")]
|
|
1011
|
+
if matches:
|
|
1012
|
+
if len(matches) == 1:
|
|
1013
|
+
return matches[0]
|
|
1014
|
+
else:
|
|
1015
|
+
print(f"Ambiguous session prefix '{session_arg}', matches:", file=sys.stderr)
|
|
1016
|
+
for m in matches[:5]:
|
|
1017
|
+
print(f" {m.stem}", file=sys.stderr)
|
|
1018
|
+
sys.exit(1)
|
|
1019
|
+
|
|
1020
|
+
print(f"Error: Session '{session_arg}' not found.", file=sys.stderr)
|
|
1021
|
+
print("Use 'cchat list' to see available sessions.", file=sys.stderr)
|
|
1022
|
+
sys.exit(1)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def parse_range(range_str: str, max_val: int) -> list[int]:
|
|
1026
|
+
"""Parse a range string like '3', '3-7', '-1', '-3--1' into 1-based indices."""
|
|
1027
|
+
indices = []
|
|
1028
|
+
|
|
1029
|
+
# Handle negative-to-negative range: -3--1
|
|
1030
|
+
m = re.match(r"^(-?\d+)--(-?\d+)$", range_str)
|
|
1031
|
+
if m:
|
|
1032
|
+
start, end = int(m.group(1)), -int(m.group(2))
|
|
1033
|
+
else:
|
|
1034
|
+
m = re.match(r"^(-?\d+)-(\d+)$", range_str)
|
|
1035
|
+
if m:
|
|
1036
|
+
start, end = int(m.group(1)), int(m.group(2))
|
|
1037
|
+
elif range_str.lstrip("-").isdigit():
|
|
1038
|
+
val = int(range_str)
|
|
1039
|
+
if val < 0:
|
|
1040
|
+
idx = max_val + val + 1
|
|
1041
|
+
return [idx] if 1 <= idx <= max_val else []
|
|
1042
|
+
else:
|
|
1043
|
+
return [val] if 1 <= val <= max_val else []
|
|
1044
|
+
else:
|
|
1045
|
+
print(f"Error: Invalid range '{range_str}'", file=sys.stderr)
|
|
1046
|
+
return []
|
|
1047
|
+
|
|
1048
|
+
# Resolve negatives
|
|
1049
|
+
if start < 0:
|
|
1050
|
+
start = max_val + start + 1
|
|
1051
|
+
if end < 0:
|
|
1052
|
+
end = max_val + end + 1
|
|
1053
|
+
|
|
1054
|
+
return [i for i in range(start, end + 1) if 1 <= i <= max_val]
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def compute_indices(total: int, n: Optional[int], range_str: Optional[str],
|
|
1058
|
+
show_all: bool) -> list[int]:
|
|
1059
|
+
"""Compute which turn indices to show."""
|
|
1060
|
+
if show_all:
|
|
1061
|
+
return list(range(1, total + 1))
|
|
1062
|
+
elif range_str:
|
|
1063
|
+
return parse_range(range_str, total)
|
|
1064
|
+
elif n:
|
|
1065
|
+
start = max(0, total - n)
|
|
1066
|
+
return list(range(start + 1, total + 1))
|
|
1067
|
+
else:
|
|
1068
|
+
start = max(0, total - DEFAULT_TURNS)
|
|
1069
|
+
return list(range(start + 1, total + 1))
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1073
|
+
# COMMANDS
|
|
1074
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def cmd_list(args):
|
|
1078
|
+
"""List recent sessions."""
|
|
1079
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1080
|
+
index = SessionIndex(project_dir)
|
|
1081
|
+
sessions = index.list_sessions(args.count)
|
|
1082
|
+
|
|
1083
|
+
if not sessions:
|
|
1084
|
+
print("No sessions found.")
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
print(f"Sessions in {project_dir.name}:\n")
|
|
1088
|
+
for i, s in enumerate(sessions, 1):
|
|
1089
|
+
modified = ""
|
|
1090
|
+
if s.modified:
|
|
1091
|
+
ts = _parse_timestamp(s.modified)
|
|
1092
|
+
if ts != datetime.min:
|
|
1093
|
+
modified = ts.strftime("%Y-%m-%d %H:%M")
|
|
1094
|
+
else:
|
|
1095
|
+
modified = s.modified[:16]
|
|
1096
|
+
msg_info = f"{s.message_count} msgs"
|
|
1097
|
+
print(f"[{i}] {s.session_id[:8]}... ({msg_info}, {modified})")
|
|
1098
|
+
|
|
1099
|
+
display = s.summary or s.first_prompt
|
|
1100
|
+
if display:
|
|
1101
|
+
# Clean up and truncate
|
|
1102
|
+
display = display.replace("\n", " ").strip()
|
|
1103
|
+
if len(display) > 76:
|
|
1104
|
+
display = display[:76] + "..."
|
|
1105
|
+
print(f" {display}")
|
|
1106
|
+
print()
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def cmd_view(args):
|
|
1110
|
+
"""View messages from a session."""
|
|
1111
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1112
|
+
session_file = resolve_session(project_dir, args.session)
|
|
1113
|
+
session = Session(session_file)
|
|
1114
|
+
|
|
1115
|
+
raw_path = session.active_path(stitch=not args.no_stitch)
|
|
1116
|
+
if not raw_path:
|
|
1117
|
+
print("No messages in this session.")
|
|
1118
|
+
return
|
|
1119
|
+
|
|
1120
|
+
if args.raw:
|
|
1121
|
+
# Raw mode
|
|
1122
|
+
messages = extract_raw_messages(raw_path, truncate_len=args.truncate)
|
|
1123
|
+
total = len(messages)
|
|
1124
|
+
indices = compute_indices(total, args.n, args.r, args.all)
|
|
1125
|
+
|
|
1126
|
+
if not indices:
|
|
1127
|
+
print(f"No messages match (1-{total} available)", file=sys.stderr)
|
|
1128
|
+
sys.exit(1)
|
|
1129
|
+
|
|
1130
|
+
if args.json:
|
|
1131
|
+
selected = [messages[i - 1] for i in indices]
|
|
1132
|
+
print(format_raw_json(selected, session.session_id))
|
|
1133
|
+
else:
|
|
1134
|
+
print(f"Session: {session.session_id}")
|
|
1135
|
+
print(f"Showing {len(indices)} of {total} raw messages")
|
|
1136
|
+
print("=" * 60)
|
|
1137
|
+
for i in indices:
|
|
1138
|
+
print(format_raw_message(messages[i - 1], i, total))
|
|
1139
|
+
else:
|
|
1140
|
+
# Turn mode
|
|
1141
|
+
mode = "tools" if args.tools else "text"
|
|
1142
|
+
turns = group_into_turns(raw_path, mode=mode,
|
|
1143
|
+
include_compact_summaries=args.compact_summaries)
|
|
1144
|
+
|
|
1145
|
+
# Fallback: if no turns found and compact summaries were hidden,
|
|
1146
|
+
# retry with them included (handles sessions where the only user
|
|
1147
|
+
# text is the continuation summary after compaction)
|
|
1148
|
+
if not turns and not args.compact_summaries:
|
|
1149
|
+
turns = group_into_turns(raw_path, mode=mode,
|
|
1150
|
+
include_compact_summaries=True)
|
|
1151
|
+
|
|
1152
|
+
total = len(turns)
|
|
1153
|
+
|
|
1154
|
+
if total == 0:
|
|
1155
|
+
print("No conversation turns in this session.")
|
|
1156
|
+
return
|
|
1157
|
+
|
|
1158
|
+
indices = compute_indices(total, args.n, args.r, args.all)
|
|
1159
|
+
|
|
1160
|
+
if not indices:
|
|
1161
|
+
print(f"No turns match (1-{total} available)", file=sys.stderr)
|
|
1162
|
+
sys.exit(1)
|
|
1163
|
+
|
|
1164
|
+
if args.json:
|
|
1165
|
+
selected = [turns[i - 1] for i in indices]
|
|
1166
|
+
print(format_turns_json(selected, session.session_id, total, indices[0]))
|
|
1167
|
+
else:
|
|
1168
|
+
print(f"Session: {session.session_id}")
|
|
1169
|
+
print(f"Showing {len(indices)} of {total} turns")
|
|
1170
|
+
print("=" * 60)
|
|
1171
|
+
for i in indices:
|
|
1172
|
+
print(format_turn(turns[i - 1], i, total,
|
|
1173
|
+
show_tools=args.tools,
|
|
1174
|
+
show_timestamp=args.timestamps))
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def cmd_copy(args):
|
|
1178
|
+
"""Copy message(s) to clipboard."""
|
|
1179
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1180
|
+
session_file = resolve_session(project_dir, args.session)
|
|
1181
|
+
session = Session(session_file)
|
|
1182
|
+
|
|
1183
|
+
raw_path = session.active_path(stitch=True)
|
|
1184
|
+
if not raw_path:
|
|
1185
|
+
print("No messages in this session.", file=sys.stderr)
|
|
1186
|
+
sys.exit(1)
|
|
1187
|
+
|
|
1188
|
+
if args.raw:
|
|
1189
|
+
messages = extract_raw_messages(raw_path, truncate_len=-1)
|
|
1190
|
+
total = len(messages)
|
|
1191
|
+
|
|
1192
|
+
# Default: last message
|
|
1193
|
+
if args.r is None and args.n is None:
|
|
1194
|
+
args.r = "-1"
|
|
1195
|
+
|
|
1196
|
+
indices = compute_indices(total, args.n, args.r, False)
|
|
1197
|
+
if not indices:
|
|
1198
|
+
print(f"No messages match (1-{total} available)", file=sys.stderr)
|
|
1199
|
+
sys.exit(1)
|
|
1200
|
+
|
|
1201
|
+
texts = []
|
|
1202
|
+
for i in indices:
|
|
1203
|
+
msg = messages[i - 1]
|
|
1204
|
+
texts.append(f"**{msg.role.title()}:**\n\n{msg.content}")
|
|
1205
|
+
combined = "\n\n---\n\n".join(texts)
|
|
1206
|
+
else:
|
|
1207
|
+
mode = "tools" if args.tools else "text"
|
|
1208
|
+
turns = group_into_turns(raw_path, mode=mode)
|
|
1209
|
+
if not turns:
|
|
1210
|
+
turns = group_into_turns(raw_path, mode=mode,
|
|
1211
|
+
include_compact_summaries=True)
|
|
1212
|
+
total = len(turns)
|
|
1213
|
+
|
|
1214
|
+
# Default: last turn's assistant response
|
|
1215
|
+
if args.r is None and args.n is None:
|
|
1216
|
+
args.r = "-1"
|
|
1217
|
+
|
|
1218
|
+
indices = compute_indices(total, args.n, args.r, False)
|
|
1219
|
+
if not indices:
|
|
1220
|
+
print(f"No turns match (1-{total} available)", file=sys.stderr)
|
|
1221
|
+
sys.exit(1)
|
|
1222
|
+
|
|
1223
|
+
texts = []
|
|
1224
|
+
for i in indices:
|
|
1225
|
+
turn = turns[i - 1]
|
|
1226
|
+
parts = []
|
|
1227
|
+
if turn.user_text:
|
|
1228
|
+
parts.append(f"**User:**\n\n{turn.user_text}")
|
|
1229
|
+
if turn.assistant_text:
|
|
1230
|
+
parts.append(f"**Assistant:**\n\n{turn.assistant_text}")
|
|
1231
|
+
texts.append("\n\n".join(parts))
|
|
1232
|
+
combined = "\n\n---\n\n".join(texts)
|
|
1233
|
+
|
|
1234
|
+
if copy_to_clipboard(combined):
|
|
1235
|
+
if len(indices) == 1:
|
|
1236
|
+
print(f"Copied turn #{indices[0]} to clipboard ({len(combined)} chars)")
|
|
1237
|
+
else:
|
|
1238
|
+
print(f"Copied {len(indices)} turns (#{indices[0]}-#{indices[-1]}) to clipboard ({len(combined)} chars)")
|
|
1239
|
+
else:
|
|
1240
|
+
print("Failed to copy to clipboard", file=sys.stderr)
|
|
1241
|
+
sys.exit(1)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def cmd_projects(args):
|
|
1245
|
+
"""List all projects."""
|
|
1246
|
+
projects = ProjectResolver.list_all_projects()
|
|
1247
|
+
|
|
1248
|
+
if not projects:
|
|
1249
|
+
print("No projects found.")
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
print(f"Projects ({len(projects)}):\n")
|
|
1253
|
+
for i, p in enumerate(projects, 1):
|
|
1254
|
+
modified = p["latest_modified"].strftime("%Y-%m-%d %H:%M")
|
|
1255
|
+
print(f"[{i}] {p['decoded_path']}")
|
|
1256
|
+
print(f" {p['session_count']} sessions, last active: {modified}")
|
|
1257
|
+
print(f" key: {p['name']}")
|
|
1258
|
+
print()
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def cmd_search(args):
|
|
1262
|
+
"""Search across sessions for a pattern."""
|
|
1263
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1264
|
+
pattern = args.pattern
|
|
1265
|
+
limit = args.limit
|
|
1266
|
+
|
|
1267
|
+
files = sorted(
|
|
1268
|
+
[f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
|
|
1269
|
+
key=lambda x: x.stat().st_mtime,
|
|
1270
|
+
reverse=True,
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
if not files:
|
|
1274
|
+
print("No sessions to search.")
|
|
1275
|
+
return
|
|
1276
|
+
|
|
1277
|
+
pattern_lower = pattern.lower()
|
|
1278
|
+
results = []
|
|
1279
|
+
|
|
1280
|
+
for f in files:
|
|
1281
|
+
if len(results) >= limit:
|
|
1282
|
+
break
|
|
1283
|
+
try:
|
|
1284
|
+
with open(f, "r", encoding="utf-8", errors="replace") as fp:
|
|
1285
|
+
for line_num, line in enumerate(fp):
|
|
1286
|
+
if len(results) >= limit:
|
|
1287
|
+
break
|
|
1288
|
+
if pattern_lower not in line.lower():
|
|
1289
|
+
continue
|
|
1290
|
+
try:
|
|
1291
|
+
entry = json.loads(line)
|
|
1292
|
+
entry_type = entry.get("type")
|
|
1293
|
+
if entry_type not in ("user", "assistant"):
|
|
1294
|
+
continue
|
|
1295
|
+
msg = entry.get("message", {})
|
|
1296
|
+
content = msg.get("content")
|
|
1297
|
+
|
|
1298
|
+
# Extract searchable text
|
|
1299
|
+
text = ""
|
|
1300
|
+
if isinstance(content, str):
|
|
1301
|
+
text = content
|
|
1302
|
+
elif isinstance(content, list):
|
|
1303
|
+
for block in content:
|
|
1304
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
1305
|
+
text += block.get("text", "")
|
|
1306
|
+
|
|
1307
|
+
if pattern_lower in text.lower():
|
|
1308
|
+
# Find the match context
|
|
1309
|
+
idx = text.lower().index(pattern_lower)
|
|
1310
|
+
start = max(0, idx - 40)
|
|
1311
|
+
end = min(len(text), idx + len(pattern) + 40)
|
|
1312
|
+
snippet = text[start:end].replace("\n", " ")
|
|
1313
|
+
if start > 0:
|
|
1314
|
+
snippet = "..." + snippet
|
|
1315
|
+
if end < len(text):
|
|
1316
|
+
snippet = snippet + "..."
|
|
1317
|
+
|
|
1318
|
+
results.append({
|
|
1319
|
+
"session_id": f.stem,
|
|
1320
|
+
"role": entry_type,
|
|
1321
|
+
"snippet": snippet,
|
|
1322
|
+
"timestamp": entry.get("timestamp", ""),
|
|
1323
|
+
"file": f,
|
|
1324
|
+
})
|
|
1325
|
+
except json.JSONDecodeError:
|
|
1326
|
+
continue
|
|
1327
|
+
except OSError:
|
|
1328
|
+
continue
|
|
1329
|
+
|
|
1330
|
+
if not results:
|
|
1331
|
+
print(f"No matches for '{pattern}'.")
|
|
1332
|
+
return
|
|
1333
|
+
|
|
1334
|
+
print(f"Found {len(results)} match{'es' if len(results) != 1 else ''} for '{pattern}':\n")
|
|
1335
|
+
for i, r in enumerate(results, 1):
|
|
1336
|
+
ts = ""
|
|
1337
|
+
if r["timestamp"]:
|
|
1338
|
+
parsed = _parse_timestamp(r["timestamp"])
|
|
1339
|
+
if parsed != datetime.min:
|
|
1340
|
+
ts = parsed.strftime("%Y-%m-%d %H:%M")
|
|
1341
|
+
print(f"[{i}] {r['session_id'][:8]}... ({r['role']}, {ts})")
|
|
1342
|
+
print(f" {r['snippet']}")
|
|
1343
|
+
print()
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def cmd_tree(args):
|
|
1347
|
+
"""Show conversation tree structure."""
|
|
1348
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1349
|
+
session_file = resolve_session(project_dir, args.session)
|
|
1350
|
+
session = Session(session_file)
|
|
1351
|
+
|
|
1352
|
+
raw_path = session.active_path(stitch=True)
|
|
1353
|
+
if not raw_path:
|
|
1354
|
+
print("No messages in this session.")
|
|
1355
|
+
return
|
|
1356
|
+
|
|
1357
|
+
turns = group_into_turns(raw_path, mode="text")
|
|
1358
|
+
branch_points = session.branch_points()
|
|
1359
|
+
|
|
1360
|
+
# Build a set of UUIDs that are branch parents
|
|
1361
|
+
branch_parent_uuids = {bp.parent_uuid for bp in branch_points}
|
|
1362
|
+
|
|
1363
|
+
print(f"Session: {session.session_id}")
|
|
1364
|
+
print(f"Turns: {len(turns)}, Branch points: {len(branch_points)}")
|
|
1365
|
+
print("=" * 60)
|
|
1366
|
+
|
|
1367
|
+
# Show turns with branch markers
|
|
1368
|
+
for i, turn in enumerate(turns, 1):
|
|
1369
|
+
prefix = "├──" if i < len(turns) else "└──"
|
|
1370
|
+
user_preview = turn.user_text.replace("\n", " ")[:60]
|
|
1371
|
+
if len(turn.user_text) > 60:
|
|
1372
|
+
user_preview += "..."
|
|
1373
|
+
|
|
1374
|
+
print(f"{prefix} [{i}] User: {user_preview}")
|
|
1375
|
+
|
|
1376
|
+
if turn.assistant_text:
|
|
1377
|
+
asst_preview = turn.assistant_text.replace("\n", " ")[:60]
|
|
1378
|
+
if len(turn.assistant_text) > 60:
|
|
1379
|
+
asst_preview += "..."
|
|
1380
|
+
indent = "│ " if i < len(turns) else " "
|
|
1381
|
+
print(f"{indent} Assistant: {asst_preview}")
|
|
1382
|
+
|
|
1383
|
+
if turn.tool_calls:
|
|
1384
|
+
print(f"{indent} ({len(turn.tool_calls)} tool calls)")
|
|
1385
|
+
|
|
1386
|
+
if branch_points:
|
|
1387
|
+
print(f"\nBranch Points ({len(branch_points)}):")
|
|
1388
|
+
print("─" * 40)
|
|
1389
|
+
for bp in branch_points:
|
|
1390
|
+
parent_entry = session.by_uuid.get(bp.parent_uuid, {})
|
|
1391
|
+
parent_type = parent_entry.get("type", "?")
|
|
1392
|
+
n_alts = len(bp.alternative_uuids)
|
|
1393
|
+
print(f" At {bp.parent_uuid[:12]}... ({parent_type}): "
|
|
1394
|
+
f"{n_alts} alternative{'s' if n_alts != 1 else ''}")
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def cmd_export(args):
|
|
1398
|
+
"""Export full session."""
|
|
1399
|
+
project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
|
|
1400
|
+
session_file = resolve_session(project_dir, args.session)
|
|
1401
|
+
session = Session(session_file)
|
|
1402
|
+
|
|
1403
|
+
raw_path = session.active_path(stitch=True)
|
|
1404
|
+
if not raw_path:
|
|
1405
|
+
print("No messages in this session.")
|
|
1406
|
+
return
|
|
1407
|
+
|
|
1408
|
+
if args.json:
|
|
1409
|
+
if args.raw:
|
|
1410
|
+
messages = extract_raw_messages(raw_path, truncate_len=-1)
|
|
1411
|
+
print(format_raw_json(messages, session.session_id))
|
|
1412
|
+
else:
|
|
1413
|
+
mode = "tools" if args.include_tools else "text"
|
|
1414
|
+
turns = group_into_turns(raw_path, mode=mode,
|
|
1415
|
+
include_compact_summaries=True)
|
|
1416
|
+
print(format_turns_json(turns, session.session_id, len(turns), 1))
|
|
1417
|
+
else:
|
|
1418
|
+
# Markdown export
|
|
1419
|
+
mode = "tools" if args.include_tools else "text"
|
|
1420
|
+
turns = group_into_turns(raw_path, mode=mode,
|
|
1421
|
+
include_compact_summaries=True)
|
|
1422
|
+
if not turns:
|
|
1423
|
+
print("No conversation turns.")
|
|
1424
|
+
return
|
|
1425
|
+
|
|
1426
|
+
print(f"# Session {session.session_id}")
|
|
1427
|
+
print(f"**Turns:** {len(turns)}")
|
|
1428
|
+
print()
|
|
1429
|
+
for i, turn in enumerate(turns, 1):
|
|
1430
|
+
print(format_turn(turn, i, len(turns),
|
|
1431
|
+
show_tools=args.include_tools,
|
|
1432
|
+
show_timestamp=True))
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1436
|
+
# CLI PARSER
|
|
1437
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _add_project_arg(p):
|
|
1441
|
+
"""Add --project/-p to a subparser."""
|
|
1442
|
+
p.add_argument("--project", "-p", metavar="PATH",
|
|
1443
|
+
help="Use project at PATH instead of cwd")
|
|
1444
|
+
return p
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1448
|
+
parser = argparse.ArgumentParser(
|
|
1449
|
+
prog="cchat",
|
|
1450
|
+
description="Claude Code Chat History Browser - Browse, search, and copy messages",
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
1454
|
+
|
|
1455
|
+
# list
|
|
1456
|
+
list_p = subparsers.add_parser("list", aliases=["ls"],
|
|
1457
|
+
help="List recent sessions")
|
|
1458
|
+
_add_project_arg(list_p)
|
|
1459
|
+
list_p.add_argument("count", nargs="?", type=int, default=10,
|
|
1460
|
+
help="Number of sessions (default: 10)")
|
|
1461
|
+
|
|
1462
|
+
# view
|
|
1463
|
+
view_p = subparsers.add_parser("view", aliases=["v"],
|
|
1464
|
+
help="View conversation messages")
|
|
1465
|
+
_add_project_arg(view_p)
|
|
1466
|
+
view_p.add_argument("session", nargs="?",
|
|
1467
|
+
help="Session index or UUID prefix (default: latest)")
|
|
1468
|
+
view_p.add_argument("-n", type=int, metavar="N",
|
|
1469
|
+
help="Show last N turns")
|
|
1470
|
+
view_p.add_argument("-r", metavar="RANGE",
|
|
1471
|
+
help="Show specific turns: 10, 5-10, -1, -3--1")
|
|
1472
|
+
view_p.add_argument("--all", action="store_true",
|
|
1473
|
+
help="Show all turns")
|
|
1474
|
+
view_p.add_argument("--tools", action="store_true",
|
|
1475
|
+
help="Show tool call summaries")
|
|
1476
|
+
view_p.add_argument("--raw", action="store_true",
|
|
1477
|
+
help="Show everything (tool IO, thinking, system)")
|
|
1478
|
+
view_p.add_argument("--json", action="store_true",
|
|
1479
|
+
help="Output as JSON")
|
|
1480
|
+
view_p.add_argument("--no-stitch", action="store_true",
|
|
1481
|
+
help="Don't bridge compaction boundaries")
|
|
1482
|
+
view_p.add_argument("--timestamps", action="store_true",
|
|
1483
|
+
help="Show timestamps")
|
|
1484
|
+
view_p.add_argument("--compact-summaries", action="store_true",
|
|
1485
|
+
help="Include compaction summary messages")
|
|
1486
|
+
view_p.add_argument("--truncate", type=int, default=500, metavar="LEN",
|
|
1487
|
+
help="Truncate length for raw content (default: 500, -1=none)")
|
|
1488
|
+
|
|
1489
|
+
# copy
|
|
1490
|
+
copy_p = subparsers.add_parser("copy", aliases=["cp"],
|
|
1491
|
+
help="Copy messages to clipboard")
|
|
1492
|
+
_add_project_arg(copy_p)
|
|
1493
|
+
copy_p.add_argument("session", nargs="?",
|
|
1494
|
+
help="Session index or UUID prefix")
|
|
1495
|
+
copy_p.add_argument("-n", type=int, metavar="N",
|
|
1496
|
+
help="Copy last N turns")
|
|
1497
|
+
copy_p.add_argument("-r", metavar="RANGE",
|
|
1498
|
+
help="Copy specific turns (default: -1)")
|
|
1499
|
+
copy_p.add_argument("--tools", action="store_true",
|
|
1500
|
+
help="Include tool summaries")
|
|
1501
|
+
copy_p.add_argument("--raw", action="store_true",
|
|
1502
|
+
help="Copy raw messages")
|
|
1503
|
+
|
|
1504
|
+
# projects (no --project flag needed)
|
|
1505
|
+
subparsers.add_parser("projects", help="List all projects")
|
|
1506
|
+
|
|
1507
|
+
# search
|
|
1508
|
+
search_p = subparsers.add_parser("search", aliases=["s"],
|
|
1509
|
+
help="Search across sessions")
|
|
1510
|
+
_add_project_arg(search_p)
|
|
1511
|
+
search_p.add_argument("pattern", help="Search pattern")
|
|
1512
|
+
search_p.add_argument("--limit", type=int, default=20,
|
|
1513
|
+
help="Max results (default: 20)")
|
|
1514
|
+
|
|
1515
|
+
# tree
|
|
1516
|
+
tree_p = subparsers.add_parser("tree", help="Show conversation tree structure")
|
|
1517
|
+
_add_project_arg(tree_p)
|
|
1518
|
+
tree_p.add_argument("session", nargs="?",
|
|
1519
|
+
help="Session index or UUID prefix")
|
|
1520
|
+
|
|
1521
|
+
# export
|
|
1522
|
+
export_p = subparsers.add_parser("export", help="Export full session")
|
|
1523
|
+
_add_project_arg(export_p)
|
|
1524
|
+
export_p.add_argument("session", nargs="?",
|
|
1525
|
+
help="Session index or UUID prefix")
|
|
1526
|
+
export_p.add_argument("--json", action="store_true",
|
|
1527
|
+
help="Export as JSON (default: markdown)")
|
|
1528
|
+
export_p.add_argument("--raw", action="store_true",
|
|
1529
|
+
help="Export raw messages")
|
|
1530
|
+
export_p.add_argument("--include-tools", action="store_true",
|
|
1531
|
+
help="Include tool calls in export")
|
|
1532
|
+
|
|
1533
|
+
return parser
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def _preprocess_argv(argv: list[str]) -> list[str]:
|
|
1537
|
+
"""Fix argparse issue with -r and negative ranges like -3--1.
|
|
1538
|
+
|
|
1539
|
+
argparse can't handle '-r -3--1' because '-3--1' starts with '-'
|
|
1540
|
+
and isn't a valid negative number, so argparse rejects it.
|
|
1541
|
+
We normalize '-r <range>' to '-r=<range>' when the range looks valid.
|
|
1542
|
+
"""
|
|
1543
|
+
result = []
|
|
1544
|
+
i = 0
|
|
1545
|
+
range_pat = re.compile(r'^-?\d+(--?\d+)?$')
|
|
1546
|
+
while i < len(argv):
|
|
1547
|
+
if argv[i] == '-r' and i + 1 < len(argv) and range_pat.match(argv[i + 1]):
|
|
1548
|
+
result.append(f'-r={argv[i + 1]}')
|
|
1549
|
+
i += 2
|
|
1550
|
+
else:
|
|
1551
|
+
result.append(argv[i])
|
|
1552
|
+
i += 1
|
|
1553
|
+
return result
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def main():
|
|
1557
|
+
parser = build_parser()
|
|
1558
|
+
args = parser.parse_args(_preprocess_argv(sys.argv[1:]))
|
|
1559
|
+
|
|
1560
|
+
# Default command: view
|
|
1561
|
+
if args.command is None:
|
|
1562
|
+
# Show help if no command
|
|
1563
|
+
parser.print_help()
|
|
1564
|
+
sys.exit(0)
|
|
1565
|
+
|
|
1566
|
+
commands = {
|
|
1567
|
+
"list": cmd_list,
|
|
1568
|
+
"ls": cmd_list,
|
|
1569
|
+
"view": cmd_view,
|
|
1570
|
+
"v": cmd_view,
|
|
1571
|
+
"copy": cmd_copy,
|
|
1572
|
+
"cp": cmd_copy,
|
|
1573
|
+
"projects": cmd_projects,
|
|
1574
|
+
"search": cmd_search,
|
|
1575
|
+
"s": cmd_search,
|
|
1576
|
+
"tree": cmd_tree,
|
|
1577
|
+
"export": cmd_export,
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
cmd = commands.get(args.command)
|
|
1581
|
+
if cmd:
|
|
1582
|
+
cmd(args)
|
|
1583
|
+
else:
|
|
1584
|
+
parser.print_help()
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
if __name__ == "__main__":
|
|
1588
|
+
main()
|