copilot-session-tools 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,993 @@
1
+ """Command-line interface for Copilot Session Tools.
2
+
3
+ This module provides a modern CLI built with Typer for scanning, searching,
4
+ and exporting VS Code GitHub Copilot chat history.
5
+ """
6
+
7
+ import sys
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from copilot_session_tools import (
17
+ ChatMessage,
18
+ ChatSession,
19
+ Database,
20
+ __version__,
21
+ export_session_to_file,
22
+ export_session_to_html_file,
23
+ generate_session_filename,
24
+ generate_session_html_filename,
25
+ get_vscode_storage_paths,
26
+ scan_chat_sessions,
27
+ )
28
+ from copilot_session_tools.scanner import (
29
+ SessionFileInfo,
30
+ parse_session_file,
31
+ scan_session_files,
32
+ )
33
+
34
+ # On Windows, reconfigure stdout/stderr to UTF-8 when piped to prevent
35
+ # Rich from falling back to cp1252 which can't handle Unicode output
36
+ if sys.platform == "win32":
37
+ if hasattr(sys.stdout, "reconfigure"):
38
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # ty: ignore[call-non-callable]
39
+ if hasattr(sys.stderr, "reconfigure"):
40
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # ty: ignore[call-non-callable]
41
+
42
+ app = typer.Typer(
43
+ name="copilot-session-tools",
44
+ help="Create a searchable archive of VS Code GitHub Copilot chats.",
45
+ no_args_is_help=True,
46
+ )
47
+ console = Console()
48
+
49
+ # Number of threads for parallel file parsing
50
+ PARSE_WORKERS = 4
51
+
52
+
53
+ def version_callback(value: bool):
54
+ """Print version and exit."""
55
+ if value:
56
+ console.print(f"copilot-session-tools version {__version__}")
57
+ raise typer.Exit()
58
+
59
+
60
+ def format_timestamp(ts: str | int | None) -> str:
61
+ """Convert a timestamp to a human-readable date string."""
62
+ if ts is None:
63
+ return "Unknown"
64
+ try:
65
+ # Try parsing as milliseconds (JS timestamp) - int() accepts both strings and ints
66
+ numeric_ts = int(ts) if isinstance(ts, str) else ts
67
+ if numeric_ts > 1e12: # Milliseconds
68
+ numeric_ts = numeric_ts / 1000
69
+ return datetime.fromtimestamp(numeric_ts).strftime("%Y-%m-%d %H:%M:%S")
70
+ except (ValueError, OSError):
71
+ return str(ts)
72
+
73
+
74
+ @app.callback()
75
+ def main(
76
+ version: Annotated[
77
+ bool,
78
+ typer.Option(
79
+ "--version",
80
+ "-V",
81
+ callback=version_callback,
82
+ is_eager=True,
83
+ help="Show version and exit.",
84
+ ),
85
+ ] = False,
86
+ ):
87
+ """Copilot Chat Archive - Create a searchable archive of VS Code GitHub Copilot chats."""
88
+ pass
89
+
90
+
91
+ @app.command()
92
+ def scan(
93
+ db: Annotated[
94
+ Path,
95
+ typer.Option(
96
+ "--db",
97
+ "-d",
98
+ help="Path to SQLite database file.",
99
+ ),
100
+ ] = Path("copilot_chats.db"),
101
+ storage_path: Annotated[
102
+ list[Path] | None,
103
+ typer.Option(
104
+ "--storage-path",
105
+ "-s",
106
+ help="Custom VS Code storage path(s) to scan. Can be specified multiple times.",
107
+ exists=True,
108
+ file_okay=False,
109
+ ),
110
+ ] = None,
111
+ edition: Annotated[
112
+ str,
113
+ typer.Option(
114
+ "--edition",
115
+ "-e",
116
+ help="VS Code edition to scan.",
117
+ ),
118
+ ] = "both",
119
+ verbose: Annotated[
120
+ bool,
121
+ typer.Option(
122
+ "--verbose",
123
+ "-v",
124
+ help="Show verbose output.",
125
+ ),
126
+ ] = False,
127
+ full: Annotated[
128
+ bool,
129
+ typer.Option(
130
+ "--full",
131
+ "-f",
132
+ help="Full scan: update all sessions regardless of file changes.",
133
+ ),
134
+ ] = False,
135
+ store_raw: Annotated[
136
+ bool,
137
+ typer.Option(
138
+ "--store-raw",
139
+ help="Store raw JSON in database (enables rebuild command, increases DB size).",
140
+ ),
141
+ ] = False,
142
+ ):
143
+ """Scan for and import Copilot chat sessions into the database.
144
+
145
+ By default, uses incremental refresh: only updates sessions whose source files
146
+ have changed (based on file mtime and size). Use --full to force a complete
147
+ re-import of all sessions.
148
+
149
+ Raw JSON is not stored by default to save space. Use --store-raw to enable
150
+ storing raw JSON, which allows using the 'rebuild' and 'raw-json' commands.
151
+ """
152
+ if edition not in ("stable", "insider", "both"):
153
+ console.print("[red]Error: edition must be 'stable', 'insider', or 'both'[/red]")
154
+ raise typer.Exit(1)
155
+
156
+ database = Database(db)
157
+
158
+ # Determine storage paths
159
+ if storage_path:
160
+ paths = [(str(p), "custom") for p in storage_path]
161
+ else:
162
+ all_paths = get_vscode_storage_paths()
163
+ if edition == "both":
164
+ paths = all_paths
165
+ else:
166
+ paths = [(p, e) for p, e in all_paths if e == edition]
167
+
168
+ console.print("Scanning for Copilot chat sessions...")
169
+ if full:
170
+ console.print(" (Full mode: will update all sessions)")
171
+ else:
172
+ console.print(" (Incremental mode: skipping unchanged sessions)")
173
+ if store_raw:
174
+ console.print(" (Storing raw JSON in database)")
175
+ if verbose:
176
+ for path, ed in paths:
177
+ console.print(f" Checking: {path} ({ed})")
178
+
179
+ added = 0
180
+ updated = 0
181
+ skipped = 0
182
+
183
+ if full:
184
+ # Full mode: parse and update all sessions
185
+ for session in scan_chat_sessions(paths):
186
+ existing = database.get_session(session.session_id)
187
+ if existing:
188
+ database.update_session(session, store_raw=store_raw)
189
+ updated += 1
190
+ if verbose:
191
+ workspace = session.workspace_name or "Unknown workspace"
192
+ console.print(f" Updated: {workspace} ({len(session.messages)} messages)")
193
+ else:
194
+ database.add_session(session, store_raw=store_raw)
195
+ added += 1
196
+ if verbose:
197
+ workspace = session.workspace_name or "Unknown workspace"
198
+ console.print(f" Added: {workspace} ({len(session.messages)} messages)")
199
+ else:
200
+ # Incremental mode: load all file metadata upfront for fast comparison
201
+ stored_metadata = database.get_all_file_metadata()
202
+
203
+ # Collect files that need updating
204
+ files_to_update: list[SessionFileInfo] = []
205
+ for file_info in scan_session_files(paths):
206
+ source_file = str(file_info.file_path)
207
+ stored = stored_metadata.get(source_file)
208
+
209
+ needs_update = stored is None or stored[0] is None or stored[1] is None or stored[0] != file_info.mtime or stored[1] != file_info.size
210
+
211
+ if needs_update:
212
+ files_to_update.append(file_info)
213
+ else:
214
+ skipped += 1
215
+ if verbose:
216
+ workspace = file_info.workspace_name or "Unknown workspace"
217
+ console.print(f" Skipped (unchanged): {workspace}")
218
+
219
+ # Parse files in parallel (I/O + CPU bound)
220
+ if files_to_update:
221
+ with ThreadPoolExecutor(max_workers=PARSE_WORKERS) as executor:
222
+ parse_results = list(executor.map(parse_session_file, files_to_update))
223
+
224
+ # Separate new sessions from updates
225
+ sessions_to_add: list[ChatSession] = []
226
+ sessions_to_update: list[ChatSession] = []
227
+
228
+ for sessions in parse_results:
229
+ for session in sessions:
230
+ existing = database.get_session(session.session_id)
231
+ if existing:
232
+ sessions_to_update.append(session)
233
+ else:
234
+ sessions_to_add.append(session)
235
+
236
+ # Batch insert new sessions (single transaction)
237
+ if sessions_to_add:
238
+ batch_added, _batch_skipped = database.add_sessions_batch(sessions_to_add, store_raw=store_raw)
239
+ added += batch_added
240
+ if verbose:
241
+ for session in sessions_to_add:
242
+ workspace = session.workspace_name or "Unknown workspace"
243
+ console.print(f" Added: {workspace} ({len(session.messages)} messages)")
244
+
245
+ # Update existing sessions (must be individual due to delete+insert)
246
+ for session in sessions_to_update:
247
+ database.update_session(session, store_raw=store_raw)
248
+ updated += 1
249
+ if verbose:
250
+ workspace = session.workspace_name or "Unknown workspace"
251
+ console.print(f" Updated: {workspace} ({len(session.messages)} messages)")
252
+
253
+ console.print("\n[green]Import complete:[/green]")
254
+ console.print(f" Added: {added} sessions")
255
+ console.print(f" Updated: {updated} sessions")
256
+ console.print(f" Skipped (unchanged): {skipped} sessions")
257
+
258
+ stats = database.get_stats()
259
+ console.print("\n[cyan]Database now contains:[/cyan]")
260
+ console.print(f" {stats['session_count']} sessions")
261
+ console.print(f" {stats['message_count']} messages")
262
+ console.print(f" {stats['workspace_count']} workspaces")
263
+
264
+
265
+ @app.command()
266
+ def search(
267
+ query: Annotated[str, typer.Argument(help="Search query.")],
268
+ db: Annotated[
269
+ Path,
270
+ typer.Option(
271
+ "--db",
272
+ "-d",
273
+ help="Path to SQLite database file.",
274
+ exists=True,
275
+ ),
276
+ ] = Path("copilot_chats.db"),
277
+ limit: Annotated[
278
+ int,
279
+ typer.Option(
280
+ "--limit",
281
+ "-l",
282
+ "--top",
283
+ help="Maximum number of results to show (top).",
284
+ ),
285
+ ] = 20,
286
+ skip: Annotated[
287
+ int,
288
+ typer.Option(
289
+ "--skip",
290
+ help="Number of results to skip (for pagination).",
291
+ ),
292
+ ] = 0,
293
+ role: Annotated[
294
+ str | None,
295
+ typer.Option(
296
+ "--role",
297
+ "-r",
298
+ help="Filter by message role (user or assistant).",
299
+ ),
300
+ ] = None,
301
+ title_filter: Annotated[
302
+ str | None,
303
+ typer.Option(
304
+ "--title",
305
+ "-t",
306
+ help="Filter by session title or workspace name.",
307
+ ),
308
+ ] = None,
309
+ repository_filter: Annotated[
310
+ str | None,
311
+ typer.Option(
312
+ "--repository",
313
+ "--repo",
314
+ help="Filter by repository URL (e.g., github.com/owner/repo).",
315
+ ),
316
+ ] = None,
317
+ no_tools: Annotated[
318
+ bool,
319
+ typer.Option(
320
+ "--no-tools",
321
+ help="Exclude tool invocations from search results.",
322
+ ),
323
+ ] = False,
324
+ no_files: Annotated[
325
+ bool,
326
+ typer.Option(
327
+ "--no-files",
328
+ help="Exclude file changes from search results.",
329
+ ),
330
+ ] = False,
331
+ tools_only: Annotated[
332
+ bool,
333
+ typer.Option(
334
+ "--tools-only",
335
+ help="Only search in tool invocations.",
336
+ ),
337
+ ] = False,
338
+ files_only: Annotated[
339
+ bool,
340
+ typer.Option(
341
+ "--files-only",
342
+ help="Only search in file changes.",
343
+ ),
344
+ ] = False,
345
+ full_content: Annotated[
346
+ bool,
347
+ typer.Option(
348
+ "--full",
349
+ "-F",
350
+ help="Show full content instead of truncated snippets.",
351
+ ),
352
+ ] = False,
353
+ sort_by: Annotated[
354
+ str,
355
+ typer.Option(
356
+ "--sort",
357
+ "-s",
358
+ help="Sort results by relevance (default) or date.",
359
+ ),
360
+ ] = "relevance",
361
+ ):
362
+ """Search chat messages in the database.
363
+
364
+ Supports advanced query syntax:
365
+
366
+ \b
367
+ - Multiple words: "python function" matches both words (AND logic)
368
+ - Exact phrases: Use quotes like "python function" for exact match
369
+ - Field filters in query: role:user, role:assistant, workspace:name, title:name, repository:url (or repo:url)
370
+ - Date filters: start_date:2024-01-01 end_date:2024-12-31 (yyyy-mm-dd format, inclusive)
371
+
372
+ Examples:
373
+
374
+ \b
375
+ copilot-session-tools search "python function"
376
+ copilot-session-tools search "role:user python"
377
+ copilot-session-tools search "workspace:my-project"
378
+ copilot-session-tools search "repo:github.com/owner/repo"
379
+ copilot-session-tools search "start_date:2024-01-01 end_date:2024-06-30"
380
+ copilot-session-tools search '"exact phrase"'
381
+
382
+ Use --role to filter by user requests or assistant responses.
383
+ Use --title to filter by session/workspace name.
384
+ Use --repository to filter by git repository URL.
385
+ Use --skip and --limit/--top for pagination.
386
+ Use --no-tools or --no-files to exclude specific content types.
387
+ Use --tools-only or --files-only to search only specific content types.
388
+ Use --full to show complete content instead of truncated snippets.
389
+ Use --sort to sort by relevance (default) or date.
390
+ """
391
+ if role and role not in ("user", "assistant"):
392
+ console.print("[red]Error: role must be 'user' or 'assistant'[/red]")
393
+ raise typer.Exit(1)
394
+
395
+ if sort_by not in ("relevance", "date"):
396
+ console.print("[red]Error: sort must be 'relevance' or 'date'[/red]")
397
+ raise typer.Exit(1)
398
+
399
+ # Handle search mode options
400
+ include_messages = True
401
+ include_tool_calls = not no_tools
402
+ include_file_changes = not no_files
403
+
404
+ if tools_only:
405
+ include_messages = False
406
+ include_file_changes = False
407
+ include_tool_calls = True
408
+ elif files_only:
409
+ include_messages = False
410
+ include_tool_calls = False
411
+ include_file_changes = True
412
+
413
+ database = Database(db)
414
+ results = database.search(
415
+ query,
416
+ limit=limit,
417
+ skip=skip,
418
+ role=role,
419
+ include_messages=include_messages,
420
+ include_tool_calls=include_tool_calls,
421
+ include_file_changes=include_file_changes,
422
+ session_title=title_filter,
423
+ sort_by=sort_by,
424
+ repository=repository_filter,
425
+ )
426
+
427
+ if not results:
428
+ console.print(f"[yellow]No results found for '{query}'[/yellow]")
429
+ return
430
+
431
+ # Display result count with pagination info
432
+ start_num = skip + 1
433
+ end_num = skip + len(results)
434
+ console.print(f"[green bold]Showing results {start_num}-{end_num} for '{query}':[/green bold]\n")
435
+
436
+ for i, result in enumerate(results, start_num):
437
+ console.print(f"[cyan bold]━━━ Result {i} ━━━[/cyan bold]")
438
+ console.print(f"[bright_blue bold]Session ID:[/bright_blue bold] {result['session_id']}")
439
+
440
+ if result.get("workspace_name"):
441
+ console.print(f"[bright_blue bold]Workspace:[/bright_blue bold] [yellow]{result['workspace_name']}[/yellow]")
442
+
443
+ if result.get("custom_title"):
444
+ console.print(f"[bright_blue bold]Title:[/bright_blue bold] {result['custom_title']}")
445
+
446
+ if result.get("created_at"):
447
+ formatted_date = format_timestamp(result["created_at"])
448
+ console.print(f"[bright_blue bold]Date:[/bright_blue bold] [dim]{formatted_date}[/dim]")
449
+
450
+ role_color = "green" if result["role"] == "user" else "magenta"
451
+ console.print(f"[bright_blue bold]Role:[/bright_blue bold] [{role_color}]{result['role']}[/{role_color}]")
452
+
453
+ if result.get("match_type") and result["match_type"] != "message":
454
+ console.print(f"[bright_blue bold]Match Type:[/bright_blue bold] [cyan]{result['match_type']}[/cyan]")
455
+
456
+ content = result["content"]
457
+ if not full_content and len(content) > 200:
458
+ content = content[:200] + "[dim]... (use --full to see more)[/dim]"
459
+ console.print(f"[bright_blue bold]Content:[/bright_blue bold] {content}")
460
+ console.print()
461
+
462
+
463
+ @app.command()
464
+ def stats(
465
+ db: Annotated[
466
+ Path,
467
+ typer.Option(
468
+ "--db",
469
+ "-d",
470
+ help="Path to SQLite database file.",
471
+ exists=True,
472
+ ),
473
+ ] = Path("copilot_chats.db"),
474
+ ):
475
+ """Show database statistics."""
476
+ database = Database(db)
477
+ stats_data = database.get_stats()
478
+
479
+ console.print("[bold]Database Statistics:[/bold]")
480
+ console.print(f" Sessions: {stats_data['session_count']}")
481
+ console.print(f" Messages: {stats_data['message_count']}")
482
+ console.print(f" Workspaces: {stats_data['workspace_count']}")
483
+
484
+ if stats_data["editions"]:
485
+ console.print("\n [cyan]By VS Code Edition:[/cyan]")
486
+ for edition, count in stats_data["editions"].items():
487
+ console.print(f" {edition}: {count}")
488
+
489
+ workspaces = database.get_workspaces()
490
+ if workspaces:
491
+ console.print("\n [cyan]Workspaces:[/cyan]")
492
+ for ws in workspaces[:10]:
493
+ console.print(f" {ws['workspace_name']}: {ws['session_count']} sessions")
494
+ if len(workspaces) > 10:
495
+ console.print(f" ... and {len(workspaces) - 10} more")
496
+
497
+ repositories = database.get_repositories()
498
+ if repositories:
499
+ console.print("\n [green]Repositories:[/green]")
500
+ for repo in repositories[:10]:
501
+ console.print(f" {repo['repository_url']}: {repo['session_count']} sessions")
502
+ if len(repositories) > 10:
503
+ console.print(f" ... and {len(repositories) - 10} more")
504
+
505
+
506
+ @app.command()
507
+ def export(
508
+ db: Annotated[
509
+ Path,
510
+ typer.Option(
511
+ "--db",
512
+ "-d",
513
+ help="Path to SQLite database file.",
514
+ exists=True,
515
+ ),
516
+ ] = Path("copilot_chats.db"),
517
+ output: Annotated[
518
+ str,
519
+ typer.Option(
520
+ "--output",
521
+ "-o",
522
+ help="Output file (- for stdout).",
523
+ ),
524
+ ] = "-",
525
+ ):
526
+ """Export the database as JSON."""
527
+ database = Database(db)
528
+ json_data = database.export_json()
529
+
530
+ if output == "-":
531
+ console.print(json_data)
532
+ else:
533
+ Path(output).write_text(json_data, encoding="utf-8")
534
+ console.print(f"[green]Exported to {output}[/green]")
535
+
536
+
537
+ @app.command("export-markdown")
538
+ def export_markdown(
539
+ db: Annotated[
540
+ Path,
541
+ typer.Option(
542
+ "--db",
543
+ "-d",
544
+ help="Path to SQLite database file.",
545
+ exists=True,
546
+ ),
547
+ ] = Path("copilot_chats.db"),
548
+ output_dir: Annotated[
549
+ Path,
550
+ typer.Option(
551
+ "--output-dir",
552
+ "-o",
553
+ help="Output directory for markdown files.",
554
+ ),
555
+ ] = Path(),
556
+ session_id: Annotated[
557
+ str | None,
558
+ typer.Option(
559
+ "--session-id",
560
+ "-s",
561
+ help="Export only a specific session by ID.",
562
+ ),
563
+ ] = None,
564
+ verbose: Annotated[
565
+ bool,
566
+ typer.Option(
567
+ "--verbose",
568
+ "-v",
569
+ help="Show verbose output.",
570
+ ),
571
+ ] = False,
572
+ include_diffs: Annotated[
573
+ bool,
574
+ typer.Option(
575
+ "--include-diffs/--no-diffs",
576
+ help="Include file diffs as code blocks in the markdown output.",
577
+ ),
578
+ ] = False,
579
+ include_tool_inputs: Annotated[
580
+ bool,
581
+ typer.Option(
582
+ "--include-tool-inputs/--no-tool-inputs",
583
+ help="Include tool inputs as code blocks in the markdown output.",
584
+ ),
585
+ ] = False,
586
+ include_thinking: Annotated[
587
+ bool,
588
+ typer.Option(
589
+ "--include-thinking/--no-thinking",
590
+ help="Include thinking/reasoning block content in the markdown output.",
591
+ ),
592
+ ] = False,
593
+ ):
594
+ """Export sessions as markdown files.
595
+
596
+ Each session is exported to a separate markdown file with:
597
+ - Header block with metadata (session ID, workspace, dates)
598
+ - Messages separated by horizontal rules
599
+ - Message numbers and roles as bold headers
600
+ - Tool call summaries in italics
601
+ - Thinking block notices in italics (content omitted)
602
+ """
603
+ database = Database(db)
604
+
605
+ output_dir.mkdir(parents=True, exist_ok=True)
606
+
607
+ if session_id:
608
+ session = database.get_session(session_id)
609
+ if session is None:
610
+ console.print(f"[red]Error: Session '{session_id}' not found.[/red]")
611
+ raise typer.Exit(1)
612
+
613
+ filename = generate_session_filename(session)
614
+ file_path = output_dir / filename
615
+ export_session_to_file(
616
+ session,
617
+ file_path,
618
+ include_diffs=include_diffs,
619
+ include_tool_inputs=include_tool_inputs,
620
+ include_thinking=include_thinking,
621
+ )
622
+ console.print(f"[green]Exported: {file_path}[/green]")
623
+ else:
624
+ sessions = database.list_sessions()
625
+ exported = 0
626
+
627
+ for session_info in sessions:
628
+ session = database.get_session(session_info["session_id"])
629
+ if session:
630
+ filename = generate_session_filename(session)
631
+ file_path = output_dir / filename
632
+ export_session_to_file(
633
+ session,
634
+ file_path,
635
+ include_diffs=include_diffs,
636
+ include_tool_inputs=include_tool_inputs,
637
+ include_thinking=include_thinking,
638
+ )
639
+ exported += 1
640
+ if verbose:
641
+ console.print(f" Exported: {file_path}")
642
+
643
+ console.print(f"\n[green]Exported {exported} sessions to {output_dir}/[/green]")
644
+
645
+
646
+ @app.command("export-html")
647
+ def export_html(
648
+ db: Annotated[
649
+ Path,
650
+ typer.Option(
651
+ "--db",
652
+ "-d",
653
+ help="Path to SQLite database file.",
654
+ exists=True,
655
+ ),
656
+ ] = Path("copilot_chats.db"),
657
+ output_dir: Annotated[
658
+ Path,
659
+ typer.Option(
660
+ "--output-dir",
661
+ "-o",
662
+ help="Output directory for HTML files.",
663
+ ),
664
+ ] = Path(),
665
+ session_id: Annotated[
666
+ str | None,
667
+ typer.Option(
668
+ "--session-id",
669
+ "-s",
670
+ help="Export only a specific session by ID.",
671
+ ),
672
+ ] = None,
673
+ verbose: Annotated[
674
+ bool,
675
+ typer.Option(
676
+ "--verbose",
677
+ "-v",
678
+ help="Show verbose output.",
679
+ ),
680
+ ] = False,
681
+ ):
682
+ """Export sessions as self-contained static HTML files.
683
+
684
+ Each session is exported to a separate HTML file with the same rich
685
+ rendering as the web viewer, but without interactive elements (toolbar,
686
+ copy buttons, AJAX). The HTML is self-contained with no external
687
+ dependencies.
688
+ """
689
+ database = Database(db)
690
+
691
+ output_dir.mkdir(parents=True, exist_ok=True)
692
+
693
+ if session_id:
694
+ session = database.get_session(session_id)
695
+ if session is None:
696
+ console.print(f"[red]Error: Session '{session_id}' not found.[/red]")
697
+ raise typer.Exit(1)
698
+
699
+ filename = generate_session_html_filename(session)
700
+ file_path = output_dir / filename
701
+ export_session_to_html_file(session, file_path)
702
+ console.print(f"[green]Exported: {file_path}[/green]")
703
+ else:
704
+ sessions = database.list_sessions()
705
+ exported = 0
706
+
707
+ for session_info in sessions:
708
+ session = database.get_session(session_info["session_id"])
709
+ if session:
710
+ filename = generate_session_html_filename(session)
711
+ file_path = output_dir / filename
712
+ export_session_to_html_file(session, file_path)
713
+ exported += 1
714
+ if verbose:
715
+ console.print(f" Exported: {file_path}")
716
+
717
+ console.print(f"\n[green]Exported {exported} sessions to {output_dir}/[/green]")
718
+
719
+
720
+ @app.command("import-json")
721
+ def import_json(
722
+ json_file: Annotated[
723
+ Path,
724
+ typer.Argument(
725
+ help="JSON file to import.",
726
+ exists=True,
727
+ ),
728
+ ],
729
+ db: Annotated[
730
+ Path,
731
+ typer.Option(
732
+ "--db",
733
+ "-d",
734
+ help="Path to SQLite database file.",
735
+ ),
736
+ ] = Path("copilot_chats.db"),
737
+ ):
738
+ """Import sessions from a JSON file."""
739
+ import json
740
+
741
+ database = Database(db)
742
+
743
+ with json_file.open(encoding="utf-8") as f:
744
+ data = json.load(f)
745
+
746
+ if not isinstance(data, list):
747
+ console.print("[red]Error: JSON file must contain an array of sessions.[/red]")
748
+ raise typer.Exit(1)
749
+
750
+ added = 0
751
+ skipped = 0
752
+
753
+ for item in data:
754
+ if not isinstance(item, dict):
755
+ continue
756
+
757
+ messages = [
758
+ ChatMessage(
759
+ role=m.get("role", "unknown"),
760
+ content=m.get("content", ""),
761
+ timestamp=m.get("timestamp"),
762
+ )
763
+ for m in item.get("messages", [])
764
+ ]
765
+
766
+ session = ChatSession(
767
+ session_id=item.get("session_id", str(hash(str(item)))),
768
+ workspace_name=item.get("workspace_name"),
769
+ workspace_path=item.get("workspace_path"),
770
+ messages=messages,
771
+ created_at=item.get("created_at"),
772
+ updated_at=item.get("updated_at"),
773
+ source_file=str(json_file),
774
+ vscode_edition=item.get("vscode_edition", "imported"),
775
+ )
776
+
777
+ if database.add_session(session):
778
+ added += 1
779
+ else:
780
+ skipped += 1
781
+
782
+ console.print("[green]Import complete:[/green]")
783
+ console.print(f" Added: {added} sessions")
784
+ console.print(f" Skipped: {skipped} sessions")
785
+
786
+
787
+ @app.command()
788
+ def rebuild(
789
+ db: Annotated[
790
+ Path,
791
+ typer.Option(
792
+ "--db",
793
+ "-d",
794
+ help="Path to SQLite database file.",
795
+ exists=True,
796
+ ),
797
+ ] = Path("copilot_chats.db"),
798
+ verbose: Annotated[
799
+ bool,
800
+ typer.Option(
801
+ "--verbose",
802
+ "-v",
803
+ help="Show verbose output.",
804
+ ),
805
+ ] = False,
806
+ ):
807
+ """Rebuild derived tables from raw JSON data.
808
+
809
+ This command drops all derived tables (sessions, messages, tool_invocations,
810
+ file_changes, command_runs, content_blocks) and recreates them from the
811
+ compressed raw JSON stored in raw_sessions.
812
+
813
+ Use this after schema changes to regenerate all derived data without needing
814
+ to re-scan the original VS Code storage.
815
+ """
816
+ database = Database(db)
817
+
818
+ # Check if there are any raw sessions to rebuild from
819
+ raw_count = database.get_raw_session_count()
820
+ if raw_count == 0:
821
+ console.print("[yellow]Warning: No raw sessions found in database.[/yellow]")
822
+ console.print("Run 'copilot-session-tools scan' first to import sessions.")
823
+ raise typer.Exit(1)
824
+
825
+ console.print(f"Rebuilding {raw_count} sessions from raw JSON...")
826
+
827
+ def progress_callback(processed, total):
828
+ if verbose:
829
+ console.print(f" Processed: {processed}/{total}")
830
+
831
+ result = database.rebuild_derived_tables(progress_callback=progress_callback if verbose else None)
832
+
833
+ console.print("\n[green]Rebuild complete:[/green]")
834
+ console.print(f" Processed: {result['processed']} sessions")
835
+ if result["errors"] > 0:
836
+ console.print(f" Errors: {result['errors']} sessions")
837
+
838
+ stats = database.get_stats()
839
+ console.print("\n[cyan]Database now contains:[/cyan]")
840
+ console.print(f" {stats['session_count']} sessions")
841
+ console.print(f" {stats['message_count']} messages")
842
+ console.print(f" {stats['workspace_count']} workspaces")
843
+
844
+
845
+ @app.command()
846
+ def optimize(
847
+ db: Annotated[
848
+ Path,
849
+ typer.Option(
850
+ "--db",
851
+ "-d",
852
+ help="Path to SQLite database file.",
853
+ exists=True,
854
+ ),
855
+ ] = Path("copilot_chats.db"),
856
+ ):
857
+ """Optimize the full-text search index for better query performance.
858
+
859
+ This command merges FTS5 index segments, reducing fragmentation and
860
+ improving search speed. Recommended to run periodically, especially
861
+ after bulk imports or the rebuild command.
862
+
863
+ The optimization process:
864
+ 1. Merges all FTS index segments into fewer, larger segments
865
+ 2. Runs an integrity check to verify index consistency
866
+ """
867
+ database = Database(db)
868
+
869
+ console.print("Optimizing FTS5 search index...")
870
+
871
+ result = database.optimize_fts()
872
+
873
+ console.print("\n[green]Optimization complete:[/green]")
874
+ console.print(f" Index segments before: {result['segments_before']}")
875
+ console.print(f" Index segments after: {result['segments_after']}")
876
+
877
+ if result["segments_before"] > result["segments_after"]:
878
+ reduction = result["segments_before"] - result["segments_after"]
879
+ console.print(f" [cyan]Merged {reduction} segments for faster queries[/cyan]")
880
+ else:
881
+ console.print(" [dim]Index was already optimized[/dim]")
882
+
883
+
884
+ @app.command("raw-json")
885
+ def raw_json(
886
+ session_id: Annotated[
887
+ str,
888
+ typer.Argument(help="Session ID to retrieve raw JSON for."),
889
+ ],
890
+ db: Annotated[
891
+ Path,
892
+ typer.Option(
893
+ "--db",
894
+ "-d",
895
+ help="Path to SQLite database file.",
896
+ exists=True,
897
+ ),
898
+ ] = Path("copilot_chats.db"),
899
+ output: Annotated[
900
+ Path | None,
901
+ typer.Option(
902
+ "--output",
903
+ "-o",
904
+ help="Output file path. If not specified, prints to stdout.",
905
+ ),
906
+ ] = None,
907
+ db_only: Annotated[
908
+ bool,
909
+ typer.Option(
910
+ "--db-only",
911
+ help="Only use database copy, don't try reading from source file.",
912
+ ),
913
+ ] = False,
914
+ ):
915
+ """Get the raw JSON for a specific session.
916
+
917
+ By default, tries to read from the original source file first (if it exists),
918
+ falling back to the compressed database copy. Use --db-only to always use
919
+ the database copy.
920
+
921
+ This is useful for debugging, data recovery, or inspecting the original
922
+ session data format.
923
+ """
924
+ database = Database(db)
925
+
926
+ raw_data = database.get_raw_json(session_id, prefer_file=not db_only)
927
+
928
+ if raw_data is None:
929
+ console.print(f"[red]Session not found: {session_id}[/red]")
930
+ raise typer.Exit(1)
931
+
932
+ if output:
933
+ output.write_bytes(raw_data)
934
+ console.print(f"[green]Wrote {len(raw_data)} bytes to {output}[/green]")
935
+ else:
936
+ # Print to stdout (decode as UTF-8 for display)
937
+ try:
938
+ print(raw_data.decode("utf-8"))
939
+ except UnicodeDecodeError as err:
940
+ console.print("[red]Error: Raw data is not valid UTF-8. Use --output to save to file.[/red]")
941
+ raise typer.Exit(1) from err
942
+
943
+
944
+ @app.command()
945
+ def web(
946
+ db: Annotated[Path, typer.Option("--db", "-d", help="Path to SQLite database file.")] = Path("copilot_chats.db"),
947
+ host: Annotated[str, typer.Option("--host", "-H", help="Host to bind to.")] = "127.0.0.1",
948
+ port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to.")] = 5000,
949
+ title: Annotated[str, typer.Option("--title", "-t", help="Title for the archive.")] = "Copilot Chat Archive",
950
+ debug: Annotated[bool, typer.Option("--debug", help="Enable debug mode.")] = False,
951
+ ):
952
+ """Start the web viewer for browsing chat sessions."""
953
+ try:
954
+ from copilot_session_tools.web import run_server
955
+ except ImportError as err:
956
+ console.print("[red]Web interface requires the [web] extra.[/red]")
957
+ console.print("Install with: pip install copilot-session-tools[web]")
958
+ raise typer.Exit(1) from err
959
+
960
+ if not db.exists():
961
+ console.print(f"[red]Database file '{db}' not found.[/red]")
962
+ console.print("Run 'copilot-session-tools scan' first to import chat sessions.")
963
+ raise typer.Exit(1)
964
+
965
+ database = Database(str(db))
966
+ db_stats = database.get_stats()
967
+
968
+ if db_stats["session_count"] == 0:
969
+ console.print("[yellow]Warning: Database is empty. Run 'copilot-session-tools scan' first.[/yellow]")
970
+
971
+ console.print("Starting web server...")
972
+ console.print(f" Database: {db}")
973
+ console.print(f" Sessions: {db_stats['session_count']}")
974
+ console.print(f" Messages: {db_stats['message_count']}")
975
+ console.print(f"\nOpen http://{host}:{port}/ in a browser to view your archive.")
976
+ console.print("Press Ctrl+C to stop the server.\n")
977
+
978
+ run_server(
979
+ host=host,
980
+ port=port,
981
+ db_path=str(db),
982
+ title=title,
983
+ debug=debug,
984
+ )
985
+
986
+
987
+ def run():
988
+ """Entry point for the CLI."""
989
+ app()
990
+
991
+
992
+ if __name__ == "__main__":
993
+ run()