claude-history 0.5.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.
- claude_history/__init__.py +3 -0
- claude_history/cli.py +745 -0
- claude_history/parser.py +750 -0
- claude_history-0.5.0.dist-info/METADATA +113 -0
- claude_history-0.5.0.dist-info/RECORD +9 -0
- claude_history-0.5.0.dist-info/WHEEL +5 -0
- claude_history-0.5.0.dist-info/entry_points.txt +2 -0
- claude_history-0.5.0.dist-info/licenses/LICENSE +21 -0
- claude_history-0.5.0.dist-info/top_level.txt +1 -0
claude_history/cli.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""CLI for exploring Claude Code conversation history."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .parser import (
|
|
10
|
+
Message,
|
|
11
|
+
find_conversations,
|
|
12
|
+
get_file_size_human,
|
|
13
|
+
parse_conversation,
|
|
14
|
+
search_conversations,
|
|
15
|
+
summarize_conversation,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Default Claude projects directory
|
|
19
|
+
DEFAULT_CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_projects_dir() -> Path:
|
|
23
|
+
"""Get the Claude projects directory."""
|
|
24
|
+
env_dir = os.environ.get("CLAUDE_HISTORY_DIR")
|
|
25
|
+
if env_dir:
|
|
26
|
+
return Path(env_dir)
|
|
27
|
+
return DEFAULT_CLAUDE_DIR
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def echo(text: str = "") -> None:
|
|
31
|
+
"""Print a line."""
|
|
32
|
+
click.echo(text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
36
|
+
"""Print a simple tab-separated table."""
|
|
37
|
+
for row in rows:
|
|
38
|
+
for i, cell in enumerate(row):
|
|
39
|
+
if cell is None:
|
|
40
|
+
row[i] = ""
|
|
41
|
+
else:
|
|
42
|
+
row[i] = str(cell).replace("\t", " ").replace("\n", " ")
|
|
43
|
+
click.echo("\t".join(headers))
|
|
44
|
+
for row in rows:
|
|
45
|
+
click.echo("\t".join(row))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_project_path(projects_dir: Path, project: str) -> Path | None:
|
|
49
|
+
"""Resolve a project identifier to its actual Claude projects storage path.
|
|
50
|
+
|
|
51
|
+
Handles multiple input formats:
|
|
52
|
+
- Direct path within ~/.claude/projects/: -Users-bob-myproject
|
|
53
|
+
- Original filesystem path: /Users/bob/myproject (converted to storage format)
|
|
54
|
+
- Display format with slashes: /Users/bob/myproject (as shown by 'projects' command)
|
|
55
|
+
- Just the project name: myproject (searches for matching suffix)
|
|
56
|
+
"""
|
|
57
|
+
# 1. Convert filesystem path to storage format (slashes -> dashes)
|
|
58
|
+
# e.g., /Users/bob/myproject -> -Users-bob-myproject
|
|
59
|
+
# This is the most common case - user passes their actual project path
|
|
60
|
+
storage_name = project.replace("/", "-")
|
|
61
|
+
if not storage_name.startswith("-") and project.startswith("/"):
|
|
62
|
+
storage_name = "-" + storage_name.lstrip("-")
|
|
63
|
+
storage_path = projects_dir / storage_name
|
|
64
|
+
if storage_path.exists():
|
|
65
|
+
return storage_path
|
|
66
|
+
|
|
67
|
+
# 2. Check if it exists as a subdirectory name in projects_dir (already in storage format)
|
|
68
|
+
direct_child = projects_dir / project
|
|
69
|
+
if direct_child.exists():
|
|
70
|
+
return direct_child
|
|
71
|
+
|
|
72
|
+
# 3. Check if it's a direct path that exists (e.g., full path to projects dir)
|
|
73
|
+
project_path = Path(project)
|
|
74
|
+
if project_path.exists() and project_path.is_dir():
|
|
75
|
+
# Only use if it looks like a Claude projects path (contains .jsonl files)
|
|
76
|
+
if list(project_path.glob("*.jsonl")):
|
|
77
|
+
return project_path
|
|
78
|
+
|
|
79
|
+
# 4. Try partial matching - find projects ending with the given name
|
|
80
|
+
# This handles cases like "lci-project" matching "-Users-benjamin-Desktop-repos-lci-project"
|
|
81
|
+
search_suffix = project.replace("/", "-")
|
|
82
|
+
for child in projects_dir.iterdir():
|
|
83
|
+
if child.is_dir():
|
|
84
|
+
# Match "-suffix" or exact suffix at end of name
|
|
85
|
+
if child.name.endswith("-" + search_suffix) or child.name == search_suffix:
|
|
86
|
+
return child
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_timestamp(dt) -> str:
|
|
92
|
+
"""Format a datetime for display."""
|
|
93
|
+
if not dt:
|
|
94
|
+
return "unknown"
|
|
95
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def truncate(text: str, max_len: int) -> str:
|
|
99
|
+
"""Truncate text with ellipsis."""
|
|
100
|
+
if len(text) <= max_len:
|
|
101
|
+
return text
|
|
102
|
+
return text[: max_len - 3] + "..."
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def find_conversation_file(projects_dir: Path, session_id: str) -> Path | None:
|
|
106
|
+
"""Find a conversation file by session ID (full or partial)."""
|
|
107
|
+
# Check if it's a direct file path
|
|
108
|
+
if Path(session_id).exists():
|
|
109
|
+
return Path(session_id)
|
|
110
|
+
|
|
111
|
+
# Search in all project directories
|
|
112
|
+
for project_path in projects_dir.iterdir():
|
|
113
|
+
if not project_path.is_dir():
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
for conv_path in project_path.glob("*.jsonl"):
|
|
117
|
+
if conv_path.stem == session_id or conv_path.stem.startswith(session_id):
|
|
118
|
+
return conv_path
|
|
119
|
+
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def summarize_tools(tools: list[str]) -> str:
|
|
124
|
+
"""Summarize a list of tool names into a compact string."""
|
|
125
|
+
from collections import Counter
|
|
126
|
+
|
|
127
|
+
counts = Counter(tools)
|
|
128
|
+
parts = []
|
|
129
|
+
for tool, count in counts.most_common():
|
|
130
|
+
if count > 1:
|
|
131
|
+
parts.append(f"{tool}×{count}")
|
|
132
|
+
else:
|
|
133
|
+
parts.append(tool)
|
|
134
|
+
return ", ".join(parts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render_message(msg: Message, full: bool = False, show_tools: bool = True):
|
|
138
|
+
"""Render a message without rich formatting for LLM-friendly output."""
|
|
139
|
+
if msg.role == "tool" and not show_tools:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
header_parts = [msg.role.upper()]
|
|
143
|
+
if msg.timestamp:
|
|
144
|
+
header_parts.append(format_timestamp(msg.timestamp))
|
|
145
|
+
if msg.model:
|
|
146
|
+
header_parts.append(msg.model)
|
|
147
|
+
if msg.is_sidechain:
|
|
148
|
+
header_parts.append("sidechain")
|
|
149
|
+
echo(" | ".join(header_parts))
|
|
150
|
+
|
|
151
|
+
def echo_block(text: str) -> None:
|
|
152
|
+
for line in text.splitlines() or [""]:
|
|
153
|
+
echo(f" {line}")
|
|
154
|
+
|
|
155
|
+
for block in msg.blocks:
|
|
156
|
+
if block.type == "text":
|
|
157
|
+
text = block.text or ""
|
|
158
|
+
if text.strip().startswith("<") and not full:
|
|
159
|
+
if any(
|
|
160
|
+
tag in text
|
|
161
|
+
for tag in [
|
|
162
|
+
"<local-command",
|
|
163
|
+
"<system-reminder",
|
|
164
|
+
"<command-name>",
|
|
165
|
+
]
|
|
166
|
+
):
|
|
167
|
+
echo_block("(system/command content)")
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if not full and len(text) > 500:
|
|
171
|
+
text = text[:500] + "\n... (truncated, use --full to see all)"
|
|
172
|
+
echo_block(text)
|
|
173
|
+
|
|
174
|
+
elif block.type == "tool_use" and show_tools:
|
|
175
|
+
echo_block(f"TOOL: {block.tool_name}")
|
|
176
|
+
if block.tool_input and full:
|
|
177
|
+
import json
|
|
178
|
+
|
|
179
|
+
input_str = json.dumps(block.tool_input, indent=2)
|
|
180
|
+
if len(input_str) > 200:
|
|
181
|
+
input_str = input_str[:200] + "..."
|
|
182
|
+
for line in input_str.splitlines():
|
|
183
|
+
echo_block(line)
|
|
184
|
+
|
|
185
|
+
elif block.type == "tool_result" and show_tools:
|
|
186
|
+
result_text = block.text or ""
|
|
187
|
+
if not full and len(result_text) > 200:
|
|
188
|
+
result_text = result_text[:200] + "..."
|
|
189
|
+
echo_block(f"RESULT: {result_text}")
|
|
190
|
+
|
|
191
|
+
elif block.type == "thinking":
|
|
192
|
+
if full:
|
|
193
|
+
text = block.text or ""
|
|
194
|
+
if len(text) > 300:
|
|
195
|
+
text = text[:300] + "..."
|
|
196
|
+
echo_block(f"THINKING: {text}")
|
|
197
|
+
else:
|
|
198
|
+
echo_block("THINKING: (hidden)")
|
|
199
|
+
|
|
200
|
+
echo()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@click.group()
|
|
204
|
+
def cli():
|
|
205
|
+
"""Explore Claude Code conversation history.
|
|
206
|
+
|
|
207
|
+
Use this tool to list, search, and view past Claude Code conversations.
|
|
208
|
+
"""
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command()
|
|
213
|
+
@click.option(
|
|
214
|
+
"--sort",
|
|
215
|
+
type=click.Choice(["modified", "name", "conversations"]),
|
|
216
|
+
default="modified",
|
|
217
|
+
help="Sort projects by: modified (default), name, or conversations",
|
|
218
|
+
)
|
|
219
|
+
def projects(sort: str):
|
|
220
|
+
"""List all projects with conversation history."""
|
|
221
|
+
from datetime import datetime
|
|
222
|
+
|
|
223
|
+
projects_dir = get_projects_dir()
|
|
224
|
+
|
|
225
|
+
if not projects_dir.exists():
|
|
226
|
+
echo(f"Directory not found: {projects_dir}")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# Collect project data
|
|
230
|
+
project_data = []
|
|
231
|
+
for project_path in projects_dir.iterdir():
|
|
232
|
+
if not project_path.is_dir():
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
convos = find_conversations(project_path)
|
|
236
|
+
if not convos:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Get most recent modification time
|
|
240
|
+
latest = max(c.stat().st_mtime for c in convos)
|
|
241
|
+
latest_dt = datetime.fromtimestamp(latest)
|
|
242
|
+
|
|
243
|
+
# Decode project name (replace dashes with slashes to show path)
|
|
244
|
+
name = project_path.name
|
|
245
|
+
if name.startswith("-"):
|
|
246
|
+
name = name.replace("-", "/")
|
|
247
|
+
|
|
248
|
+
project_data.append(
|
|
249
|
+
{
|
|
250
|
+
"name": name,
|
|
251
|
+
"conversations": len(convos),
|
|
252
|
+
"latest_dt": latest_dt,
|
|
253
|
+
"latest_mtime": latest,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Sort based on option
|
|
258
|
+
if sort == "modified":
|
|
259
|
+
project_data.sort(key=lambda p: p["latest_mtime"], reverse=True)
|
|
260
|
+
elif sort == "name":
|
|
261
|
+
project_data.sort(key=lambda p: p["name"])
|
|
262
|
+
elif sort == "conversations":
|
|
263
|
+
project_data.sort(key=lambda p: p["conversations"], reverse=True)
|
|
264
|
+
|
|
265
|
+
rows = [
|
|
266
|
+
[p["name"], str(p["conversations"]), format_timestamp(p["latest_dt"])]
|
|
267
|
+
for p in project_data
|
|
268
|
+
]
|
|
269
|
+
print_table(["Project", "Conversations", "Last Modified"], rows)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@cli.command("list")
|
|
273
|
+
@click.argument("project", required=False)
|
|
274
|
+
@click.option("-n", "--limit", default=20, help="Number of conversations to show")
|
|
275
|
+
def list_conversations(project: str | None, limit: int):
|
|
276
|
+
"""List conversations in a project.
|
|
277
|
+
|
|
278
|
+
PROJECT can be a full filesystem path (e.g., /Users/bob/myproject),
|
|
279
|
+
the display path shown by 'projects' command, or just a project name suffix.
|
|
280
|
+
"""
|
|
281
|
+
projects_dir = get_projects_dir()
|
|
282
|
+
|
|
283
|
+
if project:
|
|
284
|
+
project_path = resolve_project_path(projects_dir, project)
|
|
285
|
+
if not project_path:
|
|
286
|
+
echo(f"Project not found: {project}")
|
|
287
|
+
echo("Tip: Use 'claude-history projects' to see available projects")
|
|
288
|
+
return
|
|
289
|
+
else:
|
|
290
|
+
# List all conversations across all projects
|
|
291
|
+
project_path = projects_dir
|
|
292
|
+
|
|
293
|
+
# Find conversations
|
|
294
|
+
if project_path.is_file() and project_path.suffix == ".jsonl":
|
|
295
|
+
convos = [project_path]
|
|
296
|
+
elif project_path.is_dir():
|
|
297
|
+
if project:
|
|
298
|
+
convos = find_conversations(project_path)
|
|
299
|
+
else:
|
|
300
|
+
# Gather from all subdirectories
|
|
301
|
+
convos = []
|
|
302
|
+
for subdir in project_path.iterdir():
|
|
303
|
+
if subdir.is_dir():
|
|
304
|
+
convos.extend(find_conversations(subdir))
|
|
305
|
+
convos.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
306
|
+
else:
|
|
307
|
+
echo(f"Invalid path: {project_path}")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if not convos:
|
|
311
|
+
echo("No conversations found.")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
rows = []
|
|
315
|
+
for i, conv_path in enumerate(convos[:limit]):
|
|
316
|
+
try:
|
|
317
|
+
summary = summarize_conversation(conv_path)
|
|
318
|
+
title = truncate(summary.title, 50)
|
|
319
|
+
msg_count = (
|
|
320
|
+
f"{summary.user_message_count}u/{summary.assistant_message_count}a"
|
|
321
|
+
)
|
|
322
|
+
date = format_timestamp(summary.start_time)
|
|
323
|
+
size = get_file_size_human(conv_path)
|
|
324
|
+
rows.append(
|
|
325
|
+
[str(i + 1), summary.session_id[:8], title, msg_count, size, date]
|
|
326
|
+
)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
rows.append([str(i + 1), conv_path.stem[:8], f"Error: {e}", "-", "-", "-"])
|
|
329
|
+
|
|
330
|
+
echo(f"Conversations ({len(convos)} total, showing {min(limit, len(convos))})")
|
|
331
|
+
print_table(["#", "Session ID", "Title", "Messages", "Size", "Date"], rows)
|
|
332
|
+
echo("Tip: Use 'claude-history view <session-id>' to view a conversation")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@cli.command()
|
|
336
|
+
@click.argument("session_id")
|
|
337
|
+
@click.option("-f", "--full", is_flag=True, help="Show full message content")
|
|
338
|
+
@click.option("--no-tools", is_flag=True, help="Hide tool use details")
|
|
339
|
+
@click.option("-n", "--limit", type=int, help="Limit number of messages shown")
|
|
340
|
+
@click.option("-o", "--offset", type=int, default=0, help="Skip first N messages")
|
|
341
|
+
def view(session_id: str, full: bool, no_tools: bool, limit: int | None, offset: int):
|
|
342
|
+
"""View a conversation by session ID.
|
|
343
|
+
|
|
344
|
+
SESSION_ID can be a full ID, partial ID, or a file path.
|
|
345
|
+
"""
|
|
346
|
+
projects_dir = get_projects_dir()
|
|
347
|
+
|
|
348
|
+
# Find the conversation file
|
|
349
|
+
conv_path = find_conversation_file(projects_dir, session_id)
|
|
350
|
+
if not conv_path:
|
|
351
|
+
echo(f"Conversation not found: {session_id}")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
conv = parse_conversation(conv_path)
|
|
355
|
+
|
|
356
|
+
# Print header
|
|
357
|
+
echo("Conversation Info")
|
|
358
|
+
echo(f"Title: {conv.title}")
|
|
359
|
+
echo(f"Session: {conv.session_id}")
|
|
360
|
+
echo(f"Directory: {conv.cwd or 'unknown'}")
|
|
361
|
+
echo(f"Branch: {conv.git_branch or 'unknown'}")
|
|
362
|
+
echo(f"Version: {conv.version or 'unknown'}")
|
|
363
|
+
echo(
|
|
364
|
+
f"Time: {format_timestamp(conv.start_time)} - {format_timestamp(conv.end_time)}"
|
|
365
|
+
)
|
|
366
|
+
echo(
|
|
367
|
+
f"Messages: {conv.user_message_count} user, {conv.assistant_message_count} assistant"
|
|
368
|
+
)
|
|
369
|
+
echo(f"Tool uses: {conv.tool_use_count}")
|
|
370
|
+
|
|
371
|
+
# Print summaries if available
|
|
372
|
+
if conv.summaries:
|
|
373
|
+
echo()
|
|
374
|
+
echo("Summaries:")
|
|
375
|
+
for summary in conv.summaries:
|
|
376
|
+
indented = summary.replace("\n", "\n ")
|
|
377
|
+
echo(f"- {indented}")
|
|
378
|
+
|
|
379
|
+
# Print messages
|
|
380
|
+
messages = conv.messages[offset:]
|
|
381
|
+
if limit:
|
|
382
|
+
messages = messages[:limit]
|
|
383
|
+
|
|
384
|
+
echo()
|
|
385
|
+
echo(f"Messages (showing {len(messages)} of {len(conv.messages)}):")
|
|
386
|
+
echo()
|
|
387
|
+
|
|
388
|
+
for msg in messages:
|
|
389
|
+
if msg.is_meta:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
render_message(msg, full=full, show_tools=not no_tools)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@cli.command()
|
|
396
|
+
@click.argument("query")
|
|
397
|
+
@click.option("-n", "--limit", default=10, help="Number of results to show")
|
|
398
|
+
def search(query: str, limit: int):
|
|
399
|
+
"""Search conversations for a query string."""
|
|
400
|
+
projects_dir = get_projects_dir()
|
|
401
|
+
|
|
402
|
+
results = search_conversations(projects_dir, query, limit)
|
|
403
|
+
|
|
404
|
+
if not results:
|
|
405
|
+
echo(f"No results found for: {query}")
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
echo(f"Found {len(results)} matches:")
|
|
409
|
+
echo()
|
|
410
|
+
|
|
411
|
+
for match in results:
|
|
412
|
+
echo(f"{match.session_id[:8]} - {truncate(match.title, 40)}")
|
|
413
|
+
echo(f" {format_timestamp(match.timestamp)}")
|
|
414
|
+
echo(f" {match.snippet}")
|
|
415
|
+
echo()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@cli.command()
|
|
419
|
+
@click.argument("session_id")
|
|
420
|
+
def summary(session_id: str):
|
|
421
|
+
"""Show a concise summary of a conversation."""
|
|
422
|
+
projects_dir = get_projects_dir()
|
|
423
|
+
|
|
424
|
+
conv_path = find_conversation_file(projects_dir, session_id)
|
|
425
|
+
if not conv_path:
|
|
426
|
+
echo(f"Conversation not found: {session_id}")
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
conv = parse_conversation(conv_path)
|
|
430
|
+
|
|
431
|
+
# Print basic info
|
|
432
|
+
echo(f"Session: {conv.session_id}")
|
|
433
|
+
echo(f"Title: {conv.title}")
|
|
434
|
+
echo(f"Directory: {conv.cwd or 'unknown'}")
|
|
435
|
+
echo(f"Time: {format_timestamp(conv.start_time)}")
|
|
436
|
+
echo(
|
|
437
|
+
f"Stats: {conv.user_message_count} user msgs, {conv.assistant_message_count} assistant msgs, {conv.tool_use_count} tool uses"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Print any stored summaries
|
|
441
|
+
if conv.summaries:
|
|
442
|
+
echo()
|
|
443
|
+
echo("Stored Summaries:")
|
|
444
|
+
for s in conv.summaries:
|
|
445
|
+
indented = s.replace("\n", "\n ")
|
|
446
|
+
echo(f"- {indented}")
|
|
447
|
+
|
|
448
|
+
# Generate a quick summary from messages
|
|
449
|
+
echo()
|
|
450
|
+
echo("Conversation Flow:")
|
|
451
|
+
|
|
452
|
+
turn = 0
|
|
453
|
+
pending_tools: list[str] = []
|
|
454
|
+
|
|
455
|
+
for i, msg in enumerate(conv.messages):
|
|
456
|
+
if msg.is_meta or msg.role == "tool":
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
turn += 1
|
|
460
|
+
if msg.role == "user":
|
|
461
|
+
text = msg.text.strip()
|
|
462
|
+
# Skip system messages
|
|
463
|
+
if text.startswith("<"):
|
|
464
|
+
continue
|
|
465
|
+
echo()
|
|
466
|
+
echo(f"{turn}. User: {truncate(text, 100)}")
|
|
467
|
+
|
|
468
|
+
elif msg.role == "assistant":
|
|
469
|
+
# Collect tools used
|
|
470
|
+
if msg.tool_names:
|
|
471
|
+
pending_tools.extend(msg.tool_names)
|
|
472
|
+
|
|
473
|
+
# Show text summary
|
|
474
|
+
text = msg.text.strip()
|
|
475
|
+
if text:
|
|
476
|
+
# If we have pending tools, show them first
|
|
477
|
+
if pending_tools:
|
|
478
|
+
tool_summary = summarize_tools(pending_tools)
|
|
479
|
+
echo(f" Tools: {tool_summary}")
|
|
480
|
+
pending_tools = []
|
|
481
|
+
echo(f" Assistant: {truncate(text, 100)}")
|
|
482
|
+
|
|
483
|
+
# Check if next message is a user message or end - if so, flush tools
|
|
484
|
+
next_msg = conv.messages[i + 1] if i + 1 < len(conv.messages) else None
|
|
485
|
+
if pending_tools and (not next_msg or next_msg.role == "user"):
|
|
486
|
+
tool_summary = summarize_tools(pending_tools)
|
|
487
|
+
echo(f" Tools: {tool_summary}")
|
|
488
|
+
pending_tools = []
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@cli.command()
|
|
492
|
+
@click.argument("session_id")
|
|
493
|
+
@click.option(
|
|
494
|
+
"-f",
|
|
495
|
+
"--format",
|
|
496
|
+
"output_format",
|
|
497
|
+
type=click.Choice(["text", "json"]),
|
|
498
|
+
default="text",
|
|
499
|
+
)
|
|
500
|
+
def export(session_id: str, output_format: str):
|
|
501
|
+
"""Export a conversation to a simpler format."""
|
|
502
|
+
projects_dir = get_projects_dir()
|
|
503
|
+
|
|
504
|
+
conv_path = find_conversation_file(projects_dir, session_id)
|
|
505
|
+
if not conv_path:
|
|
506
|
+
echo(f"Conversation not found: {session_id}")
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
conv = parse_conversation(conv_path)
|
|
510
|
+
|
|
511
|
+
if output_format == "json":
|
|
512
|
+
import json
|
|
513
|
+
|
|
514
|
+
output = {
|
|
515
|
+
"session_id": conv.session_id,
|
|
516
|
+
"title": conv.title,
|
|
517
|
+
"cwd": conv.cwd,
|
|
518
|
+
"git_branch": conv.git_branch,
|
|
519
|
+
"start_time": conv.start_time.isoformat() if conv.start_time else None,
|
|
520
|
+
"messages": [],
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for msg in conv.messages:
|
|
524
|
+
if msg.is_meta:
|
|
525
|
+
continue
|
|
526
|
+
output["messages"].append(
|
|
527
|
+
{
|
|
528
|
+
"role": msg.role,
|
|
529
|
+
"text": msg.text,
|
|
530
|
+
"tools": msg.tool_names,
|
|
531
|
+
"timestamp": msg.timestamp.isoformat() if msg.timestamp else None,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
click.echo(json.dumps(output, indent=2))
|
|
536
|
+
|
|
537
|
+
else: # text format
|
|
538
|
+
click.echo(f"# {conv.title}")
|
|
539
|
+
click.echo(f"Session: {conv.session_id}")
|
|
540
|
+
click.echo(f"Date: {format_timestamp(conv.start_time)}")
|
|
541
|
+
click.echo(f"Directory: {conv.cwd}")
|
|
542
|
+
click.echo()
|
|
543
|
+
|
|
544
|
+
for msg in conv.messages:
|
|
545
|
+
if msg.is_meta:
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
role = "USER" if msg.role == "user" else "ASSISTANT"
|
|
549
|
+
click.echo(f"## {role}")
|
|
550
|
+
|
|
551
|
+
if msg.text:
|
|
552
|
+
click.echo(msg.text)
|
|
553
|
+
|
|
554
|
+
if msg.tool_names:
|
|
555
|
+
click.echo(f"\n[Tools: {', '.join(msg.tool_names)}]")
|
|
556
|
+
|
|
557
|
+
click.echo()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@cli.command()
|
|
561
|
+
@click.argument("session_id")
|
|
562
|
+
@click.option("-c", "--max-chars", default=8000, help="Maximum characters in output")
|
|
563
|
+
@click.option("--include-tools", is_flag=True, help="Include tool names in output")
|
|
564
|
+
def catchup(session_id: str, max_chars: int, include_tools: bool):
|
|
565
|
+
"""Generate a context summary for catching up in a new session.
|
|
566
|
+
|
|
567
|
+
This outputs a compact summary suitable for pasting into a new Claude
|
|
568
|
+
conversation to restore context from a previous session.
|
|
569
|
+
"""
|
|
570
|
+
projects_dir = get_projects_dir()
|
|
571
|
+
|
|
572
|
+
conv_path = find_conversation_file(projects_dir, session_id)
|
|
573
|
+
if not conv_path:
|
|
574
|
+
echo(f"Conversation not found: {session_id}")
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
conv = parse_conversation(conv_path)
|
|
578
|
+
|
|
579
|
+
# Build the catchup summary
|
|
580
|
+
lines = []
|
|
581
|
+
lines.append("# Previous Session Context")
|
|
582
|
+
lines.append("")
|
|
583
|
+
lines.append(f"**Session:** {conv.session_id}")
|
|
584
|
+
lines.append(f"**Project:** {conv.cwd or 'unknown'}")
|
|
585
|
+
lines.append(f"**Date:** {format_timestamp(conv.start_time)}")
|
|
586
|
+
lines.append(
|
|
587
|
+
f"**Messages:** {conv.user_message_count} user, {conv.assistant_message_count} assistant"
|
|
588
|
+
)
|
|
589
|
+
lines.append("")
|
|
590
|
+
|
|
591
|
+
# Include stored summaries if available
|
|
592
|
+
if conv.summaries:
|
|
593
|
+
lines.append("## Stored Summaries")
|
|
594
|
+
for s in conv.summaries:
|
|
595
|
+
lines.append(s)
|
|
596
|
+
lines.append("")
|
|
597
|
+
|
|
598
|
+
lines.append("## Conversation Flow")
|
|
599
|
+
lines.append("")
|
|
600
|
+
|
|
601
|
+
# Build message summaries
|
|
602
|
+
current_length = len("\n".join(lines))
|
|
603
|
+
turn = 0
|
|
604
|
+
pending_tools: list[str] = []
|
|
605
|
+
|
|
606
|
+
for i, msg in enumerate(conv.messages):
|
|
607
|
+
if msg.is_meta or msg.role == "tool":
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
if msg.role == "user":
|
|
611
|
+
text = msg.text.strip()
|
|
612
|
+
if text.startswith("<"):
|
|
613
|
+
continue
|
|
614
|
+
turn += 1
|
|
615
|
+
entry = f"**User {turn}:** {truncate(text, 200)}"
|
|
616
|
+
if current_length + len(entry) + 10 > max_chars:
|
|
617
|
+
lines.append("... (truncated due to length limit)")
|
|
618
|
+
break
|
|
619
|
+
lines.append(entry)
|
|
620
|
+
current_length += len(entry) + 1
|
|
621
|
+
|
|
622
|
+
elif msg.role == "assistant":
|
|
623
|
+
if msg.tool_names:
|
|
624
|
+
pending_tools.extend(msg.tool_names)
|
|
625
|
+
|
|
626
|
+
text = msg.text.strip()
|
|
627
|
+
if text:
|
|
628
|
+
# Flush pending tools
|
|
629
|
+
if include_tools and pending_tools:
|
|
630
|
+
tool_summary = summarize_tools(pending_tools)
|
|
631
|
+
lines.append(f" *Tools: {tool_summary}*")
|
|
632
|
+
pending_tools = []
|
|
633
|
+
|
|
634
|
+
entry = f" Assistant: {truncate(text, 300)}"
|
|
635
|
+
if current_length + len(entry) + 10 > max_chars:
|
|
636
|
+
lines.append("... (truncated due to length limit)")
|
|
637
|
+
break
|
|
638
|
+
lines.append(entry)
|
|
639
|
+
current_length += len(entry) + 1
|
|
640
|
+
|
|
641
|
+
# Flush tools at end of turn
|
|
642
|
+
next_msg = conv.messages[i + 1] if i + 1 < len(conv.messages) else None
|
|
643
|
+
if (
|
|
644
|
+
include_tools
|
|
645
|
+
and pending_tools
|
|
646
|
+
and (not next_msg or next_msg.role == "user")
|
|
647
|
+
):
|
|
648
|
+
tool_summary = summarize_tools(pending_tools)
|
|
649
|
+
lines.append(f" *Tools: {tool_summary}*")
|
|
650
|
+
pending_tools = []
|
|
651
|
+
|
|
652
|
+
lines.append("")
|
|
653
|
+
|
|
654
|
+
output = "\n".join(lines)
|
|
655
|
+
output = re.sub(r"\n{3,}", "\n\n", output)
|
|
656
|
+
click.echo(output)
|
|
657
|
+
click.echo(f"\n({len(output)} characters)", err=True)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@cli.command("project-summary")
|
|
661
|
+
@click.argument("project")
|
|
662
|
+
@click.option("-n", "--limit", default=5, help="Number of recent conversations")
|
|
663
|
+
@click.option("-c", "--max-chars", default=6000, help="Maximum characters in output")
|
|
664
|
+
def project_summary(project: str, limit: int, max_chars: int):
|
|
665
|
+
"""Generate a summary of recent conversations in a project.
|
|
666
|
+
|
|
667
|
+
Useful for getting context on what's been happening in a project
|
|
668
|
+
across multiple sessions.
|
|
669
|
+
"""
|
|
670
|
+
projects_dir = get_projects_dir()
|
|
671
|
+
|
|
672
|
+
# Find project directory
|
|
673
|
+
project_path = resolve_project_path(projects_dir, project)
|
|
674
|
+
if not project_path:
|
|
675
|
+
echo(f"Project not found: {project}")
|
|
676
|
+
echo("Tip: Use 'claude-history projects' to see available projects")
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
convos = find_conversations(project_path)[:limit]
|
|
680
|
+
if not convos:
|
|
681
|
+
echo("No conversations found.")
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
lines = []
|
|
685
|
+
lines.append(f"# Project Summary: {project_path.name}")
|
|
686
|
+
lines.append("")
|
|
687
|
+
lines.append(f"Recent conversations ({len(convos)} shown):")
|
|
688
|
+
lines.append("")
|
|
689
|
+
|
|
690
|
+
current_length = len("\n".join(lines))
|
|
691
|
+
|
|
692
|
+
for conv_path in convos:
|
|
693
|
+
try:
|
|
694
|
+
conv = parse_conversation(conv_path)
|
|
695
|
+
|
|
696
|
+
entry_lines = []
|
|
697
|
+
entry_lines.append(
|
|
698
|
+
f"## {format_timestamp(conv.start_time)} - {conv.session_id[:8]}"
|
|
699
|
+
)
|
|
700
|
+
entry_lines.append(f"**Title:** {conv.title}")
|
|
701
|
+
entry_lines.append(
|
|
702
|
+
f"**Stats:** {conv.user_message_count}u/{conv.assistant_message_count}a msgs, {conv.tool_use_count} tools"
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# Get first user message as context
|
|
706
|
+
for msg in conv.messages:
|
|
707
|
+
if msg.role == "user" and not msg.is_meta:
|
|
708
|
+
text = msg.text.strip()
|
|
709
|
+
if not text.startswith("<"):
|
|
710
|
+
entry_lines.append(f"**Started with:** {truncate(text, 150)}")
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
# Get last substantive assistant message
|
|
714
|
+
for msg in reversed(conv.messages):
|
|
715
|
+
if msg.role == "assistant" and msg.text.strip():
|
|
716
|
+
entry_lines.append(
|
|
717
|
+
f"**Last response:** {truncate(msg.text.strip(), 150)}"
|
|
718
|
+
)
|
|
719
|
+
break
|
|
720
|
+
|
|
721
|
+
entry_lines.append("")
|
|
722
|
+
|
|
723
|
+
entry = "\n".join(entry_lines)
|
|
724
|
+
if current_length + len(entry) > max_chars:
|
|
725
|
+
lines.append("... (more conversations truncated)")
|
|
726
|
+
break
|
|
727
|
+
lines.extend(entry_lines)
|
|
728
|
+
current_length += len(entry)
|
|
729
|
+
|
|
730
|
+
except Exception as e:
|
|
731
|
+
lines.append(f"## {conv_path.stem[:8]} - Error: {e}")
|
|
732
|
+
lines.append("")
|
|
733
|
+
|
|
734
|
+
output = "\n".join(lines)
|
|
735
|
+
click.echo(output)
|
|
736
|
+
click.echo(f"\n({len(output)} characters)", err=True)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def main():
|
|
740
|
+
"""Entry point for the CLI."""
|
|
741
|
+
cli()
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
if __name__ == "__main__":
|
|
745
|
+
main()
|