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,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
|
+
}
|