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/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()