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.
- copilot_session_tools/__init__.py +72 -0
- copilot_session_tools/cli.py +993 -0
- copilot_session_tools/database.py +1848 -0
- copilot_session_tools/html_exporter.py +270 -0
- copilot_session_tools/markdown_exporter.py +426 -0
- copilot_session_tools/py.typed +0 -0
- copilot_session_tools/scanner/__init__.py +73 -0
- copilot_session_tools/scanner/cli.py +606 -0
- copilot_session_tools/scanner/content.py +313 -0
- copilot_session_tools/scanner/diff.py +297 -0
- copilot_session_tools/scanner/discovery.py +365 -0
- copilot_session_tools/scanner/git.py +114 -0
- copilot_session_tools/scanner/models.py +122 -0
- copilot_session_tools/scanner/vscode.py +693 -0
- copilot_session_tools/web/__init__.py +107 -0
- copilot_session_tools/web/templates/error.html +61 -0
- copilot_session_tools/web/templates/index.html +1072 -0
- copilot_session_tools/web/templates/session.html +1592 -0
- copilot_session_tools/web/webapp.py +610 -0
- copilot_session_tools-0.1.1.dist-info/METADATA +375 -0
- copilot_session_tools-0.1.1.dist-info/RECORD +24 -0
- copilot_session_tools-0.1.1.dist-info/WHEEL +4 -0
- copilot_session_tools-0.1.1.dist-info/entry_points.txt +3 -0
- copilot_session_tools-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|