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,1848 @@
1
+ """Database module for storing and querying Copilot chat sessions.
2
+
3
+ Schema design inspired by:
4
+ - tad-hq/universal-session-viewer: FTS5 full-text search
5
+ - jazzyalex/agent-sessions: SQLite indexing patterns
6
+
7
+ The schema has two parts:
8
+ 1. raw_sessions - Stores compressed raw JSON as the source of truth for rebuilding
9
+ 2. Derived tables (sessions, messages, etc.) - Can be dropped and recreated from raw_sessions
10
+ """
11
+
12
+ import contextlib
13
+ import json
14
+ import re
15
+ import sqlite3
16
+ import zlib
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import ClassVar
21
+
22
+ import orjson
23
+
24
+
25
+ @dataclass
26
+ class ParsedQuery:
27
+ """Represents a parsed search query with extracted field filters."""
28
+
29
+ fts_query: str # The FTS5 query string for content search
30
+ role: str | None = None # Extracted role filter (user/assistant)
31
+ workspace: str | None = None # Extracted workspace filter
32
+ title: str | None = None # Extracted title filter
33
+ repository: str | None = None # Extracted repository filter
34
+ edition: str | None = None # Extracted edition filter (stable/insider/cli)
35
+ start_date: str | None = None # Extracted start date filter (yyyy-mm-dd format, inclusive)
36
+ end_date: str | None = None # Extracted end date filter (yyyy-mm-dd format, inclusive)
37
+
38
+
39
+ def _validate_date_format(date_str: str) -> str | None:
40
+ """Validate date string is in yyyy-mm-dd format.
41
+
42
+ Args:
43
+ date_str: Date string to validate.
44
+
45
+ Returns:
46
+ The validated date string if valid, None otherwise.
47
+ """
48
+ if not date_str:
49
+ return None
50
+ # Check format: yyyy-mm-dd
51
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
52
+ return None
53
+ # Basic validation of month/day ranges
54
+ try:
55
+ _year, month, day = map(int, date_str.split("-"))
56
+ if month < 1 or month > 12 or day < 1 or day > 31:
57
+ return None
58
+ return date_str
59
+ except (ValueError, AttributeError):
60
+ return None
61
+
62
+
63
+ def _escape_fts5_token(token: str) -> str:
64
+ """Escape a single FTS5 token to prevent syntax errors.
65
+
66
+ FTS5 has special operators like:
67
+ - Dash (-) which means NOT
68
+ - Colon (:) for column specification
69
+ - Parentheses, brackets for grouping
70
+
71
+ If a token is already quoted, leave it as-is.
72
+ Otherwise, wrap it in quotes if it contains special characters.
73
+
74
+ Args:
75
+ token: A single search token (word or phrase).
76
+
77
+ Returns:
78
+ The escaped token, safe for FTS5 MATCH queries.
79
+ """
80
+ if not token:
81
+ return token
82
+
83
+ # If already quoted, leave as-is
84
+ if token.startswith('"') and token.endswith('"'):
85
+ return token
86
+
87
+ # FTS5 special characters that need escaping:
88
+ # - (dash/NOT operator), : (column spec), (, ), [, ]
89
+ # Note: We don't need to escape * (prefix match) or ^ (first token)
90
+ # as these are useful operators users might want to use
91
+ special_chars = ["-", ":", "(", ")", "[", "]"]
92
+
93
+ # Check if token contains any special characters
94
+ if any(char in token for char in special_chars):
95
+ # Escape internal quotes by doubling them (FTS5 convention)
96
+ escaped = token.replace('"', '""')
97
+ # Wrap in quotes
98
+ return f'"{escaped}"'
99
+
100
+ return token
101
+
102
+
103
+ def parse_search_query(query: str) -> ParsedQuery:
104
+ """Parse a search query to extract field prefixes and convert to FTS5 format.
105
+
106
+ Supports:
107
+ - Multiple words: "python function" → matches both words (AND logic)
108
+ - Exact phrases: '"python function"' → matches exact phrase
109
+ - Field prefixes: 'role:user workspace:myproject title:something repository:github.com/owner/repo edition:cli'
110
+ - Date filters: 'start_date:2024-01-01 end_date:2024-12-31' (yyyy-mm-dd format, inclusive)
111
+
112
+ Args:
113
+ query: The raw search query string.
114
+
115
+ Returns:
116
+ ParsedQuery with extracted field filters and FTS5 query string.
117
+ """
118
+ if not query or not query.strip():
119
+ return ParsedQuery(fts_query="")
120
+
121
+ query = query.strip()
122
+
123
+ # Extract field prefixes (role:, workspace:, title:, repository:, edition:, start_date:, end_date:)
124
+ role = None
125
+ workspace = None
126
+ title = None
127
+ repository = None
128
+ edition = None
129
+ start_date = None
130
+ end_date = None
131
+
132
+ # Pattern for field:value (value can be quoted or unquoted)
133
+ field_pattern = r'\b(role|workspace|title|repository|repo|edition|start_date|end_date):(?:"([^"]*)"|(\S+))'
134
+
135
+ def extract_field(match):
136
+ nonlocal role, workspace, title, repository, edition, start_date, end_date
137
+ field_name = match.group(1).lower()
138
+ # Value is either in group 2 (quoted) or group 3 (unquoted)
139
+ value = match.group(2) if match.group(2) is not None else match.group(3)
140
+
141
+ if field_name == "role":
142
+ role = value.lower()
143
+ elif field_name == "workspace":
144
+ workspace = value
145
+ elif field_name == "title":
146
+ title = value
147
+ elif field_name in ("repository", "repo"):
148
+ repository = value
149
+ elif field_name == "edition":
150
+ edition = value.lower()
151
+ elif field_name == "start_date":
152
+ validated = _validate_date_format(value)
153
+ if validated:
154
+ start_date = validated
155
+ elif field_name == "end_date":
156
+ validated = _validate_date_format(value)
157
+ if validated:
158
+ end_date = validated
159
+
160
+ return "" # Remove the field prefix from the query
161
+
162
+ # Remove field prefixes and extract their values
163
+ remaining_query = re.sub(field_pattern, extract_field, query, flags=re.IGNORECASE)
164
+ remaining_query = remaining_query.strip()
165
+
166
+ # Now process the remaining query for FTS5
167
+ # FTS5 by default uses AND for multiple terms, so we just need to handle:
168
+ # 1. Quoted phrases (keep as-is)
169
+ # 2. Unquoted words (escape special chars and join with spaces for implicit AND)
170
+
171
+ if not remaining_query:
172
+ fts_query = ""
173
+ else:
174
+ # Tokenize the query preserving quoted strings
175
+ tokens = []
176
+ # Pattern to match quoted strings or individual words
177
+ token_pattern = r'"[^"]*"|[^\s"]+'
178
+
179
+ for match in re.finditer(token_pattern, remaining_query):
180
+ token = match.group(0)
181
+ # Clean up any empty quotes
182
+ if token == '""':
183
+ continue
184
+ # Escape FTS5 special characters in the token
185
+ escaped_token = _escape_fts5_token(token)
186
+ tokens.append(escaped_token)
187
+
188
+ # Join tokens with space (FTS5 uses implicit AND)
189
+ fts_query = " ".join(tokens)
190
+
191
+ return ParsedQuery(
192
+ fts_query=fts_query,
193
+ role=role,
194
+ workspace=workspace,
195
+ title=title,
196
+ repository=repository,
197
+ edition=edition,
198
+ start_date=start_date,
199
+ end_date=end_date,
200
+ )
201
+
202
+
203
+ # SQL LIKE pattern to detect ISO timestamp format (e.g., "2025-01-15T10:30:00Z")
204
+ # Pattern matches "YYYY-MM-DD" prefix which is common to all ISO timestamps
205
+ _ISO_TIMESTAMP_PATTERN = "____-__-__%"
206
+
207
+
208
+ def _build_date_filter_clause(start_date: str | None, end_date: str | None, date_column: str = "s.created_at") -> tuple[str, list]:
209
+ """Build SQL WHERE clause fragments for date filtering.
210
+
211
+ The created_at field can be:
212
+ 1. ISO timestamp string like "2025-01-15T10:30:00Z"
213
+ 2. Millisecond epoch timestamp like "1704067200000"
214
+
215
+ This function handles both formats by using SQLite's date/datetime functions.
216
+
217
+ Args:
218
+ start_date: Start date in yyyy-mm-dd format (inclusive).
219
+ end_date: End date in yyyy-mm-dd format (inclusive).
220
+ date_column: The SQL column name to filter on.
221
+
222
+ Returns:
223
+ Tuple of (SQL clause string, list of parameters).
224
+ """
225
+ clauses = []
226
+ params = []
227
+
228
+ if start_date:
229
+ # Handle both ISO timestamps and millisecond epochs
230
+ # For ISO: compare directly using date extraction
231
+ # For epoch ms: convert to date using SQLite's datetime functions
232
+ clauses.append(f"""
233
+ (
234
+ (TYPEOF({date_column}) = 'text' AND (
235
+ ({date_column} LIKE '{_ISO_TIMESTAMP_PATTERN}' AND DATE(SUBSTR({date_column}, 1, 10)) >= ?) OR
236
+ ({date_column} NOT LIKE '{_ISO_TIMESTAMP_PATTERN}' AND DATE({date_column} / 1000, 'unixepoch') >= ?)
237
+ ))
238
+ )
239
+ """)
240
+ params.extend([start_date, start_date])
241
+
242
+ if end_date:
243
+ clauses.append(f"""
244
+ (
245
+ (TYPEOF({date_column}) = 'text' AND (
246
+ ({date_column} LIKE '{_ISO_TIMESTAMP_PATTERN}' AND DATE(SUBSTR({date_column}, 1, 10)) <= ?) OR
247
+ ({date_column} NOT LIKE '{_ISO_TIMESTAMP_PATTERN}' AND DATE({date_column} / 1000, 'unixepoch') <= ?)
248
+ ))
249
+ )
250
+ """)
251
+ params.extend([end_date, end_date])
252
+
253
+ return " AND ".join(clauses) if clauses else "", params
254
+
255
+
256
+ # Allowed sort options with their SQL ORDER BY clauses (whitelist for security)
257
+ # Note: For relevance, we combine FTS5 rank (text relevance) with recency.
258
+ # FTS5 rank is negative (lower/more negative = better match).
259
+ # We subtract a recency bonus to make recent items more negative (better rank).
260
+ # The formula: rank - (days_since_2020 * 0.001) gives more weight to text relevance
261
+ # while providing a small boost to recent items.
262
+ _SORT_ORDER_CLAUSES = {
263
+ "relevance": """ORDER BY (
264
+ rank -
265
+ CASE
266
+ WHEN TYPEOF(s.created_at) = 'text' AND s.created_at LIKE '____-__-__%'
267
+ THEN (JULIANDAY(SUBSTR(s.created_at, 1, 10)) - JULIANDAY('2020-01-01')) * 0.001
268
+ WHEN TYPEOF(s.created_at) = 'text'
269
+ THEN (JULIANDAY(DATETIME(CAST(s.created_at AS REAL) / 1000, 'unixepoch')) - JULIANDAY('2020-01-01')) * 0.001
270
+ ELSE 0
271
+ END
272
+ )""",
273
+ "date": "ORDER BY s.created_at DESC",
274
+ }
275
+
276
+ from .markdown_exporter import message_to_markdown
277
+ from .scanner import (
278
+ ChatMessage,
279
+ ChatSession,
280
+ CommandRun,
281
+ ContentBlock,
282
+ FileChange,
283
+ ToolInvocation,
284
+ _extract_session_from_dict,
285
+ )
286
+
287
+
288
+ class Database:
289
+ """SQLite database for storing Copilot chat sessions.
290
+
291
+ Uses FTS5 for full-text search (inspired by tad-hq/universal-session-viewer).
292
+
293
+ The database has a two-layer design:
294
+ 1. raw_sessions table stores compressed raw JSON as the source of truth
295
+ 2. Derived tables (sessions, messages, etc.) can be dropped and rebuilt
296
+ """
297
+
298
+ # Schema for the raw data table - source of truth
299
+ RAW_SCHEMA = """
300
+ CREATE TABLE IF NOT EXISTS raw_sessions (
301
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
302
+ session_id TEXT UNIQUE NOT NULL,
303
+ raw_json_compressed BLOB,
304
+ workspace_name TEXT,
305
+ workspace_path TEXT,
306
+ source_file TEXT,
307
+ vscode_edition TEXT DEFAULT 'stable',
308
+ source_file_mtime REAL,
309
+ source_file_size INTEGER,
310
+ repository_url TEXT,
311
+ imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
312
+ );
313
+
314
+ CREATE INDEX IF NOT EXISTS idx_raw_sessions_session_id ON raw_sessions(session_id);
315
+ CREATE INDEX IF NOT EXISTS idx_raw_sessions_workspace ON raw_sessions(workspace_name);
316
+ CREATE INDEX IF NOT EXISTS idx_raw_sessions_repository ON raw_sessions(repository_url);
317
+ """
318
+
319
+ # Schema for derived tables that can be dropped and recreated
320
+ DERIVED_SCHEMA = """
321
+ CREATE TABLE IF NOT EXISTS sessions (
322
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
323
+ session_id TEXT UNIQUE NOT NULL,
324
+ workspace_name TEXT,
325
+ workspace_path TEXT,
326
+ created_at TEXT,
327
+ updated_at TEXT,
328
+ source_file TEXT,
329
+ vscode_edition TEXT DEFAULT 'stable',
330
+ custom_title TEXT,
331
+ requester_username TEXT,
332
+ responder_username TEXT,
333
+ imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
334
+ source_file_mtime REAL,
335
+ source_file_size INTEGER,
336
+ type TEXT DEFAULT 'vscode',
337
+ repository_url TEXT
338
+ );
339
+
340
+ CREATE TABLE IF NOT EXISTS messages (
341
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
342
+ session_id TEXT NOT NULL,
343
+ message_index INTEGER NOT NULL,
344
+ role TEXT NOT NULL,
345
+ content TEXT NOT NULL,
346
+ timestamp TEXT,
347
+ cached_markdown TEXT,
348
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
349
+ );
350
+
351
+ -- Tool invocations table (from Arbuzov/copilot-chat-history types)
352
+ CREATE TABLE IF NOT EXISTS tool_invocations (
353
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
354
+ message_id INTEGER NOT NULL,
355
+ name TEXT NOT NULL,
356
+ input TEXT,
357
+ result TEXT,
358
+ status TEXT,
359
+ start_time INTEGER,
360
+ end_time INTEGER,
361
+ source_type TEXT,
362
+ invocation_message TEXT,
363
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
364
+ );
365
+
366
+ -- File changes table (from Arbuzov/copilot-chat-history types)
367
+ CREATE TABLE IF NOT EXISTS file_changes (
368
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
369
+ message_id INTEGER NOT NULL,
370
+ path TEXT NOT NULL,
371
+ diff TEXT,
372
+ content TEXT,
373
+ explanation TEXT,
374
+ language_id TEXT,
375
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
376
+ );
377
+
378
+ -- Command runs table (from Arbuzov/copilot-chat-history types)
379
+ CREATE TABLE IF NOT EXISTS command_runs (
380
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
381
+ message_id INTEGER NOT NULL,
382
+ command TEXT NOT NULL,
383
+ title TEXT,
384
+ result TEXT,
385
+ status TEXT,
386
+ output TEXT,
387
+ timestamp INTEGER,
388
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
389
+ );
390
+
391
+ -- Content blocks table for structured message content with kind (thinking, text, etc.)
392
+ CREATE TABLE IF NOT EXISTS content_blocks (
393
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
394
+ message_id INTEGER NOT NULL,
395
+ block_index INTEGER NOT NULL,
396
+ kind TEXT NOT NULL DEFAULT 'text',
397
+ content TEXT NOT NULL,
398
+ description TEXT,
399
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
400
+ );
401
+
402
+ CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_name);
403
+ CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at);
404
+ CREATE INDEX IF NOT EXISTS idx_sessions_repository ON sessions(repository_url);
405
+ CREATE INDEX IF NOT EXISTS idx_sessions_edition ON sessions(vscode_edition);
406
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
407
+ CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
408
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
409
+ CREATE INDEX IF NOT EXISTS idx_messages_session_index ON messages(session_id, message_index);
410
+ CREATE INDEX IF NOT EXISTS idx_tool_invocations_message ON tool_invocations(message_id);
411
+ CREATE INDEX IF NOT EXISTS idx_file_changes_message ON file_changes(message_id);
412
+ CREATE INDEX IF NOT EXISTS idx_command_runs_message ON command_runs(message_id);
413
+ CREATE INDEX IF NOT EXISTS idx_content_blocks_message ON content_blocks(message_id);
414
+
415
+ -- Full-text search for messages (FTS5 inspired by tad-hq/universal-session-viewer)
416
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
417
+ content,
418
+ content='messages',
419
+ content_rowid='id'
420
+ );
421
+
422
+ -- Triggers to keep FTS in sync
423
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
424
+ INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
425
+ END;
426
+
427
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
428
+ INSERT INTO messages_fts(messages_fts, rowid, content)
429
+ VALUES ('delete', old.id, old.content);
430
+ END;
431
+
432
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
433
+ INSERT INTO messages_fts(messages_fts, rowid, content)
434
+ VALUES ('delete', old.id, old.content);
435
+ INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
436
+ END;
437
+ """
438
+
439
+ # List of derived tables that can be dropped and recreated
440
+ DERIVED_TABLES: ClassVar[list[str]] = [
441
+ "messages_fts", # FTS table must be dropped first
442
+ "content_blocks",
443
+ "command_runs",
444
+ "file_changes",
445
+ "tool_invocations",
446
+ "messages",
447
+ "sessions",
448
+ ]
449
+
450
+ # List of triggers that need to be dropped/recreated with derived tables
451
+ DERIVED_TRIGGERS: ClassVar[list[str]] = ["messages_ai", "messages_ad", "messages_au"]
452
+
453
+ # Compression level for zlib (0-9, 6 is a good balance of speed and compression)
454
+ COMPRESSION_LEVEL = 1 # Fast compression; level 1 is 2x faster than 6 with similar ratio for JSON
455
+
456
+ def __init__(self, db_path: str | Path):
457
+ """Initialize the database connection.
458
+
459
+ Args:
460
+ db_path: Path to the SQLite database file.
461
+ """
462
+ self.db_path = Path(db_path)
463
+ self._ensure_schema()
464
+
465
+ @contextmanager
466
+ def _get_connection(self):
467
+ """Get a database connection context manager."""
468
+ conn = sqlite3.connect(str(self.db_path))
469
+ conn.row_factory = sqlite3.Row
470
+ conn.execute("PRAGMA foreign_keys = ON")
471
+ try:
472
+ yield conn
473
+ conn.commit()
474
+ except Exception:
475
+ conn.rollback()
476
+ raise
477
+ finally:
478
+ conn.close()
479
+
480
+ def _ensure_schema(self):
481
+ """Ensure the database schema exists.
482
+
483
+ With the new two-layer design:
484
+ - raw_sessions is the source of truth (never needs migration)
485
+ - Derived tables can be dropped and rebuilt, so no migrations needed
486
+ """
487
+ with self._get_connection() as conn:
488
+ # Check if raw_sessions exists and needs migration
489
+ cursor = conn.cursor()
490
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='raw_sessions'")
491
+ raw_sessions_exists = cursor.fetchone() is not None
492
+
493
+ if raw_sessions_exists:
494
+ # Check if repository_url column exists, add if missing
495
+ cursor.execute("PRAGMA table_info(raw_sessions)")
496
+ columns = {row[1] for row in cursor.fetchall()}
497
+ if "repository_url" not in columns:
498
+ cursor.execute("ALTER TABLE raw_sessions ADD COLUMN repository_url TEXT")
499
+ conn.commit()
500
+
501
+ # Check if sessions table exists and needs migration
502
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'")
503
+ sessions_exists = cursor.fetchone() is not None
504
+
505
+ if sessions_exists:
506
+ # Check if repository_url column exists in sessions, add if missing
507
+ cursor.execute("PRAGMA table_info(sessions)")
508
+ columns = {row[1] for row in cursor.fetchall()}
509
+ if "repository_url" not in columns:
510
+ cursor.execute("ALTER TABLE sessions ADD COLUMN repository_url TEXT")
511
+ conn.commit()
512
+
513
+ # Create raw_sessions table first (source of truth) - uses IF NOT EXISTS
514
+ conn.executescript(self.RAW_SCHEMA)
515
+ # Create derived tables
516
+ conn.executescript(self.DERIVED_SCHEMA)
517
+
518
+ def add_session(self, session: ChatSession, store_raw: bool = False) -> bool:
519
+ """Add a chat session to the database.
520
+
521
+ Args:
522
+ session: The ChatSession to add.
523
+ store_raw: If True, store the raw JSON in the database. If False (default),
524
+ only store metadata and derived tables. Raw JSON can still be
525
+ retrieved from the source file if it exists.
526
+
527
+ Returns:
528
+ True if the session was added, False if it already exists.
529
+ """
530
+ with self._get_connection() as conn:
531
+ cursor = conn.cursor()
532
+
533
+ # Check if session already exists in raw_sessions
534
+ cursor.execute("SELECT id FROM raw_sessions WHERE session_id = ?", (session.session_id,))
535
+ if cursor.fetchone():
536
+ return False
537
+
538
+ self._add_session_impl(cursor, session, store_raw=store_raw)
539
+ return True
540
+
541
+ def add_sessions_batch(self, sessions: list[ChatSession], store_raw: bool = False) -> tuple[int, int]:
542
+ """Add multiple sessions in a single transaction.
543
+
544
+ Much faster than calling add_session() repeatedly as it uses
545
+ a single connection and commit.
546
+
547
+ Args:
548
+ sessions: List of ChatSession objects to add.
549
+ store_raw: If True, store the raw JSON in the database.
550
+
551
+ Returns:
552
+ Tuple of (added_count, skipped_count).
553
+ """
554
+ added = 0
555
+ skipped = 0
556
+
557
+ with self._get_connection() as conn:
558
+ cursor = conn.cursor()
559
+
560
+ for session in sessions:
561
+ # Check if session already exists
562
+ cursor.execute("SELECT id FROM raw_sessions WHERE session_id = ?", (session.session_id,))
563
+ if cursor.fetchone():
564
+ skipped += 1
565
+ continue
566
+
567
+ # Add the session within this transaction
568
+ self._add_session_impl(cursor, session, store_raw=store_raw)
569
+ added += 1
570
+
571
+ return added, skipped
572
+
573
+ def _add_session_impl(self, cursor, session: ChatSession, store_raw: bool = False):
574
+ """Internal implementation of add_session that uses an existing cursor.
575
+
576
+ Used by add_sessions_batch for efficient batch inserts.
577
+ """
578
+ # Store compressed raw JSON only if requested
579
+ compressed_json = None
580
+ if store_raw and session.raw_json:
581
+ compressed_json = zlib.compress(session.raw_json, level=self.COMPRESSION_LEVEL)
582
+
583
+ cursor.execute(
584
+ """
585
+ INSERT INTO raw_sessions
586
+ (session_id, raw_json_compressed, workspace_name, workspace_path,
587
+ source_file, vscode_edition, source_file_mtime, source_file_size, repository_url)
588
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
589
+ """,
590
+ (
591
+ session.session_id,
592
+ compressed_json,
593
+ session.workspace_name,
594
+ session.workspace_path,
595
+ session.source_file,
596
+ session.vscode_edition,
597
+ session.source_file_mtime,
598
+ session.source_file_size,
599
+ session.repository_url,
600
+ ),
601
+ )
602
+
603
+ # Insert into derived sessions table
604
+ cursor.execute(
605
+ """
606
+ INSERT INTO sessions
607
+ (session_id, workspace_name, workspace_path, created_at, updated_at,
608
+ source_file, vscode_edition, custom_title, requester_username, responder_username,
609
+ source_file_mtime, source_file_size, type, repository_url)
610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
611
+ """,
612
+ (
613
+ session.session_id,
614
+ session.workspace_name,
615
+ session.workspace_path,
616
+ session.created_at,
617
+ session.updated_at,
618
+ session.source_file,
619
+ session.vscode_edition,
620
+ session.custom_title,
621
+ session.requester_username,
622
+ session.responder_username,
623
+ session.source_file_mtime,
624
+ session.source_file_size,
625
+ session.type,
626
+ session.repository_url,
627
+ ),
628
+ )
629
+
630
+ # Insert messages and related data
631
+ for idx, msg in enumerate(session.messages):
632
+ cached_markdown = message_to_markdown(
633
+ msg,
634
+ message_number=idx + 1,
635
+ include_diffs=True,
636
+ include_tool_inputs=True,
637
+ )
638
+
639
+ cursor.execute(
640
+ """
641
+ INSERT INTO messages
642
+ (session_id, message_index, role, content, timestamp, cached_markdown)
643
+ VALUES (?, ?, ?, ?, ?, ?)
644
+ """,
645
+ (
646
+ session.session_id,
647
+ idx,
648
+ msg.role,
649
+ msg.content,
650
+ msg.timestamp,
651
+ cached_markdown,
652
+ ),
653
+ )
654
+ message_id = cursor.lastrowid
655
+
656
+ # Insert FTS entry
657
+ cursor.execute(
658
+ "INSERT INTO messages_fts (rowid, content) VALUES (?, ?)",
659
+ (message_id, msg.content),
660
+ )
661
+
662
+ # Insert tool invocations
663
+ for tool in msg.tool_invocations:
664
+ cursor.execute(
665
+ """
666
+ INSERT INTO tool_invocations
667
+ (message_id, name, input, result, status, start_time, end_time, source_type, invocation_message)
668
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
669
+ """,
670
+ (
671
+ message_id,
672
+ tool.name,
673
+ tool.input,
674
+ tool.result,
675
+ tool.status,
676
+ tool.start_time,
677
+ tool.end_time,
678
+ tool.source_type,
679
+ tool.invocation_message,
680
+ ),
681
+ )
682
+
683
+ # Insert file changes
684
+ for change in msg.file_changes:
685
+ cursor.execute(
686
+ """
687
+ INSERT INTO file_changes
688
+ (message_id, path, diff, content, explanation, language_id)
689
+ VALUES (?, ?, ?, ?, ?, ?)
690
+ """,
691
+ (
692
+ message_id,
693
+ change.path,
694
+ change.diff,
695
+ change.content,
696
+ change.explanation,
697
+ change.language_id,
698
+ ),
699
+ )
700
+
701
+ # Insert command runs
702
+ for cmd in msg.command_runs:
703
+ cursor.execute(
704
+ """
705
+ INSERT INTO command_runs
706
+ (message_id, command, title, result, status, output, timestamp)
707
+ VALUES (?, ?, ?, ?, ?, ?, ?)
708
+ """,
709
+ (
710
+ message_id,
711
+ cmd.command,
712
+ cmd.title,
713
+ cmd.result,
714
+ cmd.status,
715
+ cmd.output,
716
+ cmd.timestamp,
717
+ ),
718
+ )
719
+
720
+ # Insert content blocks
721
+ for block_idx, block in enumerate(msg.content_blocks):
722
+ cursor.execute(
723
+ """
724
+ INSERT INTO content_blocks
725
+ (message_id, block_index, kind, content, description)
726
+ VALUES (?, ?, ?, ?, ?)
727
+ """,
728
+ (
729
+ message_id,
730
+ block_idx,
731
+ block.kind,
732
+ block.content,
733
+ block.description,
734
+ ),
735
+ )
736
+
737
+ def update_session(self, session: ChatSession, store_raw: bool = False):
738
+ """Update an existing session or add it if it doesn't exist.
739
+
740
+ Args:
741
+ session: The ChatSession to update.
742
+ store_raw: If True, store the raw JSON in the database.
743
+ """
744
+ with self._get_connection() as conn:
745
+ cursor = conn.cursor()
746
+
747
+ # Delete from raw_sessions first (source of truth)
748
+ cursor.execute("DELETE FROM raw_sessions WHERE session_id = ?", (session.session_id,))
749
+
750
+ # Delete existing session and messages (cascades)
751
+ cursor.execute("DELETE FROM sessions WHERE session_id = ?", (session.session_id,))
752
+
753
+ # Add the session (this will add to both raw_sessions and derived tables)
754
+ self.add_session(session, store_raw=store_raw)
755
+
756
+ def needs_update(self, session_id: str, file_mtime: float | None, file_size: int | None) -> bool:
757
+ """Check if a session needs to be updated based on file metadata.
758
+
759
+ Returns True if:
760
+ - Session doesn't exist, OR
761
+ - Stored mtime/size is NULL (migration case), OR
762
+ - Stored mtime/size differs from provided values
763
+
764
+ Args:
765
+ session_id: The session ID to check.
766
+ file_mtime: The current file modification time.
767
+ file_size: The current file size in bytes.
768
+
769
+ Returns:
770
+ True if the session needs to be updated, False otherwise.
771
+ """
772
+ with self._get_connection() as conn:
773
+ cursor = conn.cursor()
774
+ # Check raw_sessions table (source of truth)
775
+ cursor.execute(
776
+ "SELECT source_file_mtime, source_file_size FROM raw_sessions WHERE session_id = ?",
777
+ (session_id,),
778
+ )
779
+ row = cursor.fetchone()
780
+
781
+ if row is None:
782
+ # Session doesn't exist
783
+ return True
784
+
785
+ stored_mtime = row[0]
786
+ stored_size = row[1]
787
+
788
+ # If stored values are NULL (migration case), session needs update
789
+ if stored_mtime is None or stored_size is None:
790
+ return True
791
+
792
+ # Compare with provided values
793
+ return stored_mtime != file_mtime or stored_size != file_size
794
+
795
+ def needs_update_by_file(self, source_file: str, file_mtime: float, file_size: int) -> bool:
796
+ """Check if a file needs to be parsed based on its metadata.
797
+
798
+ This is more efficient than needs_update() as it doesn't require
799
+ parsing the file first to get the session_id.
800
+
801
+ Returns True if:
802
+ - No session exists with this source_file, OR
803
+ - Stored mtime/size differs from provided values
804
+
805
+ Args:
806
+ source_file: The path to the source file.
807
+ file_mtime: The current file modification time.
808
+ file_size: The current file size in bytes.
809
+
810
+ Returns:
811
+ True if the file needs to be parsed, False otherwise.
812
+ """
813
+ with self._get_connection() as conn:
814
+ cursor = conn.cursor()
815
+ cursor.execute(
816
+ "SELECT source_file_mtime, source_file_size FROM raw_sessions WHERE source_file = ?",
817
+ (source_file,),
818
+ )
819
+ row = cursor.fetchone()
820
+
821
+ if row is None:
822
+ return True
823
+
824
+ stored_mtime, stored_size = row[0], row[1]
825
+ if stored_mtime is None or stored_size is None:
826
+ return True
827
+
828
+ return stored_mtime != file_mtime or stored_size != file_size
829
+
830
+ def get_all_file_metadata(self) -> dict[str, tuple[float, int]]:
831
+ """Get all stored file metadata in one query.
832
+
833
+ Returns a dict mapping source_file -> (mtime, size) for all sessions.
834
+ This is much faster than calling needs_update_by_file for each file.
835
+ """
836
+ with self._get_connection() as conn:
837
+ cursor = conn.cursor()
838
+ cursor.execute("SELECT source_file, source_file_mtime, source_file_size FROM raw_sessions WHERE source_file IS NOT NULL")
839
+ return {row[0]: (row[1], row[2]) for row in cursor.fetchall()}
840
+
841
+ def _reconstruct_message(self, cursor, message_id: int, msg_row) -> ChatMessage:
842
+ """Reconstruct a ChatMessage from database rows by querying related tables."""
843
+ # Query tool_invocations for this message
844
+ cursor.execute("SELECT * FROM tool_invocations WHERE message_id = ?", (message_id,))
845
+ tool_invocations = []
846
+ for t in cursor.fetchall():
847
+ t_keys = t.keys()
848
+ tool_invocations.append(
849
+ ToolInvocation(
850
+ name=t["name"],
851
+ input=t["input"],
852
+ result=t["result"],
853
+ status=t["status"],
854
+ start_time=t["start_time"],
855
+ end_time=t["end_time"],
856
+ source_type=t["source_type"] if "source_type" in t_keys else None,
857
+ invocation_message=t["invocation_message"] if "invocation_message" in t_keys else None,
858
+ )
859
+ )
860
+
861
+ # Query file_changes
862
+ cursor.execute("SELECT * FROM file_changes WHERE message_id = ?", (message_id,))
863
+ file_changes = [
864
+ FileChange(
865
+ path=f["path"],
866
+ diff=f["diff"],
867
+ content=f["content"],
868
+ explanation=f["explanation"],
869
+ language_id=f["language_id"],
870
+ )
871
+ for f in cursor.fetchall()
872
+ ]
873
+
874
+ # Query command_runs
875
+ cursor.execute("SELECT * FROM command_runs WHERE message_id = ?", (message_id,))
876
+ command_runs = [
877
+ CommandRun(
878
+ command=c["command"],
879
+ title=c["title"],
880
+ result=c["result"],
881
+ status=c["status"],
882
+ output=c["output"],
883
+ timestamp=c["timestamp"],
884
+ )
885
+ for c in cursor.fetchall()
886
+ ]
887
+
888
+ # Query content_blocks
889
+ cursor.execute("SELECT * FROM content_blocks WHERE message_id = ? ORDER BY block_index", (message_id,))
890
+ content_blocks = [
891
+ ContentBlock(
892
+ kind=b["kind"],
893
+ content=b["content"],
894
+ description=b["description"] if "description" in b.keys() else None, # noqa: SIM118
895
+ )
896
+ for b in cursor.fetchall()
897
+ ]
898
+
899
+ # Get cached_markdown safely
900
+ cached_md = msg_row["cached_markdown"] if "cached_markdown" in msg_row.keys() else None # noqa: SIM118
901
+
902
+ return ChatMessage(
903
+ role=msg_row["role"],
904
+ content=msg_row["content"],
905
+ timestamp=msg_row["timestamp"],
906
+ tool_invocations=tool_invocations,
907
+ file_changes=file_changes,
908
+ command_runs=command_runs,
909
+ content_blocks=content_blocks,
910
+ cached_markdown=cached_md,
911
+ )
912
+
913
+ def get_session(self, session_id: str) -> ChatSession | None:
914
+ """Get a session by its ID.
915
+
916
+ Args:
917
+ session_id: The session ID to look up.
918
+
919
+ Returns:
920
+ ChatSession if found, None otherwise.
921
+ """
922
+ with self._get_connection() as conn:
923
+ cursor = conn.cursor()
924
+
925
+ cursor.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,))
926
+ row = cursor.fetchone()
927
+ if not row:
928
+ return None
929
+
930
+ # Get messages with their IDs for fetching related data
931
+ cursor.execute(
932
+ """
933
+ SELECT id, role, content, timestamp, cached_markdown
934
+ FROM messages
935
+ WHERE session_id = ?
936
+ ORDER BY message_index
937
+ """,
938
+ (session_id,),
939
+ )
940
+ message_rows = cursor.fetchall()
941
+
942
+ messages = []
943
+ for msg_row in message_rows:
944
+ message_id = msg_row["id"]
945
+ messages.append(self._reconstruct_message(cursor, message_id, msg_row))
946
+
947
+ # Helper to safely get optional fields from sqlite3.Row
948
+ def safe_get(key):
949
+ try:
950
+ return row[key]
951
+ except (IndexError, KeyError):
952
+ return None
953
+
954
+ return ChatSession(
955
+ session_id=row["session_id"],
956
+ workspace_name=row["workspace_name"],
957
+ workspace_path=row["workspace_path"],
958
+ messages=messages,
959
+ created_at=row["created_at"],
960
+ updated_at=row["updated_at"],
961
+ source_file=row["source_file"],
962
+ vscode_edition=row["vscode_edition"],
963
+ custom_title=safe_get("custom_title"),
964
+ requester_username=safe_get("requester_username"),
965
+ responder_username=safe_get("responder_username"),
966
+ source_file_mtime=safe_get("source_file_mtime"),
967
+ source_file_size=safe_get("source_file_size"),
968
+ type=safe_get("type") or "vscode",
969
+ repository_url=safe_get("repository_url"),
970
+ )
971
+
972
+ def get_messages_markdown(
973
+ self,
974
+ session_id: str,
975
+ start: int | None = None,
976
+ end: int | None = None,
977
+ include_diffs: bool = True,
978
+ include_tool_inputs: bool = True,
979
+ include_thinking: bool = False,
980
+ ) -> str:
981
+ """Get markdown for specific messages or all messages in a session.
982
+
983
+ Args:
984
+ session_id: The session ID to get messages from.
985
+ start: Optional 1-based start message index (inclusive).
986
+ end: Optional 1-based end message index (inclusive).
987
+ include_diffs: Whether to include file diffs in the markdown.
988
+ include_tool_inputs: Whether to include tool inputs in the markdown.
989
+ include_thinking: Whether to include thinking/reasoning blocks.
990
+
991
+ Returns:
992
+ Combined markdown string for the selected messages.
993
+ """
994
+ from .markdown_exporter import message_to_markdown
995
+
996
+ with self._get_connection() as conn:
997
+ cursor = conn.cursor()
998
+
999
+ # Build query based on range
1000
+ if start is not None or end is not None:
1001
+ # Convert to 0-based indices
1002
+ start_idx = (start - 1) if start else 0
1003
+ end_idx = (end - 1) if end else 999999 # Large number for "no limit"
1004
+
1005
+ cursor.execute(
1006
+ """
1007
+ SELECT id, role, content, timestamp, cached_markdown, message_index
1008
+ FROM messages
1009
+ WHERE session_id = ? AND message_index >= ? AND message_index <= ?
1010
+ ORDER BY message_index
1011
+ """,
1012
+ (session_id, start_idx, end_idx),
1013
+ )
1014
+ else:
1015
+ cursor.execute(
1016
+ """
1017
+ SELECT id, role, content, timestamp, cached_markdown, message_index
1018
+ FROM messages
1019
+ WHERE session_id = ?
1020
+ ORDER BY message_index
1021
+ """,
1022
+ (session_id,),
1023
+ )
1024
+
1025
+ rows = cursor.fetchall()
1026
+ markdown_parts = []
1027
+
1028
+ # If both options are enabled, use cached markdown
1029
+ if include_diffs and include_tool_inputs and not include_thinking:
1030
+ for row in rows:
1031
+ md = row["cached_markdown"]
1032
+ if md:
1033
+ markdown_parts.append(md)
1034
+ else:
1035
+ # Need to regenerate markdown with specific options
1036
+ for row in rows:
1037
+ message_id = row["id"]
1038
+ message_index = row["message_index"] + 1 # Convert to 1-based
1039
+
1040
+ # Create message object
1041
+ message = self._reconstruct_message(cursor, message_id, row)
1042
+
1043
+ # Generate markdown with specified options
1044
+ md = message_to_markdown(
1045
+ message,
1046
+ message_number=message_index,
1047
+ include_diffs=include_diffs,
1048
+ include_tool_inputs=include_tool_inputs,
1049
+ include_thinking=include_thinking,
1050
+ )
1051
+ markdown_parts.append(md)
1052
+
1053
+ return "\n".join(markdown_parts)
1054
+
1055
+ def list_sessions(
1056
+ self,
1057
+ workspace_name: str | None = None,
1058
+ limit: int | None = None,
1059
+ offset: int = 0,
1060
+ ) -> list[dict]:
1061
+ """List sessions with optional filtering.
1062
+
1063
+ Args:
1064
+ workspace_name: Optional workspace name filter.
1065
+ limit: Maximum number of sessions to return.
1066
+ offset: Number of sessions to skip.
1067
+
1068
+ Returns:
1069
+ List of session info dictionaries.
1070
+ """
1071
+ with self._get_connection() as conn:
1072
+ cursor = conn.cursor()
1073
+
1074
+ query = """
1075
+ SELECT
1076
+ s.session_id,
1077
+ s.workspace_name,
1078
+ s.workspace_path,
1079
+ s.created_at,
1080
+ s.updated_at,
1081
+ s.vscode_edition,
1082
+ s.custom_title,
1083
+ s.repository_url,
1084
+ COUNT(m.id) as message_count,
1085
+ MAX(m.timestamp) as last_message_at,
1086
+ (SELECT content FROM messages m2
1087
+ WHERE m2.session_id = s.session_id AND m2.role = 'user'
1088
+ ORDER BY m2.message_index LIMIT 1) as first_user_prompt
1089
+ FROM sessions s
1090
+ LEFT JOIN messages m ON s.session_id = m.session_id
1091
+ """
1092
+ params = []
1093
+
1094
+ if workspace_name:
1095
+ query += " WHERE s.workspace_name = ?"
1096
+ params.append(workspace_name)
1097
+
1098
+ query += " GROUP BY s.session_id ORDER BY last_message_at DESC, s.created_at DESC"
1099
+
1100
+ if limit:
1101
+ query += " LIMIT ? OFFSET ?"
1102
+ params.extend([limit, offset])
1103
+
1104
+ cursor.execute(query, params)
1105
+ return [dict(row) for row in cursor.fetchall()]
1106
+
1107
+ def search(
1108
+ self,
1109
+ query: str,
1110
+ limit: int = 50,
1111
+ skip: int = 0,
1112
+ role: str | None = None,
1113
+ include_messages: bool = True,
1114
+ include_tool_calls: bool = True,
1115
+ include_file_changes: bool = True,
1116
+ session_title: str | None = None,
1117
+ sort_by: str = "relevance",
1118
+ repository: str | None = None,
1119
+ start_date: str | None = None,
1120
+ end_date: str | None = None,
1121
+ ) -> list[dict]:
1122
+ """Search messages using full-text search with field filtering.
1123
+
1124
+ Supports advanced query syntax:
1125
+ - Multiple words: "python function" → matches both words (AND logic)
1126
+ - Exact phrases: '"python function"' → matches exact phrase
1127
+ - Field prefixes: 'role:user', 'workspace:myproject', 'title:something', 'repository:github.com/owner/repo'
1128
+ - Date filters: 'start_date:2024-01-01 end_date:2024-12-31' (yyyy-mm-dd format, inclusive)
1129
+
1130
+ Args:
1131
+ query: The search query (supports field prefixes and quoted phrases).
1132
+ limit: Maximum number of results to return (top).
1133
+ skip: Number of results to skip (for pagination).
1134
+ role: Filter by message role ('user', 'assistant', or None for both).
1135
+ Can also be specified in query as 'role:user' or 'role:assistant'.
1136
+ include_messages: Whether to search message content.
1137
+ include_tool_calls: Whether to also search tool invocations.
1138
+ include_file_changes: Whether to also search file changes.
1139
+ session_title: Filter by session title/workspace name.
1140
+ Can also be specified in query as 'title:...' or 'workspace:...'.
1141
+ sort_by: Sort order - 'relevance' (default) or 'date'.
1142
+ repository: Filter by repository URL.
1143
+ Can also be specified in query as 'repository:...' or 'repo:...'.
1144
+ start_date: Filter results on or after this date (yyyy-mm-dd format, inclusive).
1145
+ Can also be specified in query as 'start_date:yyyy-mm-dd'.
1146
+ end_date: Filter results on or before this date (yyyy-mm-dd format, inclusive).
1147
+ Can also be specified in query as 'end_date:yyyy-mm-dd'.
1148
+
1149
+ Returns:
1150
+ List of matching messages with session info.
1151
+ """
1152
+ results = []
1153
+
1154
+ # Parse the query to extract field filters and convert to FTS5 format
1155
+ parsed = parse_search_query(query)
1156
+
1157
+ # Use parsed field filters, with explicit parameters taking precedence
1158
+ effective_role = role if role else parsed.role
1159
+ effective_title = session_title if session_title else parsed.title
1160
+ effective_workspace = parsed.workspace # Only from query parsing
1161
+ effective_repository = repository if repository else parsed.repository
1162
+ effective_edition = parsed.edition # Only from query parsing
1163
+ effective_start_date = start_date if start_date else parsed.start_date
1164
+ effective_end_date = end_date if end_date else parsed.end_date
1165
+
1166
+ # If no FTS query after parsing, we can't do FTS search
1167
+ # But we might still have field filters to apply
1168
+ fts_query = parsed.fts_query
1169
+
1170
+ # Check if we have any filters to apply (even without FTS query)
1171
+ has_filters = effective_role or effective_title or effective_workspace or effective_repository or effective_edition or effective_start_date or effective_end_date
1172
+
1173
+ # Get the safe order clause from whitelist (defaults to relevance)
1174
+ order_clause = _SORT_ORDER_CLAUSES.get(sort_by, _SORT_ORDER_CLAUSES["relevance"])
1175
+
1176
+ with self._get_connection() as conn:
1177
+ cursor = conn.cursor()
1178
+
1179
+ # Search messages (only if include_messages is True)
1180
+ if include_messages:
1181
+ if fts_query:
1182
+ # FTS search with optional filters
1183
+ message_query = """
1184
+ SELECT
1185
+ m.id,
1186
+ m.session_id,
1187
+ m.message_index,
1188
+ m.role,
1189
+ m.content,
1190
+ s.workspace_name,
1191
+ s.custom_title,
1192
+ s.created_at,
1193
+ s.vscode_edition,
1194
+ highlight(messages_fts, 0, '<mark>', '</mark>') as highlighted,
1195
+ 'message' as match_type,
1196
+ rank
1197
+ FROM messages_fts
1198
+ JOIN messages m ON messages_fts.rowid = m.id
1199
+ JOIN sessions s ON m.session_id = s.session_id
1200
+ WHERE messages_fts MATCH ?
1201
+ """
1202
+ params = [fts_query]
1203
+
1204
+ if effective_role:
1205
+ message_query += " AND m.role = ?"
1206
+ params.append(effective_role)
1207
+
1208
+ if effective_title:
1209
+ message_query += " AND (s.workspace_name LIKE ? OR s.custom_title LIKE ?)"
1210
+ params.extend([f"%{effective_title}%", f"%{effective_title}%"])
1211
+
1212
+ if effective_workspace:
1213
+ message_query += " AND s.workspace_name LIKE ?"
1214
+ params.append(f"%{effective_workspace}%")
1215
+
1216
+ if effective_repository:
1217
+ message_query += " AND s.repository_url LIKE ?"
1218
+ params.append(f"%{effective_repository}%")
1219
+
1220
+ if effective_edition:
1221
+ message_query += " AND s.vscode_edition = ?"
1222
+ params.append(effective_edition)
1223
+
1224
+ # Add date filters
1225
+ date_clause, date_params = _build_date_filter_clause(effective_start_date, effective_end_date, "s.created_at")
1226
+ if date_clause:
1227
+ message_query += f" AND {date_clause}"
1228
+ params.extend(date_params)
1229
+
1230
+ # Note: order_clause is safe because it comes from _SORT_ORDER_CLAUSES whitelist
1231
+
1232
+ message_query += f" {order_clause} LIMIT ? OFFSET ?"
1233
+ params.extend([limit, skip])
1234
+
1235
+ cursor.execute(message_query, params)
1236
+ results.extend([dict(row) for row in cursor.fetchall()])
1237
+
1238
+ elif has_filters:
1239
+ # Filter-only query (no FTS, but with field filters)
1240
+ message_query = """
1241
+ SELECT
1242
+ m.id,
1243
+ m.session_id,
1244
+ m.message_index,
1245
+ m.role,
1246
+ m.content,
1247
+ s.workspace_name,
1248
+ s.custom_title,
1249
+ s.created_at,
1250
+ s.vscode_edition,
1251
+ m.content as highlighted,
1252
+ 'message' as match_type
1253
+ FROM messages m
1254
+ JOIN sessions s ON m.session_id = s.session_id
1255
+ WHERE 1=1
1256
+ """
1257
+ params = []
1258
+
1259
+ if effective_role:
1260
+ message_query += " AND m.role = ?"
1261
+ params.append(effective_role)
1262
+
1263
+ if effective_title:
1264
+ message_query += " AND (s.workspace_name LIKE ? OR s.custom_title LIKE ?)"
1265
+ params.extend([f"%{effective_title}%", f"%{effective_title}%"])
1266
+
1267
+ if effective_workspace:
1268
+ message_query += " AND s.workspace_name LIKE ?"
1269
+ params.append(f"%{effective_workspace}%")
1270
+
1271
+ if effective_repository:
1272
+ message_query += " AND s.repository_url LIKE ?"
1273
+ params.append(f"%{effective_repository}%")
1274
+
1275
+ if effective_edition:
1276
+ message_query += " AND s.vscode_edition = ?"
1277
+ params.append(effective_edition)
1278
+
1279
+ # Add date filters
1280
+ date_clause, date_params = _build_date_filter_clause(effective_start_date, effective_end_date, "s.created_at")
1281
+ if date_clause:
1282
+ message_query += f" AND {date_clause}"
1283
+ params.extend(date_params)
1284
+
1285
+ message_query += " ORDER BY s.created_at DESC LIMIT ? OFFSET ?"
1286
+ params.extend([limit, skip])
1287
+
1288
+ cursor.execute(message_query, params)
1289
+ results.extend([dict(row) for row in cursor.fetchall()])
1290
+
1291
+ # Search tool invocations
1292
+ # For tool/file searches, we use the original query terms for LIKE matching
1293
+ search_terms = fts_query if fts_query else query
1294
+ if include_tool_calls and len(results) < limit and search_terms:
1295
+ remaining = limit - len(results)
1296
+ tool_query = """
1297
+ SELECT
1298
+ t.id,
1299
+ m.session_id,
1300
+ 'assistant' as role,
1301
+ t.name || ': ' || COALESCE(t.input, '') || ' -> ' || COALESCE(t.result, '') as content,
1302
+ s.workspace_name,
1303
+ s.custom_title,
1304
+ s.created_at,
1305
+ s.vscode_edition,
1306
+ t.name || ': ' || COALESCE(t.input, '') as highlighted,
1307
+ 'tool_invocation' as match_type
1308
+ FROM tool_invocations t
1309
+ JOIN messages m ON t.message_id = m.id
1310
+ JOIN sessions s ON m.session_id = s.session_id
1311
+ WHERE (t.name LIKE ? OR t.input LIKE ? OR t.result LIKE ?)
1312
+ """
1313
+ params = [f"%{search_terms}%", f"%{search_terms}%", f"%{search_terms}%"]
1314
+
1315
+ if effective_workspace:
1316
+ tool_query += " AND s.workspace_name LIKE ?"
1317
+ params.append(f"%{effective_workspace}%")
1318
+
1319
+ if effective_title:
1320
+ tool_query += " AND (s.workspace_name LIKE ? OR s.custom_title LIKE ?)"
1321
+ params.extend([f"%{effective_title}%", f"%{effective_title}%"])
1322
+
1323
+ if effective_repository:
1324
+ tool_query += " AND s.repository_url LIKE ?"
1325
+ params.append(f"%{effective_repository}%")
1326
+
1327
+ if effective_edition:
1328
+ tool_query += " AND s.vscode_edition = ?"
1329
+ params.append(effective_edition)
1330
+
1331
+ # Add date filters
1332
+ date_clause, date_params = _build_date_filter_clause(effective_start_date, effective_end_date, "s.created_at")
1333
+ if date_clause:
1334
+ tool_query += f" AND {date_clause}"
1335
+ params.extend(date_params)
1336
+
1337
+ tool_query += " LIMIT ?"
1338
+ params.append(remaining)
1339
+
1340
+ cursor.execute(tool_query, params)
1341
+ results.extend([dict(row) for row in cursor.fetchall()])
1342
+
1343
+ # Search file changes
1344
+ if include_file_changes and len(results) < limit and search_terms:
1345
+ remaining = limit - len(results)
1346
+ file_query = """
1347
+ SELECT
1348
+ f.id,
1349
+ m.session_id,
1350
+ 'assistant' as role,
1351
+ f.path || ': ' || COALESCE(f.explanation, '') as content,
1352
+ s.workspace_name,
1353
+ s.custom_title,
1354
+ s.created_at,
1355
+ s.vscode_edition,
1356
+ f.path as highlighted,
1357
+ 'file_change' as match_type
1358
+ FROM file_changes f
1359
+ JOIN messages m ON f.message_id = m.id
1360
+ JOIN sessions s ON m.session_id = s.session_id
1361
+ WHERE (f.path LIKE ? OR f.explanation LIKE ? OR f.diff LIKE ?)
1362
+ """
1363
+ params = [f"%{search_terms}%", f"%{search_terms}%", f"%{search_terms}%"]
1364
+
1365
+ if effective_workspace:
1366
+ file_query += " AND s.workspace_name LIKE ?"
1367
+ params.append(f"%{effective_workspace}%")
1368
+
1369
+ if effective_title:
1370
+ file_query += " AND (s.workspace_name LIKE ? OR s.custom_title LIKE ?)"
1371
+ params.extend([f"%{effective_title}%", f"%{effective_title}%"])
1372
+
1373
+ if effective_repository:
1374
+ file_query += " AND s.repository_url LIKE ?"
1375
+ params.append(f"%{effective_repository}%")
1376
+
1377
+ if effective_edition:
1378
+ file_query += " AND s.vscode_edition = ?"
1379
+ params.append(effective_edition)
1380
+
1381
+ # Add date filters
1382
+ date_clause, date_params = _build_date_filter_clause(effective_start_date, effective_end_date, "s.created_at")
1383
+ if date_clause:
1384
+ file_query += f" AND {date_clause}"
1385
+ params.extend(date_params)
1386
+
1387
+ file_query += " LIMIT ?"
1388
+ params.append(remaining)
1389
+
1390
+ cursor.execute(file_query, params)
1391
+ results.extend([dict(row) for row in cursor.fetchall()])
1392
+
1393
+ return results[:limit]
1394
+
1395
+ def get_workspaces(self) -> list[dict]:
1396
+ """Get all unique workspaces.
1397
+
1398
+ Returns:
1399
+ List of workspace info dictionaries.
1400
+ """
1401
+ with self._get_connection() as conn:
1402
+ cursor = conn.cursor()
1403
+ cursor.execute(
1404
+ """
1405
+ SELECT
1406
+ workspace_name,
1407
+ workspace_path,
1408
+ COUNT(*) as session_count,
1409
+ MAX(created_at) as last_activity
1410
+ FROM sessions
1411
+ WHERE workspace_name IS NOT NULL
1412
+ GROUP BY workspace_name, workspace_path
1413
+ ORDER BY last_activity DESC
1414
+ """
1415
+ )
1416
+ return [dict(row) for row in cursor.fetchall()]
1417
+
1418
+ def get_repositories(self) -> list[dict]:
1419
+ """Get all unique repositories.
1420
+
1421
+ Returns:
1422
+ List of repository info dictionaries.
1423
+ """
1424
+ with self._get_connection() as conn:
1425
+ cursor = conn.cursor()
1426
+ cursor.execute(
1427
+ """
1428
+ SELECT
1429
+ repository_url,
1430
+ COUNT(*) as session_count,
1431
+ MAX(created_at) as last_activity
1432
+ FROM sessions
1433
+ WHERE repository_url IS NOT NULL
1434
+ GROUP BY repository_url
1435
+ ORDER BY last_activity DESC
1436
+ """
1437
+ )
1438
+ return [dict(row) for row in cursor.fetchall()]
1439
+
1440
+ def get_stats(self) -> dict:
1441
+ """Get database statistics.
1442
+
1443
+ Returns:
1444
+ Dictionary with stats.
1445
+ """
1446
+ with self._get_connection() as conn:
1447
+ cursor = conn.cursor()
1448
+
1449
+ cursor.execute("SELECT COUNT(*) FROM sessions")
1450
+ session_count = cursor.fetchone()[0]
1451
+
1452
+ cursor.execute("SELECT COUNT(*) FROM messages")
1453
+ message_count = cursor.fetchone()[0]
1454
+
1455
+ cursor.execute("SELECT COUNT(DISTINCT workspace_name) FROM sessions")
1456
+ workspace_count = cursor.fetchone()[0]
1457
+
1458
+ cursor.execute("SELECT vscode_edition, COUNT(*) FROM sessions GROUP BY vscode_edition")
1459
+ editions = dict(cursor.fetchall())
1460
+
1461
+ return {
1462
+ "session_count": session_count,
1463
+ "message_count": message_count,
1464
+ "workspace_count": workspace_count,
1465
+ "editions": editions,
1466
+ }
1467
+
1468
+ def export_json(self) -> str:
1469
+ """Export all data as JSON.
1470
+
1471
+ Returns:
1472
+ JSON string with all sessions and messages.
1473
+ """
1474
+ sessions = []
1475
+ for session_info in self.list_sessions():
1476
+ session = self.get_session(session_info["session_id"])
1477
+ if session:
1478
+ sessions.append(
1479
+ {
1480
+ "session_id": session.session_id,
1481
+ "workspace_name": session.workspace_name,
1482
+ "workspace_path": session.workspace_path,
1483
+ "created_at": session.created_at,
1484
+ "updated_at": session.updated_at,
1485
+ "vscode_edition": session.vscode_edition,
1486
+ "messages": [
1487
+ {
1488
+ "role": msg.role,
1489
+ "content": msg.content,
1490
+ "timestamp": msg.timestamp,
1491
+ }
1492
+ for msg in session.messages
1493
+ ],
1494
+ }
1495
+ )
1496
+ return json.dumps(sessions, indent=2)
1497
+
1498
+ def rebuild_derived_tables(self, progress_callback=None) -> dict:
1499
+ """Drop and recreate all derived tables from raw_sessions.
1500
+
1501
+ This method allows the schema to evolve without migrations - simply
1502
+ drop the derived tables and rebuild them from the raw JSON source.
1503
+
1504
+ Args:
1505
+ progress_callback: Optional callable that receives (processed, total) counts.
1506
+
1507
+ Returns:
1508
+ Dictionary with rebuild statistics.
1509
+ """
1510
+ with self._get_connection() as conn:
1511
+ cursor = conn.cursor()
1512
+
1513
+ # Disable foreign keys temporarily for dropping tables
1514
+ conn.execute("PRAGMA foreign_keys = OFF")
1515
+
1516
+ # Drop derived tables in order (FTS first, then dependent tables)
1517
+ # Note: DERIVED_TABLES is a class constant with hardcoded table names,
1518
+ # so f-string usage is safe. Validation is additional defense-in-depth.
1519
+ for table in self.DERIVED_TABLES:
1520
+ # Validate table name is alphanumeric with underscores only
1521
+ if not all(c.isalnum() or c == "_" for c in table):
1522
+ raise ValueError(f"Invalid table name: {table}")
1523
+ with contextlib.suppress(sqlite3.OperationalError):
1524
+ # FTS tables might need special handling
1525
+ cursor.execute(f"DROP TABLE IF EXISTS {table}")
1526
+
1527
+ # Drop triggers (validated against DERIVED_TRIGGERS list)
1528
+ # Note: DERIVED_TRIGGERS is a class constant with hardcoded trigger names,
1529
+ # so f-string usage is safe. Validation is additional defense-in-depth.
1530
+ for trigger in self.DERIVED_TRIGGERS:
1531
+ # Validate trigger name is alphanumeric with underscores only
1532
+ if not all(c.isalnum() or c == "_" for c in trigger):
1533
+ raise ValueError(f"Invalid trigger name: {trigger}")
1534
+ cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
1535
+
1536
+ conn.commit()
1537
+
1538
+ # Recreate derived tables schema
1539
+ conn.executescript(self.DERIVED_SCHEMA)
1540
+ conn.execute("PRAGMA foreign_keys = ON")
1541
+
1542
+ # Count total raw sessions
1543
+ cursor.execute("SELECT COUNT(*) FROM raw_sessions")
1544
+ total_count = cursor.fetchone()[0]
1545
+
1546
+ # Rebuild from raw_sessions
1547
+ cursor.execute("""
1548
+ SELECT session_id, raw_json_compressed, workspace_name, workspace_path,
1549
+ source_file, vscode_edition, source_file_mtime, source_file_size
1550
+ FROM raw_sessions
1551
+ """)
1552
+
1553
+ processed = 0
1554
+ errors = 0
1555
+
1556
+ for row in cursor.fetchall():
1557
+ try:
1558
+ _session_id = row[0] # Unused but kept for reference
1559
+ compressed_json = row[1]
1560
+ workspace_name = row[2]
1561
+ workspace_path = row[3]
1562
+ source_file = row[4]
1563
+ vscode_edition = row[5]
1564
+ source_file_mtime = row[6]
1565
+ source_file_size = row[7]
1566
+
1567
+ # Get raw JSON: try source file first, then database
1568
+ raw_json = None
1569
+ if source_file:
1570
+ try:
1571
+ source_path = Path(source_file)
1572
+ if source_path.exists() and source_path.is_file():
1573
+ raw_json = source_path.read_bytes()
1574
+ except (OSError, PermissionError):
1575
+ pass
1576
+
1577
+ if raw_json is None and compressed_json is not None:
1578
+ raw_json = zlib.decompress(compressed_json)
1579
+
1580
+ if raw_json is None:
1581
+ # Cannot rebuild this session - no source available
1582
+ errors += 1
1583
+ processed += 1
1584
+ continue
1585
+
1586
+ data = orjson.loads(raw_json)
1587
+
1588
+ # Re-parse session from raw JSON
1589
+ session = _extract_session_from_dict(
1590
+ data,
1591
+ workspace_name=workspace_name,
1592
+ workspace_path=workspace_path,
1593
+ edition=vscode_edition,
1594
+ source_file=source_file,
1595
+ raw_json=raw_json, # Keep raw JSON for consistency
1596
+ )
1597
+
1598
+ if session:
1599
+ # Override metadata from raw_sessions table
1600
+ session.source_file_mtime = source_file_mtime
1601
+ session.source_file_size = source_file_size
1602
+
1603
+ # Insert into derived tables only (not raw_sessions)
1604
+ self._insert_derived_session(conn, session)
1605
+
1606
+ processed += 1
1607
+
1608
+ if progress_callback:
1609
+ progress_callback(processed, total_count)
1610
+
1611
+ except (zlib.error, orjson.JSONDecodeError, KeyError, TypeError):
1612
+ # Log error for debugging, but continue processing other sessions
1613
+ # These errors can occur when raw JSON is malformed or cannot be parsed
1614
+ errors += 1
1615
+ processed += 1
1616
+
1617
+ conn.commit()
1618
+
1619
+ return {
1620
+ "total": total_count,
1621
+ "processed": processed,
1622
+ "errors": errors,
1623
+ }
1624
+
1625
+ def _insert_derived_session(self, conn, session: ChatSession):
1626
+ """Insert a session into derived tables only (not raw_sessions).
1627
+
1628
+ This is an internal method used by rebuild_derived_tables.
1629
+ """
1630
+ cursor = conn.cursor()
1631
+
1632
+ # Insert into sessions table
1633
+ cursor.execute(
1634
+ """
1635
+ INSERT INTO sessions
1636
+ (session_id, workspace_name, workspace_path, created_at, updated_at,
1637
+ source_file, vscode_edition, custom_title, requester_username, responder_username,
1638
+ source_file_mtime, source_file_size, type, repository_url)
1639
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1640
+ """,
1641
+ (
1642
+ session.session_id,
1643
+ session.workspace_name,
1644
+ session.workspace_path,
1645
+ session.created_at,
1646
+ session.updated_at,
1647
+ session.source_file,
1648
+ session.vscode_edition,
1649
+ session.custom_title,
1650
+ session.requester_username,
1651
+ session.responder_username,
1652
+ session.source_file_mtime,
1653
+ session.source_file_size,
1654
+ session.type,
1655
+ session.repository_url,
1656
+ ),
1657
+ )
1658
+
1659
+ # Insert messages and associated data
1660
+ for idx, msg in enumerate(session.messages):
1661
+ # Generate cached markdown for this message
1662
+ cached_md = message_to_markdown(
1663
+ msg,
1664
+ message_number=idx + 1,
1665
+ include_diffs=True,
1666
+ include_tool_inputs=True,
1667
+ )
1668
+
1669
+ cursor.execute(
1670
+ """
1671
+ INSERT INTO messages
1672
+ (session_id, message_index, role, content, timestamp, cached_markdown)
1673
+ VALUES (?, ?, ?, ?, ?, ?)
1674
+ """,
1675
+ (
1676
+ session.session_id,
1677
+ idx,
1678
+ msg.role,
1679
+ msg.content,
1680
+ msg.timestamp,
1681
+ cached_md,
1682
+ ),
1683
+ )
1684
+ message_id = cursor.lastrowid
1685
+
1686
+ # Insert tool invocations
1687
+ for tool in msg.tool_invocations:
1688
+ cursor.execute(
1689
+ """
1690
+ INSERT INTO tool_invocations
1691
+ (message_id, name, input, result, status, start_time, end_time, source_type, invocation_message)
1692
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1693
+ """,
1694
+ (
1695
+ message_id,
1696
+ tool.name,
1697
+ tool.input,
1698
+ tool.result,
1699
+ tool.status,
1700
+ tool.start_time,
1701
+ tool.end_time,
1702
+ tool.source_type,
1703
+ tool.invocation_message,
1704
+ ),
1705
+ )
1706
+
1707
+ # Insert file changes
1708
+ for change in msg.file_changes:
1709
+ cursor.execute(
1710
+ """
1711
+ INSERT INTO file_changes
1712
+ (message_id, path, diff, content, explanation, language_id)
1713
+ VALUES (?, ?, ?, ?, ?, ?)
1714
+ """,
1715
+ (
1716
+ message_id,
1717
+ change.path,
1718
+ change.diff,
1719
+ change.content,
1720
+ change.explanation,
1721
+ change.language_id,
1722
+ ),
1723
+ )
1724
+
1725
+ # Insert command runs
1726
+ for cmd in msg.command_runs:
1727
+ cursor.execute(
1728
+ """
1729
+ INSERT INTO command_runs
1730
+ (message_id, command, title, result, status, output, timestamp)
1731
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1732
+ """,
1733
+ (
1734
+ message_id,
1735
+ cmd.command,
1736
+ cmd.title,
1737
+ cmd.result,
1738
+ cmd.status,
1739
+ cmd.output,
1740
+ cmd.timestamp,
1741
+ ),
1742
+ )
1743
+
1744
+ # Insert content blocks
1745
+ for block_idx, block in enumerate(msg.content_blocks):
1746
+ cursor.execute(
1747
+ """
1748
+ INSERT INTO content_blocks
1749
+ (message_id, block_index, kind, content, description)
1750
+ VALUES (?, ?, ?, ?, ?)
1751
+ """,
1752
+ (
1753
+ message_id,
1754
+ block_idx,
1755
+ block.kind,
1756
+ block.content,
1757
+ block.description,
1758
+ ),
1759
+ )
1760
+
1761
+ def get_raw_session_count(self) -> int:
1762
+ """Get the count of raw sessions stored in the database.
1763
+
1764
+ Returns:
1765
+ Number of sessions in raw_sessions table.
1766
+ """
1767
+ with self._get_connection() as conn:
1768
+ cursor = conn.cursor()
1769
+ cursor.execute("SELECT COUNT(*) FROM raw_sessions")
1770
+ return cursor.fetchone()[0]
1771
+
1772
+ def get_raw_json(self, session_id: str, prefer_file: bool = True) -> bytes | None:
1773
+ """Get the raw JSON for a specific session.
1774
+
1775
+ By default, tries to read from the original source file first (if it exists
1776
+ and is accessible), falling back to the compressed database copy. Set
1777
+ prefer_file=False to always use the database copy.
1778
+
1779
+ Args:
1780
+ session_id: The session ID to retrieve.
1781
+ prefer_file: If True (default), try reading from source file first.
1782
+ If False, always use database copy.
1783
+
1784
+ Returns:
1785
+ Raw JSON bytes if found, None otherwise.
1786
+ """
1787
+ with self._get_connection() as conn:
1788
+ cursor = conn.cursor()
1789
+ cursor.execute(
1790
+ "SELECT raw_json_compressed, source_file FROM raw_sessions WHERE session_id = ?",
1791
+ (session_id,),
1792
+ )
1793
+ row = cursor.fetchone()
1794
+ if not row:
1795
+ return None
1796
+
1797
+ compressed_json = row[0]
1798
+ source_file = row[1]
1799
+
1800
+ # Try reading from original file first
1801
+ if prefer_file and source_file:
1802
+ try:
1803
+ source_path = Path(source_file)
1804
+ if source_path.exists() and source_path.is_file():
1805
+ return source_path.read_bytes()
1806
+ except (OSError, PermissionError):
1807
+ # Fall back to database copy on any file access error
1808
+ pass
1809
+
1810
+ # Fall back to database copy (if stored)
1811
+ if compressed_json is not None:
1812
+ return zlib.decompress(compressed_json)
1813
+
1814
+ return None
1815
+
1816
+ def optimize_fts(self) -> dict:
1817
+ """Optimize the FTS5 full-text search index for better query performance.
1818
+
1819
+ This merges FTS index segments, reducing fragmentation and improving
1820
+ search speed. Should be run periodically, especially after bulk imports.
1821
+
1822
+ Returns:
1823
+ Dictionary with optimization results including segment counts before/after.
1824
+ """
1825
+ with self._get_connection() as conn:
1826
+ cursor = conn.cursor()
1827
+
1828
+ # Get segment count before optimization
1829
+ cursor.execute("SELECT COUNT(*) FROM messages_fts_data")
1830
+ segments_before = cursor.fetchone()[0]
1831
+
1832
+ # Run FTS5 optimize command - merges all segments into one
1833
+ cursor.execute("INSERT INTO messages_fts(messages_fts) VALUES('optimize')")
1834
+
1835
+ # Get segment count after optimization
1836
+ cursor.execute("SELECT COUNT(*) FROM messages_fts_data")
1837
+ segments_after = cursor.fetchone()[0]
1838
+
1839
+ # Also run integrity check
1840
+ cursor.execute("INSERT INTO messages_fts(messages_fts) VALUES('integrity-check')")
1841
+
1842
+ conn.commit()
1843
+
1844
+ return {
1845
+ "segments_before": segments_before,
1846
+ "segments_after": segments_after,
1847
+ "optimized": True,
1848
+ }