agent-decoder 0.3.2__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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- clau_decode/watcher.py +63 -0
clau_decode/db.py
ADDED
|
@@ -0,0 +1,2054 @@
|
|
|
1
|
+
"""SQLite database layer — indexing, caching, FTS5 search.
|
|
2
|
+
|
|
3
|
+
Contract (for Agent 1 to implement):
|
|
4
|
+
Database class (async context manager):
|
|
5
|
+
- __aenter__ / __aexit__ manage the aiosqlite connection
|
|
6
|
+
- init_schema() — CREATE TABLE IF NOT EXISTS for all tables
|
|
7
|
+
- upsert_project(project: Project) -> None
|
|
8
|
+
- upsert_session(session: Session) -> None
|
|
9
|
+
- upsert_messages(messages: list[Message]) -> None — bulk upsert + FTS index
|
|
10
|
+
- get_projects() -> list[Project]
|
|
11
|
+
- get_sessions(project_id: str | None) -> list[Session]
|
|
12
|
+
- get_session_detail(session_id: str) -> SessionDetail | None
|
|
13
|
+
- search(query: str, project_id: str | None, limit: int) -> list[SearchHit]
|
|
14
|
+
- get_stats() -> StatsResponse
|
|
15
|
+
- get_session_mtime(session_id: str) -> float | None — for cache invalidation
|
|
16
|
+
|
|
17
|
+
Schema:
|
|
18
|
+
projects (id TEXT PK, display_name, raw_path, resolved_path, data_source, last_activity_at)
|
|
19
|
+
sessions (id TEXT PK, project_id, file_path, title, model, started_at, updated_at,
|
|
20
|
+
message_count, user_message_count, cwd, git_branch, is_worktree,
|
|
21
|
+
permission_mode, file_mtime REAL)
|
|
22
|
+
messages (id TEXT PK, session_id, parent_id, role, content_json, timestamp, model,
|
|
23
|
+
is_sidechain, is_meta, cwd, git_branch, source_tool_assistant_uuid,
|
|
24
|
+
usage_json TEXT)
|
|
25
|
+
messages_fts (FTS5 virtual: content, session_id UNINDEXED, message_id UNINDEXED, role UNINDEXED)
|
|
26
|
+
|
|
27
|
+
SOLID notes:
|
|
28
|
+
- Dependency Inversion: callers depend on this interface, not on aiosqlite directly
|
|
29
|
+
- All DB path logic lives here; callers pass a db_path: Path argument
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
import aiosqlite
|
|
41
|
+
|
|
42
|
+
from .models import (
|
|
43
|
+
ContentBlock,
|
|
44
|
+
ImageBlock,
|
|
45
|
+
Message,
|
|
46
|
+
Project,
|
|
47
|
+
SearchHit,
|
|
48
|
+
Session,
|
|
49
|
+
SessionDetail,
|
|
50
|
+
StatsResponse,
|
|
51
|
+
TextBlock,
|
|
52
|
+
ThinkingBlock,
|
|
53
|
+
TokenUsage,
|
|
54
|
+
ToolResultBlock,
|
|
55
|
+
ToolUseBlock,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Content block (de)serialization helpers
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _utc_now_iso() -> str:
|
|
64
|
+
return datetime.now(timezone.utc).isoformat()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _serialize_content_blocks(blocks: list[ContentBlock]) -> str:
|
|
68
|
+
return json.dumps([b.model_dump() for b in blocks])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _deserialize_content_block(d: dict) -> ContentBlock:
|
|
72
|
+
t = d.get("type")
|
|
73
|
+
match t:
|
|
74
|
+
case "text":
|
|
75
|
+
return TextBlock(**d)
|
|
76
|
+
case "tool_use":
|
|
77
|
+
return ToolUseBlock(**d)
|
|
78
|
+
case "tool_result":
|
|
79
|
+
return ToolResultBlock(**d)
|
|
80
|
+
case "thinking":
|
|
81
|
+
return ThinkingBlock(**d)
|
|
82
|
+
case "image":
|
|
83
|
+
return ImageBlock(**d)
|
|
84
|
+
case _:
|
|
85
|
+
return TextBlock(text=str(d))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _deserialize_content_blocks(content_json: str) -> list[ContentBlock]:
|
|
89
|
+
try:
|
|
90
|
+
raw = json.loads(content_json)
|
|
91
|
+
return [_deserialize_content_block(d) for d in raw]
|
|
92
|
+
except (json.JSONDecodeError, TypeError):
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_text_for_fts(blocks: list[ContentBlock]) -> str:
|
|
97
|
+
"""Extract plain text from content blocks for FTS indexing."""
|
|
98
|
+
parts: list[str] = []
|
|
99
|
+
for block in blocks:
|
|
100
|
+
if isinstance(block, TextBlock) and block.text:
|
|
101
|
+
parts.append(block.text)
|
|
102
|
+
return " ".join(parts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _result_char_count(block: ToolResultBlock) -> int:
|
|
106
|
+
"""Char count of a tool_result's content. Mirrors
|
|
107
|
+
``analytics.tips._result_char_count``; the oversized-tip parity test
|
|
108
|
+
(tests/analytics/test_fast_parity.py) guards the two against drift."""
|
|
109
|
+
if block.content is None:
|
|
110
|
+
return 0
|
|
111
|
+
if isinstance(block.content, str):
|
|
112
|
+
return len(block.content)
|
|
113
|
+
return sum(
|
|
114
|
+
len(item.get("text", "")) for item in block.content if isinstance(item, dict)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _block_facts(
|
|
119
|
+
message_id: str, session_id: str, blocks: list[ContentBlock]
|
|
120
|
+
) -> list[tuple]:
|
|
121
|
+
"""Materialized ``message_blocks`` rows for one message: one tuple per
|
|
122
|
+
tool_use / tool_result block, in content-block order. Field extraction
|
|
123
|
+
mirrors the scanners (tool name, file_path-or-path, result char count) so
|
|
124
|
+
SQL aggregation over ``message_blocks`` equals the in-memory scanners.
|
|
125
|
+
"""
|
|
126
|
+
facts: list[tuple] = []
|
|
127
|
+
for i, block in enumerate(blocks):
|
|
128
|
+
if isinstance(block, ToolUseBlock):
|
|
129
|
+
path = block.input.get("file_path") or block.input.get("path") or ""
|
|
130
|
+
facts.append(
|
|
131
|
+
(
|
|
132
|
+
message_id,
|
|
133
|
+
session_id,
|
|
134
|
+
i,
|
|
135
|
+
"tool_use",
|
|
136
|
+
block.name,
|
|
137
|
+
path if isinstance(path, str) and path else None,
|
|
138
|
+
None,
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
elif isinstance(block, ToolResultBlock):
|
|
143
|
+
facts.append(
|
|
144
|
+
(
|
|
145
|
+
message_id,
|
|
146
|
+
session_id,
|
|
147
|
+
i,
|
|
148
|
+
"tool_result",
|
|
149
|
+
None,
|
|
150
|
+
None,
|
|
151
|
+
_result_char_count(block),
|
|
152
|
+
block.tool_use_id,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return facts
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Timestamp helpers
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _dt_to_str(dt: Optional[datetime]) -> Optional[str]:
|
|
164
|
+
if dt is None:
|
|
165
|
+
return None
|
|
166
|
+
return dt.isoformat()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _str_to_dt(s: Optional[str]) -> Optional[datetime]:
|
|
170
|
+
if not s:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return datetime.fromisoformat(s)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# messages upsert helpers (incremental)
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
# Column order for the messages row tuple. content_hash is last and derived
|
|
183
|
+
# (not read off the Message) — it is appended by upsert_messages.
|
|
184
|
+
_MESSAGE_COLUMNS: tuple[str, ...] = (
|
|
185
|
+
"id",
|
|
186
|
+
"session_id",
|
|
187
|
+
"parent_id",
|
|
188
|
+
"role",
|
|
189
|
+
"content_json",
|
|
190
|
+
"timestamp",
|
|
191
|
+
"model",
|
|
192
|
+
"is_sidechain",
|
|
193
|
+
"is_meta",
|
|
194
|
+
"cwd",
|
|
195
|
+
"git_branch",
|
|
196
|
+
"source_tool_assistant_uuid",
|
|
197
|
+
"usage_json",
|
|
198
|
+
"input_tokens",
|
|
199
|
+
"output_tokens",
|
|
200
|
+
"cache_creation_tokens",
|
|
201
|
+
"cache_read_tokens",
|
|
202
|
+
"content_hash",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Upsert by primary key: UPDATE-in-place on conflict (preserves rowid, so the
|
|
206
|
+
# "timestamp ASC, rowid ASC" tie-break in get_session_detail stays stable when
|
|
207
|
+
# only some same-timestamp rows change), INSERT with a fresh rowid otherwise.
|
|
208
|
+
_UPSERT_MESSAGE_SQL = (
|
|
209
|
+
f"INSERT INTO messages ({', '.join(_MESSAGE_COLUMNS)}) "
|
|
210
|
+
f"VALUES ({', '.join('?' * len(_MESSAGE_COLUMNS))}) "
|
|
211
|
+
"ON CONFLICT(id) DO UPDATE SET "
|
|
212
|
+
+ ", ".join(f"{c}=excluded.{c}" for c in _MESSAGE_COLUMNS if c != "id")
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _message_row(m: Message) -> tuple:
|
|
217
|
+
"""The messages-table value tuple for *m*, minus the trailing content_hash
|
|
218
|
+
(order matches :data:`_MESSAGE_COLUMNS` up to but excluding ``content_hash``)."""
|
|
219
|
+
return (
|
|
220
|
+
m.id,
|
|
221
|
+
m.session_id,
|
|
222
|
+
m.parent_id,
|
|
223
|
+
m.role,
|
|
224
|
+
_serialize_content_blocks(m.content_blocks),
|
|
225
|
+
_dt_to_str(m.timestamp),
|
|
226
|
+
m.model,
|
|
227
|
+
1 if m.is_sidechain else 0,
|
|
228
|
+
1 if m.is_meta else 0,
|
|
229
|
+
m.cwd,
|
|
230
|
+
m.git_branch,
|
|
231
|
+
m.source_tool_assistant_uuid,
|
|
232
|
+
m.usage.model_dump_json() if m.usage else None,
|
|
233
|
+
m.usage.input_tokens if m.usage else None,
|
|
234
|
+
m.usage.output_tokens if m.usage else None,
|
|
235
|
+
m.usage.cache_creation_input_tokens if m.usage else None,
|
|
236
|
+
m.usage.cache_read_input_tokens if m.usage else None,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _row_hash(row: tuple) -> str:
|
|
241
|
+
"""Stable content hash of a :func:`_message_row` tuple. Any change to any
|
|
242
|
+
persisted field (content, usage/token backfill, model, flags…) changes it,
|
|
243
|
+
so an unchanged re-parsed row hashes identically and is skipped."""
|
|
244
|
+
return hashlib.blake2b(
|
|
245
|
+
json.dumps(row, ensure_ascii=False).encode("utf-8"), digest_size=16
|
|
246
|
+
).hexdigest()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Database class
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Database:
|
|
255
|
+
"""Async SQLite database wrapper. Use as an async context manager."""
|
|
256
|
+
|
|
257
|
+
def __init__(self, db_path: Path) -> None:
|
|
258
|
+
self._db_path = db_path
|
|
259
|
+
self._conn: Optional[aiosqlite.Connection] = None
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self) -> "Database":
|
|
262
|
+
self._conn = await aiosqlite.connect(self._db_path)
|
|
263
|
+
self._conn.row_factory = aiosqlite.Row
|
|
264
|
+
# WAL lets readers run alongside a single writer instead of blocking
|
|
265
|
+
# outright; busy_timeout makes contending writers wait briefly instead
|
|
266
|
+
# of erroring. Required because every endpoint + the scanner opens its
|
|
267
|
+
# own connection.
|
|
268
|
+
await self._conn.execute("PRAGMA journal_mode=WAL")
|
|
269
|
+
await self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
270
|
+
await self._conn.execute("PRAGMA busy_timeout=5000")
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
async def __aexit__(self, *args: object) -> None:
|
|
274
|
+
if self._conn:
|
|
275
|
+
await self._conn.close()
|
|
276
|
+
self._conn = None
|
|
277
|
+
|
|
278
|
+
async def execute(self, sql: str, params: tuple = ()) -> None:
|
|
279
|
+
"""Execute a single SQL statement."""
|
|
280
|
+
assert self._conn is not None
|
|
281
|
+
await self._conn.execute(sql, params)
|
|
282
|
+
|
|
283
|
+
async def commit(self) -> None:
|
|
284
|
+
"""Commit the current transaction."""
|
|
285
|
+
assert self._conn is not None
|
|
286
|
+
await self._conn.commit()
|
|
287
|
+
|
|
288
|
+
async def checkpoint(self) -> None:
|
|
289
|
+
"""Fold the WAL back into the main db and truncate the -wal sidecar.
|
|
290
|
+
|
|
291
|
+
``TRUNCATE`` checkpoints as far as possible then resets
|
|
292
|
+
``index.db-wal`` to zero bytes. It is blocked only by an open *read
|
|
293
|
+
snapshot* on another connection — so it must run from a connection
|
|
294
|
+
that is not sitting between reads (see ``_periodic_checkpoint`` in
|
|
295
|
+
server.py). Without periodic truncation the WAL grew to ~22 GB here,
|
|
296
|
+
because the default ``wal_autocheckpoint`` is passive and was pinned
|
|
297
|
+
by long-lived reader connections that never released their snapshot.
|
|
298
|
+
"""
|
|
299
|
+
assert self._conn is not None
|
|
300
|
+
await self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
301
|
+
|
|
302
|
+
# -----------------------------------------------------------------------
|
|
303
|
+
# Schema
|
|
304
|
+
# -----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async def init_schema(self) -> None:
|
|
307
|
+
"""Create all tables and FTS5 virtual table if they don't exist."""
|
|
308
|
+
assert self._conn is not None
|
|
309
|
+
await self._conn.executescript("""
|
|
310
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
311
|
+
id TEXT PRIMARY KEY,
|
|
312
|
+
display_name TEXT NOT NULL,
|
|
313
|
+
raw_path TEXT NOT NULL,
|
|
314
|
+
resolved_path TEXT,
|
|
315
|
+
data_source TEXT NOT NULL DEFAULT '',
|
|
316
|
+
last_activity_at TEXT,
|
|
317
|
+
session_count INTEGER NOT NULL DEFAULT 0
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
321
|
+
id TEXT PRIMARY KEY,
|
|
322
|
+
project_id TEXT NOT NULL,
|
|
323
|
+
file_path TEXT NOT NULL,
|
|
324
|
+
title TEXT,
|
|
325
|
+
model TEXT,
|
|
326
|
+
started_at TEXT,
|
|
327
|
+
updated_at TEXT,
|
|
328
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
329
|
+
user_message_count INTEGER NOT NULL DEFAULT 0,
|
|
330
|
+
cwd TEXT,
|
|
331
|
+
git_branch TEXT,
|
|
332
|
+
is_worktree INTEGER NOT NULL DEFAULT 0,
|
|
333
|
+
is_fork INTEGER NOT NULL DEFAULT 0,
|
|
334
|
+
permission_mode TEXT,
|
|
335
|
+
provider TEXT NOT NULL DEFAULT 'claude',
|
|
336
|
+
file_mtime REAL,
|
|
337
|
+
FOREIGN KEY (project_id) REFERENCES projects(id)
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
341
|
+
id TEXT PRIMARY KEY,
|
|
342
|
+
session_id TEXT NOT NULL,
|
|
343
|
+
parent_id TEXT,
|
|
344
|
+
role TEXT NOT NULL,
|
|
345
|
+
content_json TEXT NOT NULL,
|
|
346
|
+
timestamp TEXT,
|
|
347
|
+
model TEXT,
|
|
348
|
+
is_sidechain INTEGER NOT NULL DEFAULT 0,
|
|
349
|
+
is_meta INTEGER NOT NULL DEFAULT 0,
|
|
350
|
+
cwd TEXT,
|
|
351
|
+
git_branch TEXT,
|
|
352
|
+
source_tool_assistant_uuid TEXT,
|
|
353
|
+
usage_json TEXT,
|
|
354
|
+
input_tokens INTEGER,
|
|
355
|
+
output_tokens INTEGER,
|
|
356
|
+
cache_creation_tokens INTEGER,
|
|
357
|
+
cache_read_tokens INTEGER,
|
|
358
|
+
content_hash TEXT,
|
|
359
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
363
|
+
content,
|
|
364
|
+
session_id UNINDEXED,
|
|
365
|
+
message_id UNINDEXED,
|
|
366
|
+
role UNINDEXED
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
-- Speeds up the last_message_role correlated subquery on get_sessions.
|
|
370
|
+
-- Without this, each session row triggered a full messages-table scan.
|
|
371
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session_timestamp
|
|
372
|
+
ON messages (session_id, timestamp DESC);
|
|
373
|
+
|
|
374
|
+
-- Materialized per-block facts for whole-corpus analytics: one row
|
|
375
|
+
-- per tool_use / tool_result block, written at ingest so the
|
|
376
|
+
-- dashboard never re-scans content_json. See analytics/fast.py.
|
|
377
|
+
CREATE TABLE IF NOT EXISTS message_blocks (
|
|
378
|
+
message_id TEXT NOT NULL,
|
|
379
|
+
session_id TEXT NOT NULL,
|
|
380
|
+
block_index INTEGER NOT NULL,
|
|
381
|
+
btype TEXT NOT NULL,
|
|
382
|
+
tool_name TEXT,
|
|
383
|
+
file_path TEXT,
|
|
384
|
+
result_chars INTEGER,
|
|
385
|
+
tool_use_id TEXT,
|
|
386
|
+
PRIMARY KEY (message_id, block_index)
|
|
387
|
+
);
|
|
388
|
+
CREATE INDEX IF NOT EXISTS idx_blocks_tool
|
|
389
|
+
ON message_blocks (btype, tool_name);
|
|
390
|
+
CREATE INDEX IF NOT EXISTS idx_blocks_file
|
|
391
|
+
ON message_blocks (btype, file_path);
|
|
392
|
+
CREATE INDEX IF NOT EXISTS idx_blocks_session
|
|
393
|
+
ON message_blocks (session_id);
|
|
394
|
+
|
|
395
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
396
|
+
key TEXT PRIMARY KEY,
|
|
397
|
+
value TEXT NOT NULL
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
CREATE TABLE IF NOT EXISTS recaps (
|
|
401
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
402
|
+
session_id TEXT NOT NULL,
|
|
403
|
+
text TEXT NOT NULL,
|
|
404
|
+
created_at TEXT NOT NULL,
|
|
405
|
+
covers_until_message_uuid TEXT,
|
|
406
|
+
dismissed INTEGER NOT NULL DEFAULT 0
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
CREATE INDEX IF NOT EXISTS idx_recaps_session_id
|
|
410
|
+
ON recaps(session_id, created_at);
|
|
411
|
+
|
|
412
|
+
-- Per-session metadata override for renames (issue #11).
|
|
413
|
+
-- Lives in a separate table because the JSONL file is the
|
|
414
|
+
-- source of truth for the parsed title; this row is just an
|
|
415
|
+
-- opt-in override the user typed in the sidebar.
|
|
416
|
+
CREATE TABLE IF NOT EXISTS session_meta (
|
|
417
|
+
session_id TEXT PRIMARY KEY,
|
|
418
|
+
custom_title TEXT,
|
|
419
|
+
-- Server-side persistence for the formerly-localStorage flags.
|
|
420
|
+
-- NULL = not set; ISO-8601 string = wall-clock when set.
|
|
421
|
+
archived_at TEXT,
|
|
422
|
+
starred_at TEXT,
|
|
423
|
+
viewed_at TEXT,
|
|
424
|
+
updated_at TEXT NOT NULL
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
-- Ephemeral exchanges that never land in JSONL (e.g. /btw).
|
|
428
|
+
-- Both user input and assistant response are stored here, linked
|
|
429
|
+
-- via responds_to (self-FK on the paired input row).
|
|
430
|
+
-- Excluded from analytics (no tokens / cost contribution).
|
|
431
|
+
CREATE TABLE IF NOT EXISTS ephemeral_messages (
|
|
432
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
433
|
+
session_id TEXT NOT NULL,
|
|
434
|
+
kind TEXT NOT NULL,
|
|
435
|
+
role TEXT NOT NULL,
|
|
436
|
+
content TEXT NOT NULL,
|
|
437
|
+
responds_to INTEGER REFERENCES ephemeral_messages(id),
|
|
438
|
+
timestamp TEXT NOT NULL,
|
|
439
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
CREATE INDEX IF NOT EXISTS idx_ephemeral_session
|
|
443
|
+
ON ephemeral_messages (session_id, timestamp);
|
|
444
|
+
|
|
445
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ephemeral_messages_fts USING fts5(
|
|
446
|
+
content,
|
|
447
|
+
session_id UNINDEXED,
|
|
448
|
+
kind UNINDEXED,
|
|
449
|
+
ephemeral_id UNINDEXED
|
|
450
|
+
);
|
|
451
|
+
""")
|
|
452
|
+
|
|
453
|
+
# FTS5 triggers must be created outside executescript because
|
|
454
|
+
# CREATE TRIGGER is not supported inside a multi-statement string
|
|
455
|
+
# in some SQLite versions. Use separate execute calls instead.
|
|
456
|
+
await self._ensure_ephemeral_triggers()
|
|
457
|
+
|
|
458
|
+
await self._conn.commit()
|
|
459
|
+
await self._migrate_add_is_fork()
|
|
460
|
+
await self._migrate_add_provider()
|
|
461
|
+
await self._migrate_add_session_meta_flags()
|
|
462
|
+
await self._migrate_add_content_hash()
|
|
463
|
+
|
|
464
|
+
async def _ensure_ephemeral_triggers(self) -> None:
|
|
465
|
+
"""Create INSERT/UPDATE/DELETE triggers keeping ephemeral_messages_fts in sync.
|
|
466
|
+
|
|
467
|
+
Uses IF NOT EXISTS guard so init_schema() is fully idempotent.
|
|
468
|
+
Mirrors the approach used for messages_fts (manual rebuild on upsert)
|
|
469
|
+
but uses SQL triggers so any direct INSERT/UPDATE/DELETE also stays
|
|
470
|
+
in sync without requiring a Python-side rebuild call.
|
|
471
|
+
"""
|
|
472
|
+
assert self._conn is not None
|
|
473
|
+
await self._conn.execute("""
|
|
474
|
+
CREATE TRIGGER IF NOT EXISTS ephemeral_fts_insert
|
|
475
|
+
AFTER INSERT ON ephemeral_messages
|
|
476
|
+
BEGIN
|
|
477
|
+
INSERT INTO ephemeral_messages_fts
|
|
478
|
+
(content, session_id, kind, ephemeral_id)
|
|
479
|
+
VALUES
|
|
480
|
+
(NEW.content, NEW.session_id, NEW.kind, NEW.id);
|
|
481
|
+
END
|
|
482
|
+
""")
|
|
483
|
+
await self._conn.execute("""
|
|
484
|
+
CREATE TRIGGER IF NOT EXISTS ephemeral_fts_update
|
|
485
|
+
AFTER UPDATE OF content ON ephemeral_messages
|
|
486
|
+
BEGIN
|
|
487
|
+
DELETE FROM ephemeral_messages_fts
|
|
488
|
+
WHERE ephemeral_id = OLD.id;
|
|
489
|
+
INSERT INTO ephemeral_messages_fts
|
|
490
|
+
(content, session_id, kind, ephemeral_id)
|
|
491
|
+
VALUES
|
|
492
|
+
(NEW.content, NEW.session_id, NEW.kind, NEW.id);
|
|
493
|
+
END
|
|
494
|
+
""")
|
|
495
|
+
await self._conn.execute("""
|
|
496
|
+
CREATE TRIGGER IF NOT EXISTS ephemeral_fts_delete
|
|
497
|
+
BEFORE DELETE ON ephemeral_messages
|
|
498
|
+
BEGIN
|
|
499
|
+
DELETE FROM ephemeral_messages_fts
|
|
500
|
+
WHERE ephemeral_id = OLD.id;
|
|
501
|
+
END
|
|
502
|
+
""")
|
|
503
|
+
|
|
504
|
+
async def _migrate_add_is_fork(self) -> None:
|
|
505
|
+
"""Idempotent: add is_fork column to existing DBs."""
|
|
506
|
+
assert self._conn is not None
|
|
507
|
+
async with self._conn.execute(
|
|
508
|
+
"SELECT name FROM pragma_table_info('sessions') WHERE name='is_fork'"
|
|
509
|
+
) as cur:
|
|
510
|
+
if not await cur.fetchone():
|
|
511
|
+
await self._conn.execute(
|
|
512
|
+
"ALTER TABLE sessions ADD COLUMN is_fork INTEGER NOT NULL DEFAULT 0"
|
|
513
|
+
)
|
|
514
|
+
await self._conn.commit()
|
|
515
|
+
|
|
516
|
+
async def _migrate_add_provider(self) -> None:
|
|
517
|
+
"""Idempotent: add provider column to existing DBs."""
|
|
518
|
+
assert self._conn is not None
|
|
519
|
+
async with self._conn.execute(
|
|
520
|
+
"SELECT name FROM pragma_table_info('sessions') WHERE name='provider'"
|
|
521
|
+
) as cur:
|
|
522
|
+
if not await cur.fetchone():
|
|
523
|
+
await self._conn.execute(
|
|
524
|
+
"ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT 'claude'"
|
|
525
|
+
)
|
|
526
|
+
await self._conn.commit()
|
|
527
|
+
|
|
528
|
+
async def _migrate_add_content_hash(self) -> None:
|
|
529
|
+
"""Idempotent: add content_hash column to messages for incremental upsert.
|
|
530
|
+
|
|
531
|
+
Pre-migration rows keep ``content_hash = NULL``; the first incremental
|
|
532
|
+
``upsert_messages`` after this runs sees NULL != freshly-computed hash
|
|
533
|
+
and rewrites each row once, backfilling the column. Subsequent re-parses
|
|
534
|
+
are then true deltas.
|
|
535
|
+
"""
|
|
536
|
+
assert self._conn is not None
|
|
537
|
+
async with self._conn.execute(
|
|
538
|
+
"SELECT name FROM pragma_table_info('messages') WHERE name='content_hash'"
|
|
539
|
+
) as cur:
|
|
540
|
+
if not await cur.fetchone():
|
|
541
|
+
await self._conn.execute(
|
|
542
|
+
"ALTER TABLE messages ADD COLUMN content_hash TEXT"
|
|
543
|
+
)
|
|
544
|
+
await self._conn.commit()
|
|
545
|
+
|
|
546
|
+
async def migrate_fts_rowid_v1(self) -> None:
|
|
547
|
+
"""One-time: align ``messages_fts.rowid`` with ``messages.rowid``.
|
|
548
|
+
|
|
549
|
+
``message_id`` is UNINDEXED in fts5, so ``DELETE FROM messages_fts
|
|
550
|
+
WHERE message_id IN (...)`` (the hot path in ``upsert_messages``)
|
|
551
|
+
forced a full ``VIRTUAL TABLE SCAN`` of the entire corpus on every
|
|
552
|
+
live re-index turn, regardless of how many rows actually changed —
|
|
553
|
+
measured at 250-400ms on a 27k-row FTS corpus vs sub-ms once rowid
|
|
554
|
+
is used directly (fts5's real primary index). Once aligned,
|
|
555
|
+
``upsert_messages`` deletes/inserts by rowid instead, making the FTS
|
|
556
|
+
maintenance step O(changed) like the rest of the incremental upsert.
|
|
557
|
+
|
|
558
|
+
Rebuilds any misaligned row by deleting-then-reinserting with the
|
|
559
|
+
correct rowid, reusing its already-extracted ``content`` (no
|
|
560
|
+
content_json reparse needed). Also dedupes any pre-existing
|
|
561
|
+
``message_id`` that has more than one FTS row (observed in the wild —
|
|
562
|
+
likely from an earlier race between overlapping re-indexes — which
|
|
563
|
+
would otherwise make two rows target the same new rowid and collide).
|
|
564
|
+
Idempotent via a ``_meta`` flag.
|
|
565
|
+
"""
|
|
566
|
+
assert self._conn is not None
|
|
567
|
+
async with self._conn.execute(
|
|
568
|
+
"SELECT value FROM _meta WHERE key = 'fts_rowid_v1'"
|
|
569
|
+
) as cur:
|
|
570
|
+
if await cur.fetchone() is not None:
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
async with self._conn.execute(
|
|
574
|
+
"SELECT rowid, content, session_id, message_id, role FROM messages_fts"
|
|
575
|
+
) as cur:
|
|
576
|
+
fts_rows = await cur.fetchall()
|
|
577
|
+
async with self._conn.execute("SELECT id, rowid FROM messages") as cur:
|
|
578
|
+
target_rowid = {r["id"]: r["rowid"] for r in await cur.fetchall()}
|
|
579
|
+
|
|
580
|
+
by_message_id: dict[str, list] = {}
|
|
581
|
+
for row in fts_rows:
|
|
582
|
+
by_message_id.setdefault(row["message_id"], []).append(row)
|
|
583
|
+
|
|
584
|
+
to_delete: list[int] = []
|
|
585
|
+
to_insert: list[tuple] = []
|
|
586
|
+
for message_id, group in by_message_id.items():
|
|
587
|
+
new_rowid = target_rowid.get(message_id)
|
|
588
|
+
if new_rowid is None:
|
|
589
|
+
to_delete.extend(row["rowid"] for row in group)
|
|
590
|
+
continue # orphaned: message no longer exists
|
|
591
|
+
aligned = next((row for row in group if row["rowid"] == new_rowid), None)
|
|
592
|
+
if aligned is not None:
|
|
593
|
+
# Already at the correct rowid — drop any duplicate extras.
|
|
594
|
+
to_delete.extend(row["rowid"] for row in group if row is not aligned)
|
|
595
|
+
continue
|
|
596
|
+
# No copy sits at the correct rowid: drop them all, keep one.
|
|
597
|
+
keep = group[0]
|
|
598
|
+
to_delete.extend(row["rowid"] for row in group)
|
|
599
|
+
to_insert.append(
|
|
600
|
+
(
|
|
601
|
+
new_rowid,
|
|
602
|
+
keep["content"],
|
|
603
|
+
keep["session_id"],
|
|
604
|
+
message_id,
|
|
605
|
+
keep["role"],
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Every delete runs before any insert — a rowid swap between two rows
|
|
610
|
+
# (or a dupe already sitting on another row's target rowid) would
|
|
611
|
+
# otherwise collide mid-migration.
|
|
612
|
+
for rowid in to_delete:
|
|
613
|
+
await self._conn.execute(
|
|
614
|
+
"DELETE FROM messages_fts WHERE rowid = ?", (rowid,)
|
|
615
|
+
)
|
|
616
|
+
for row in to_insert:
|
|
617
|
+
await self._conn.execute(
|
|
618
|
+
"INSERT INTO messages_fts (rowid, content, session_id, "
|
|
619
|
+
"message_id, role) VALUES (?, ?, ?, ?, ?)",
|
|
620
|
+
row,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
await self._conn.execute(
|
|
624
|
+
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('fts_rowid_v1', '1')"
|
|
625
|
+
)
|
|
626
|
+
await self._conn.commit()
|
|
627
|
+
|
|
628
|
+
async def _migrate_add_session_meta_flags(self) -> None:
|
|
629
|
+
"""Idempotent: add archived_at, starred_at, viewed_at to session_meta.
|
|
630
|
+
|
|
631
|
+
These were originally localStorage-only on the frontend (LS.ARCHIVED,
|
|
632
|
+
LS.STARRED, LS.VIEWED_AT), which meant a second browser saw stale
|
|
633
|
+
state. Moving them server-side fixes that. Each is a nullable ISO
|
|
634
|
+
timestamp: NULL = not set, string = wall-clock when last set.
|
|
635
|
+
"""
|
|
636
|
+
assert self._conn is not None
|
|
637
|
+
for col in ("archived_at", "starred_at", "viewed_at"):
|
|
638
|
+
async with self._conn.execute(
|
|
639
|
+
"SELECT name FROM pragma_table_info('session_meta') WHERE name=?",
|
|
640
|
+
(col,),
|
|
641
|
+
) as cur:
|
|
642
|
+
if not await cur.fetchone():
|
|
643
|
+
await self._conn.execute(
|
|
644
|
+
f"ALTER TABLE session_meta ADD COLUMN {col} TEXT"
|
|
645
|
+
)
|
|
646
|
+
await self._conn.commit()
|
|
647
|
+
|
|
648
|
+
async def reset_xml_title_mtimes(self) -> int:
|
|
649
|
+
"""Clear file_mtime for sessions whose title contains XML tags.
|
|
650
|
+
|
|
651
|
+
This forces the scanner to re-parse those files on the next startup,
|
|
652
|
+
picking up the fixed title-inference logic in parser.py.
|
|
653
|
+
Returns the number of sessions reset.
|
|
654
|
+
"""
|
|
655
|
+
assert self._conn is not None
|
|
656
|
+
async with self._conn.execute(
|
|
657
|
+
"SELECT id, title FROM sessions WHERE title LIKE '%<%'"
|
|
658
|
+
) as cursor:
|
|
659
|
+
rows = await cursor.fetchall()
|
|
660
|
+
|
|
661
|
+
bad_ids = [row["id"] for row in rows if row["title"] and "<" in row["title"]]
|
|
662
|
+
if bad_ids:
|
|
663
|
+
placeholders = ",".join("?" * len(bad_ids))
|
|
664
|
+
await self._conn.execute(
|
|
665
|
+
f"UPDATE sessions SET file_mtime = NULL WHERE id IN ({placeholders})",
|
|
666
|
+
bad_ids,
|
|
667
|
+
)
|
|
668
|
+
await self._conn.commit()
|
|
669
|
+
return len(bad_ids)
|
|
670
|
+
|
|
671
|
+
async def reset_truncated_titles(self) -> int:
|
|
672
|
+
"""One-shot: clear file_mtime for all sessions so titles get re-parsed.
|
|
673
|
+
|
|
674
|
+
Originally added when the 80-char title cap was removed. Without
|
|
675
|
+
the _meta guard this fired on every startup and forced every
|
|
676
|
+
session's JSONL to re-parse on next access — blocking get_session
|
|
677
|
+
for 10s+ per large file. The guard makes it a real one-shot.
|
|
678
|
+
"""
|
|
679
|
+
assert self._conn is not None
|
|
680
|
+
async with self._conn.execute(
|
|
681
|
+
"SELECT value FROM _meta WHERE key = 'truncated_titles_reset_v1'"
|
|
682
|
+
) as cursor:
|
|
683
|
+
row = await cursor.fetchone()
|
|
684
|
+
if row is not None:
|
|
685
|
+
return 0 # already migrated
|
|
686
|
+
cursor = await self._conn.execute("UPDATE sessions SET file_mtime = NULL")
|
|
687
|
+
await self._conn.execute(
|
|
688
|
+
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('truncated_titles_reset_v1', '1')"
|
|
689
|
+
)
|
|
690
|
+
await self._conn.commit()
|
|
691
|
+
return cursor.rowcount
|
|
692
|
+
|
|
693
|
+
async def migrate_project_id_v2(self) -> None:
|
|
694
|
+
"""Mark the project ID v2 migration as done.
|
|
695
|
+
|
|
696
|
+
The project ID hash now includes data_source to avoid collisions
|
|
697
|
+
across profiles. Old sessions keep their existing project_ids until
|
|
698
|
+
they're naturally re-scanned (file change, manual refresh).
|
|
699
|
+
"""
|
|
700
|
+
assert self._conn is not None
|
|
701
|
+
async with self._conn.execute(
|
|
702
|
+
"SELECT value FROM _meta WHERE key = 'project_id_v2'"
|
|
703
|
+
) as cursor:
|
|
704
|
+
row = await cursor.fetchone()
|
|
705
|
+
if row is not None:
|
|
706
|
+
return # already migrated
|
|
707
|
+
await self._conn.execute(
|
|
708
|
+
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('project_id_v2', '1')"
|
|
709
|
+
)
|
|
710
|
+
await self._conn.commit()
|
|
711
|
+
|
|
712
|
+
async def migrate_materialize_v1(self) -> None:
|
|
713
|
+
"""One-time backfill of the materialized analytics columns + table for
|
|
714
|
+
databases created before they existed. Idempotent via a ``_meta`` flag.
|
|
715
|
+
|
|
716
|
+
Token columns come straight from ``usage_json`` (an exact, cheap
|
|
717
|
+
``UPDATE``). ``message_blocks`` is rebuilt by streaming each message's
|
|
718
|
+
``content_json`` through the SAME extractor the ingest path uses, so
|
|
719
|
+
the backfilled rows match what new writes produce. This reads the whole
|
|
720
|
+
corpus once — the only expensive content pass, and it happens exactly
|
|
721
|
+
once per database.
|
|
722
|
+
"""
|
|
723
|
+
assert self._conn is not None
|
|
724
|
+
# Pre-existing messages tables won't have the token columns yet.
|
|
725
|
+
async with self._conn.execute("PRAGMA table_info(messages)") as cursor:
|
|
726
|
+
cols = {r["name"] for r in await cursor.fetchall()}
|
|
727
|
+
for col in (
|
|
728
|
+
"input_tokens",
|
|
729
|
+
"output_tokens",
|
|
730
|
+
"cache_creation_tokens",
|
|
731
|
+
"cache_read_tokens",
|
|
732
|
+
):
|
|
733
|
+
if col not in cols:
|
|
734
|
+
await self._conn.execute(
|
|
735
|
+
f"ALTER TABLE messages ADD COLUMN {col} INTEGER"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
async with self._conn.execute(
|
|
739
|
+
"SELECT value FROM _meta WHERE key = 'materialize_v1'"
|
|
740
|
+
) as cursor:
|
|
741
|
+
if await cursor.fetchone() is not None:
|
|
742
|
+
return # already backfilled
|
|
743
|
+
|
|
744
|
+
await self._conn.execute(
|
|
745
|
+
"""
|
|
746
|
+
UPDATE messages SET
|
|
747
|
+
input_tokens = json_extract(usage_json,'$.input_tokens'),
|
|
748
|
+
output_tokens = json_extract(usage_json,'$.output_tokens'),
|
|
749
|
+
cache_creation_tokens =
|
|
750
|
+
json_extract(usage_json,'$.cache_creation_input_tokens'),
|
|
751
|
+
cache_read_tokens =
|
|
752
|
+
json_extract(usage_json,'$.cache_read_input_tokens')
|
|
753
|
+
WHERE usage_json IS NOT NULL
|
|
754
|
+
"""
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
insert_sql = (
|
|
758
|
+
"INSERT OR REPLACE INTO message_blocks (message_id, session_id, "
|
|
759
|
+
"block_index, btype, tool_name, file_path, result_chars, tool_use_id) "
|
|
760
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
761
|
+
)
|
|
762
|
+
await self._conn.execute("DELETE FROM message_blocks")
|
|
763
|
+
batch: list[tuple] = []
|
|
764
|
+
async with self._conn.execute(
|
|
765
|
+
"SELECT id, session_id, content_json FROM messages"
|
|
766
|
+
) as cursor:
|
|
767
|
+
async for row in cursor:
|
|
768
|
+
facts = _block_facts(
|
|
769
|
+
row["id"],
|
|
770
|
+
row["session_id"],
|
|
771
|
+
_deserialize_content_blocks(row["content_json"]),
|
|
772
|
+
)
|
|
773
|
+
batch.extend(facts)
|
|
774
|
+
if len(batch) >= 5000:
|
|
775
|
+
await self._conn.executemany(insert_sql, batch)
|
|
776
|
+
batch.clear()
|
|
777
|
+
if batch:
|
|
778
|
+
await self._conn.executemany(insert_sql, batch)
|
|
779
|
+
|
|
780
|
+
await self._conn.execute(
|
|
781
|
+
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('materialize_v1', '1')"
|
|
782
|
+
)
|
|
783
|
+
await self._conn.commit()
|
|
784
|
+
|
|
785
|
+
# -----------------------------------------------------------------------
|
|
786
|
+
# Projects
|
|
787
|
+
# -----------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
async def upsert_project(self, project: Project) -> None:
|
|
790
|
+
assert self._conn is not None
|
|
791
|
+
await self._conn.execute(
|
|
792
|
+
"""
|
|
793
|
+
INSERT OR REPLACE INTO projects
|
|
794
|
+
(id, display_name, raw_path, resolved_path, data_source,
|
|
795
|
+
last_activity_at, session_count)
|
|
796
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
797
|
+
""",
|
|
798
|
+
(
|
|
799
|
+
project.id,
|
|
800
|
+
project.display_name,
|
|
801
|
+
project.raw_path,
|
|
802
|
+
project.resolved_path,
|
|
803
|
+
project.data_source,
|
|
804
|
+
_dt_to_str(project.last_activity_at),
|
|
805
|
+
project.session_count,
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
await self._conn.commit()
|
|
809
|
+
|
|
810
|
+
async def get_projects(
|
|
811
|
+
self, data_sources: Optional[list[str]] = None
|
|
812
|
+
) -> list[Project]:
|
|
813
|
+
assert self._conn is not None
|
|
814
|
+
if data_sources is not None:
|
|
815
|
+
placeholders = ",".join("?" * len(data_sources))
|
|
816
|
+
query = (
|
|
817
|
+
f"SELECT * FROM projects WHERE data_source IN ({placeholders}) "
|
|
818
|
+
"ORDER BY last_activity_at DESC NULLS LAST"
|
|
819
|
+
)
|
|
820
|
+
params: tuple = tuple(data_sources)
|
|
821
|
+
else:
|
|
822
|
+
query = "SELECT * FROM projects ORDER BY last_activity_at DESC NULLS LAST"
|
|
823
|
+
params = ()
|
|
824
|
+
async with self._conn.execute(query, params) as cursor:
|
|
825
|
+
rows = await cursor.fetchall()
|
|
826
|
+
return [
|
|
827
|
+
Project(
|
|
828
|
+
id=row["id"],
|
|
829
|
+
display_name=row["display_name"],
|
|
830
|
+
raw_path=row["raw_path"],
|
|
831
|
+
resolved_path=row["resolved_path"],
|
|
832
|
+
data_source=row["data_source"] or "",
|
|
833
|
+
last_activity_at=_str_to_dt(row["last_activity_at"]),
|
|
834
|
+
session_count=row["session_count"],
|
|
835
|
+
)
|
|
836
|
+
for row in rows
|
|
837
|
+
]
|
|
838
|
+
|
|
839
|
+
# -----------------------------------------------------------------------
|
|
840
|
+
# Sessions
|
|
841
|
+
# -----------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
async def upsert_session(
|
|
844
|
+
self, session: Session, file_mtime: Optional[float] = None
|
|
845
|
+
) -> None:
|
|
846
|
+
"""Upsert a session record.
|
|
847
|
+
|
|
848
|
+
Args:
|
|
849
|
+
session: The ``Session`` to store or update.
|
|
850
|
+
file_mtime: The file's ``stat().st_mtime`` — stored for cache
|
|
851
|
+
invalidation and retrievable via ``get_session_mtime``.
|
|
852
|
+
"""
|
|
853
|
+
assert self._conn is not None
|
|
854
|
+
await self._conn.execute(
|
|
855
|
+
"""
|
|
856
|
+
INSERT OR REPLACE INTO sessions
|
|
857
|
+
(id, project_id, file_path, title, model, started_at, updated_at,
|
|
858
|
+
message_count, user_message_count, cwd, git_branch, is_worktree,
|
|
859
|
+
is_fork, permission_mode, provider, file_mtime)
|
|
860
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
861
|
+
""",
|
|
862
|
+
(
|
|
863
|
+
session.id,
|
|
864
|
+
session.project_id,
|
|
865
|
+
session.file_path,
|
|
866
|
+
session.title,
|
|
867
|
+
session.model,
|
|
868
|
+
_dt_to_str(session.started_at),
|
|
869
|
+
_dt_to_str(session.updated_at),
|
|
870
|
+
session.message_count,
|
|
871
|
+
session.user_message_count,
|
|
872
|
+
session.cwd,
|
|
873
|
+
session.git_branch,
|
|
874
|
+
1 if session.is_worktree else 0,
|
|
875
|
+
1 if session.is_fork else 0,
|
|
876
|
+
session.permission_mode,
|
|
877
|
+
session.provider,
|
|
878
|
+
file_mtime,
|
|
879
|
+
),
|
|
880
|
+
)
|
|
881
|
+
await self._conn.commit()
|
|
882
|
+
|
|
883
|
+
async def get_sessions(
|
|
884
|
+
self, project_id: Optional[str] = None, data_sources: Optional[list[str]] = None
|
|
885
|
+
) -> list[Session]:
|
|
886
|
+
assert self._conn is not None
|
|
887
|
+
_lmr = (
|
|
888
|
+
"(SELECT role FROM messages WHERE session_id = s.id "
|
|
889
|
+
"ORDER BY timestamp DESC LIMIT 1) AS last_message_role"
|
|
890
|
+
)
|
|
891
|
+
conditions = ["s.title IS NOT NULL"]
|
|
892
|
+
params: list = []
|
|
893
|
+
if project_id is not None:
|
|
894
|
+
conditions.append("s.project_id = ?")
|
|
895
|
+
params.append(project_id)
|
|
896
|
+
if data_sources is not None:
|
|
897
|
+
placeholders = ",".join("?" * len(data_sources))
|
|
898
|
+
conditions.append(
|
|
899
|
+
f"s.project_id IN (SELECT id FROM projects WHERE data_source IN ({placeholders}))"
|
|
900
|
+
)
|
|
901
|
+
params.extend(data_sources)
|
|
902
|
+
where = " AND ".join(conditions)
|
|
903
|
+
# LEFT JOIN session_meta so each session row already carries any
|
|
904
|
+
# server-side rename + archive/star/viewed flags on first paint.
|
|
905
|
+
query = (
|
|
906
|
+
f"SELECT s.*, {_lmr}, "
|
|
907
|
+
"sm.custom_title AS custom_title, "
|
|
908
|
+
"sm.archived_at AS archived_at, "
|
|
909
|
+
"sm.starred_at AS starred_at, "
|
|
910
|
+
"sm.viewed_at AS viewed_at "
|
|
911
|
+
"FROM sessions s LEFT JOIN session_meta sm ON sm.session_id = s.id "
|
|
912
|
+
f"WHERE {where} ORDER BY s.updated_at DESC"
|
|
913
|
+
)
|
|
914
|
+
async with self._conn.execute(query, tuple(params)) as cursor:
|
|
915
|
+
rows = await cursor.fetchall()
|
|
916
|
+
return [_row_to_session(row) for row in rows]
|
|
917
|
+
|
|
918
|
+
async def clear_all_mtimes(self) -> None:
|
|
919
|
+
"""Reset stored file mtimes so the next scan re-parses every session."""
|
|
920
|
+
assert self._conn is not None
|
|
921
|
+
await self._conn.execute("UPDATE sessions SET file_mtime = NULL")
|
|
922
|
+
await self._conn.commit()
|
|
923
|
+
|
|
924
|
+
async def get_session_mtime(self, session_id: str) -> Optional[float]:
|
|
925
|
+
"""Return stored file mtime for cache-busting, or None if not indexed."""
|
|
926
|
+
assert self._conn is not None
|
|
927
|
+
async with self._conn.execute(
|
|
928
|
+
"SELECT file_mtime FROM sessions WHERE id = ?", (session_id,)
|
|
929
|
+
) as cursor:
|
|
930
|
+
row = await cursor.fetchone()
|
|
931
|
+
if row is None:
|
|
932
|
+
return None
|
|
933
|
+
return row["file_mtime"]
|
|
934
|
+
|
|
935
|
+
async def get_session_file_path(self, session_id: str) -> Optional[str]:
|
|
936
|
+
"""Return the JSONL file path for a session, or None if not indexed."""
|
|
937
|
+
assert self._conn is not None
|
|
938
|
+
async with self._conn.execute(
|
|
939
|
+
"SELECT file_path FROM sessions WHERE id = ?", (session_id,)
|
|
940
|
+
) as cursor:
|
|
941
|
+
row = await cursor.fetchone()
|
|
942
|
+
if row is None:
|
|
943
|
+
return None
|
|
944
|
+
return row["file_path"]
|
|
945
|
+
|
|
946
|
+
async def touch_session_updated_at(
|
|
947
|
+
self, session_id: str, when: Optional[datetime] = None
|
|
948
|
+
) -> Optional[datetime]:
|
|
949
|
+
"""Force ``sessions.updated_at`` to ``when`` (default: now, UTC).
|
|
950
|
+
|
|
951
|
+
Used after edit/delete swaps where the JSONL's parsed
|
|
952
|
+
``updated_at`` (max of remaining message timestamps) would either
|
|
953
|
+
stay flat (content edit) or regress (delete of latest), missing
|
|
954
|
+
the mutation for downstream SSE/dedupe consumers that key on
|
|
955
|
+
``detail.updated_at``.
|
|
956
|
+
|
|
957
|
+
Only advances the value — never moves it backwards — to preserve
|
|
958
|
+
the "most recent activity" invariant if the parsed timestamp is
|
|
959
|
+
already newer than ``when`` (shouldn't happen in practice, but
|
|
960
|
+
defensive).
|
|
961
|
+
|
|
962
|
+
Returns the value actually stored (or ``None`` if the session
|
|
963
|
+
row does not exist).
|
|
964
|
+
"""
|
|
965
|
+
assert self._conn is not None
|
|
966
|
+
if when is None:
|
|
967
|
+
when = datetime.now().astimezone()
|
|
968
|
+
when_str = _dt_to_str(when)
|
|
969
|
+
async with self._conn.execute(
|
|
970
|
+
"SELECT updated_at FROM sessions WHERE id = ?", (session_id,)
|
|
971
|
+
) as cursor:
|
|
972
|
+
row = await cursor.fetchone()
|
|
973
|
+
if row is None:
|
|
974
|
+
return None
|
|
975
|
+
current = _str_to_dt(row["updated_at"])
|
|
976
|
+
# Only advance — never regress.
|
|
977
|
+
if current is not None and current.tzinfo is None:
|
|
978
|
+
current = current.replace(tzinfo=when.tzinfo)
|
|
979
|
+
if current is not None and current >= when:
|
|
980
|
+
return current
|
|
981
|
+
await self._conn.execute(
|
|
982
|
+
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
|
983
|
+
(when_str, session_id),
|
|
984
|
+
)
|
|
985
|
+
await self._conn.commit()
|
|
986
|
+
return when
|
|
987
|
+
|
|
988
|
+
# -----------------------------------------------------------------------
|
|
989
|
+
# Session metadata override (rename — issue #11)
|
|
990
|
+
# -----------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
async def set_custom_title(
|
|
993
|
+
self, session_id: str, title: Optional[str]
|
|
994
|
+
) -> Optional[str]:
|
|
995
|
+
"""Upsert (or clear) the user-supplied title for a session.
|
|
996
|
+
|
|
997
|
+
Passing ``None`` deletes the row, restoring the parsed title. Returns
|
|
998
|
+
the normalised value actually stored (None when cleared).
|
|
999
|
+
"""
|
|
1000
|
+
assert self._conn is not None
|
|
1001
|
+
normalised: Optional[str] = None
|
|
1002
|
+
if title is not None:
|
|
1003
|
+
stripped = title.strip()
|
|
1004
|
+
normalised = stripped or None
|
|
1005
|
+
if normalised is None:
|
|
1006
|
+
await self._conn.execute(
|
|
1007
|
+
"DELETE FROM session_meta WHERE session_id = ?", (session_id,)
|
|
1008
|
+
)
|
|
1009
|
+
else:
|
|
1010
|
+
await self._conn.execute(
|
|
1011
|
+
"""
|
|
1012
|
+
INSERT INTO session_meta (session_id, custom_title, updated_at)
|
|
1013
|
+
VALUES (?, ?, ?)
|
|
1014
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1015
|
+
custom_title = excluded.custom_title,
|
|
1016
|
+
updated_at = excluded.updated_at
|
|
1017
|
+
""",
|
|
1018
|
+
(session_id, normalised, datetime.now().isoformat()),
|
|
1019
|
+
)
|
|
1020
|
+
await self._conn.commit()
|
|
1021
|
+
return normalised
|
|
1022
|
+
|
|
1023
|
+
async def get_custom_title(self, session_id: str) -> Optional[str]:
|
|
1024
|
+
"""Return the stored override for ``session_id`` or None if absent."""
|
|
1025
|
+
assert self._conn is not None
|
|
1026
|
+
async with self._conn.execute(
|
|
1027
|
+
"SELECT custom_title FROM session_meta WHERE session_id = ?",
|
|
1028
|
+
(session_id,),
|
|
1029
|
+
) as cursor:
|
|
1030
|
+
row = await cursor.fetchone()
|
|
1031
|
+
return row["custom_title"] if row else None
|
|
1032
|
+
|
|
1033
|
+
# -----------------------------------------------------------------------
|
|
1034
|
+
# Session flags — archived / starred / viewed
|
|
1035
|
+
#
|
|
1036
|
+
# All three live in ``session_meta`` as nullable ISO timestamps so the FE
|
|
1037
|
+
# gets the same shape it expects (boolean for archived/starred, optional
|
|
1038
|
+
# timestamp for viewed-at). Originally localStorage-only — moved
|
|
1039
|
+
# server-side so a second browser sees consistent state.
|
|
1040
|
+
# -----------------------------------------------------------------------
|
|
1041
|
+
|
|
1042
|
+
async def _set_meta_flag(
|
|
1043
|
+
self, session_id: str, column: str, value: Optional[str]
|
|
1044
|
+
) -> Optional[str]:
|
|
1045
|
+
"""Internal: upsert a single nullable timestamp column on session_meta.
|
|
1046
|
+
|
|
1047
|
+
``column`` is validated against a fixed allow-list to keep this off
|
|
1048
|
+
the user-input attack surface.
|
|
1049
|
+
"""
|
|
1050
|
+
assert self._conn is not None
|
|
1051
|
+
if column not in {"archived_at", "starred_at", "viewed_at"}:
|
|
1052
|
+
raise ValueError(f"unsupported session_meta column: {column!r}")
|
|
1053
|
+
now_iso = datetime.now().isoformat()
|
|
1054
|
+
# Use a parameterised query for the value; the column name is f-string
|
|
1055
|
+
# interpolated but allow-listed above. ON CONFLICT preserves the
|
|
1056
|
+
# other flag columns + custom_title — only the target column updates.
|
|
1057
|
+
await self._conn.execute(
|
|
1058
|
+
f"""
|
|
1059
|
+
INSERT INTO session_meta (session_id, {column}, updated_at)
|
|
1060
|
+
VALUES (?, ?, ?)
|
|
1061
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1062
|
+
{column} = excluded.{column},
|
|
1063
|
+
updated_at = excluded.updated_at
|
|
1064
|
+
""",
|
|
1065
|
+
(session_id, value, now_iso),
|
|
1066
|
+
)
|
|
1067
|
+
await self._conn.commit()
|
|
1068
|
+
return value
|
|
1069
|
+
|
|
1070
|
+
async def set_archived(self, session_id: str, archived: bool) -> Optional[str]:
|
|
1071
|
+
"""Mark a session archived (True) or unarchive it (False).
|
|
1072
|
+
|
|
1073
|
+
Returns the stored timestamp (ISO string when archived, None when
|
|
1074
|
+
cleared). An unarchive preserves the rest of the session_meta row
|
|
1075
|
+
(custom_title, starred_at, viewed_at).
|
|
1076
|
+
"""
|
|
1077
|
+
value = datetime.now().isoformat() if archived else None
|
|
1078
|
+
return await self._set_meta_flag(session_id, "archived_at", value)
|
|
1079
|
+
|
|
1080
|
+
async def set_starred(self, session_id: str, starred: bool) -> Optional[str]:
|
|
1081
|
+
"""Mark a session starred (True) or unstar it (False)."""
|
|
1082
|
+
value = datetime.now().isoformat() if starred else None
|
|
1083
|
+
return await self._set_meta_flag(session_id, "starred_at", value)
|
|
1084
|
+
|
|
1085
|
+
async def set_viewed_at(
|
|
1086
|
+
self, session_id: str, viewed_at: Optional[str]
|
|
1087
|
+
) -> Optional[str]:
|
|
1088
|
+
"""Record when the session was last viewed by the user.
|
|
1089
|
+
|
|
1090
|
+
Unlike archived/starred this accepts an explicit timestamp because the
|
|
1091
|
+
bell-dismiss logic wants to record "viewed at message_updated_at"
|
|
1092
|
+
rather than "viewed at now". Pass ``None`` to clear (mark unread).
|
|
1093
|
+
"""
|
|
1094
|
+
return await self._set_meta_flag(session_id, "viewed_at", viewed_at)
|
|
1095
|
+
|
|
1096
|
+
async def get_session_meta(self, session_id: str) -> dict:
|
|
1097
|
+
"""Return the full session_meta row for ``session_id`` as a dict.
|
|
1098
|
+
|
|
1099
|
+
Returns ``{"custom_title": None, "archived_at": None, "starred_at": None,
|
|
1100
|
+
"viewed_at": None}`` if no row exists — callers can check truthiness
|
|
1101
|
+
on each field rather than handling missing-row separately.
|
|
1102
|
+
"""
|
|
1103
|
+
assert self._conn is not None
|
|
1104
|
+
async with self._conn.execute(
|
|
1105
|
+
"""
|
|
1106
|
+
SELECT custom_title, archived_at, starred_at, viewed_at
|
|
1107
|
+
FROM session_meta WHERE session_id = ?
|
|
1108
|
+
""",
|
|
1109
|
+
(session_id,),
|
|
1110
|
+
) as cursor:
|
|
1111
|
+
row = await cursor.fetchone()
|
|
1112
|
+
if row is None:
|
|
1113
|
+
return {
|
|
1114
|
+
"custom_title": None,
|
|
1115
|
+
"archived_at": None,
|
|
1116
|
+
"starred_at": None,
|
|
1117
|
+
"viewed_at": None,
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
"custom_title": row["custom_title"],
|
|
1121
|
+
"archived_at": row["archived_at"],
|
|
1122
|
+
"starred_at": row["starred_at"],
|
|
1123
|
+
"viewed_at": row["viewed_at"],
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
# -----------------------------------------------------------------------
|
|
1127
|
+
# Messages
|
|
1128
|
+
# -----------------------------------------------------------------------
|
|
1129
|
+
|
|
1130
|
+
async def upsert_messages(self, messages: list[Message]) -> None:
|
|
1131
|
+
"""Incrementally upsert one session's messages + FTS/block-fact indexes.
|
|
1132
|
+
|
|
1133
|
+
Only rows whose persisted content actually changed since the last index
|
|
1134
|
+
are rewritten — detected by comparing a per-row ``content_hash`` against
|
|
1135
|
+
the stored one — instead of rewriting every row and rebuilding the whole
|
|
1136
|
+
session's FTS on each re-parse. On a large, mostly-appended session this
|
|
1137
|
+
turns a full O(N) rewrite (dominated by re-writing every content_json
|
|
1138
|
+
blob) into an O(changed) delta.
|
|
1139
|
+
|
|
1140
|
+
Correctness:
|
|
1141
|
+
- New rows and rows whose hash differs (content edits, streaming
|
|
1142
|
+
completion, usage/token backfill, model/flag changes) are re-upserted.
|
|
1143
|
+
- Rows that vanished from the session (compaction/history rewrite) are
|
|
1144
|
+
hard-deleted, along with their FTS + block-fact entries.
|
|
1145
|
+
- Rows with a stored hash of NULL (pre-``content_hash``-migration) always
|
|
1146
|
+
count as changed, so the first re-parse after migrating backfills the
|
|
1147
|
+
column — a freshly-indexed or just-migrated session behaves exactly
|
|
1148
|
+
like the old bulk path.
|
|
1149
|
+
The upsert is UPDATE-in-place (ON CONFLICT), preserving each unchanged
|
|
1150
|
+
row's rowid so the ``timestamp ASC, rowid ASC`` ordering stays stable.
|
|
1151
|
+
"""
|
|
1152
|
+
assert self._conn is not None
|
|
1153
|
+
if not messages:
|
|
1154
|
+
return
|
|
1155
|
+
|
|
1156
|
+
# All messages belong to one session.
|
|
1157
|
+
session_id = messages[0].session_id
|
|
1158
|
+
|
|
1159
|
+
# Build each row once and hash it; diff against the stored hashes.
|
|
1160
|
+
rows = {m.id: _message_row(m) for m in messages}
|
|
1161
|
+
hashes = {mid: _row_hash(row) for mid, row in rows.items()}
|
|
1162
|
+
|
|
1163
|
+
async with self._conn.execute(
|
|
1164
|
+
"SELECT id, content_hash FROM messages WHERE session_id = ?",
|
|
1165
|
+
(session_id,),
|
|
1166
|
+
) as cur:
|
|
1167
|
+
stored = {r["id"]: r["content_hash"] for r in await cur.fetchall()}
|
|
1168
|
+
|
|
1169
|
+
changed = [mid for mid, h in hashes.items() if stored.get(mid) != h]
|
|
1170
|
+
removed = [mid for mid in stored if mid not in rows]
|
|
1171
|
+
|
|
1172
|
+
if not changed and not removed:
|
|
1173
|
+
return # pure no-op re-parse — nothing to write
|
|
1174
|
+
|
|
1175
|
+
# 1. Upsert only the changed/new rows (content_hash appended per column order).
|
|
1176
|
+
if changed:
|
|
1177
|
+
await self._conn.executemany(
|
|
1178
|
+
_UPSERT_MESSAGE_SQL,
|
|
1179
|
+
[rows[mid] + (hashes[mid],) for mid in changed],
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
# 2. Drop FTS + block facts for every touched id, then re-insert for the
|
|
1183
|
+
# changed ones. message_id is UNINDEXED in fts5, so deleting by it
|
|
1184
|
+
# would force a full VIRTUAL TABLE SCAN of the whole corpus on every
|
|
1185
|
+
# turn (see migrate_fts_rowid_v1) — instead delete/insert by rowid,
|
|
1186
|
+
# kept aligned 1:1 with messages.rowid, which fts5 indexes natively.
|
|
1187
|
+
touched = changed + removed
|
|
1188
|
+
rowid_map: dict[str, int] = {}
|
|
1189
|
+
for i in range(0, len(touched), 500):
|
|
1190
|
+
chunk = touched[i : i + 500]
|
|
1191
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1192
|
+
select_sql = (
|
|
1193
|
+
"SELECT id, rowid FROM messages WHERE id IN (" + placeholders + ")"
|
|
1194
|
+
)
|
|
1195
|
+
async with self._conn.execute(select_sql, chunk) as cur:
|
|
1196
|
+
rowid_map.update({r["id"]: r["rowid"] for r in await cur.fetchall()})
|
|
1197
|
+
|
|
1198
|
+
touched_rowids = [rowid_map[mid] for mid in touched if mid in rowid_map]
|
|
1199
|
+
for i in range(0, len(touched_rowids), 500):
|
|
1200
|
+
chunk = touched_rowids[i : i + 500]
|
|
1201
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1202
|
+
delete_fts_sql = (
|
|
1203
|
+
"DELETE FROM messages_fts WHERE rowid IN (" + placeholders + ")"
|
|
1204
|
+
)
|
|
1205
|
+
await self._conn.execute(delete_fts_sql, chunk)
|
|
1206
|
+
for i in range(0, len(touched), 500):
|
|
1207
|
+
chunk = touched[i : i + 500]
|
|
1208
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1209
|
+
delete_blocks_sql = (
|
|
1210
|
+
"DELETE FROM message_blocks WHERE message_id IN (" + placeholders + ")"
|
|
1211
|
+
)
|
|
1212
|
+
await self._conn.execute(delete_blocks_sql, chunk)
|
|
1213
|
+
|
|
1214
|
+
by_id = {m.id: m for m in messages}
|
|
1215
|
+
fts_rows: list[tuple] = []
|
|
1216
|
+
block_rows: list[tuple] = []
|
|
1217
|
+
for mid in changed:
|
|
1218
|
+
m = by_id[mid]
|
|
1219
|
+
text = _extract_text_for_fts(m.content_blocks)
|
|
1220
|
+
if text.strip():
|
|
1221
|
+
fts_rows.append((rowid_map[mid], text, m.session_id, m.id, m.role))
|
|
1222
|
+
block_rows.extend(_block_facts(m.id, m.session_id, m.content_blocks))
|
|
1223
|
+
|
|
1224
|
+
if fts_rows:
|
|
1225
|
+
await self._conn.executemany(
|
|
1226
|
+
"INSERT INTO messages_fts (rowid, content, session_id, message_id, role) "
|
|
1227
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
1228
|
+
fts_rows,
|
|
1229
|
+
)
|
|
1230
|
+
if block_rows:
|
|
1231
|
+
await self._conn.executemany(
|
|
1232
|
+
"INSERT OR REPLACE INTO message_blocks (message_id, session_id, "
|
|
1233
|
+
"block_index, btype, tool_name, file_path, result_chars, tool_use_id) "
|
|
1234
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1235
|
+
block_rows,
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
# 3. Hard-delete rows that vanished from the session (compaction/rewrite).
|
|
1239
|
+
for i in range(0, len(removed), 500):
|
|
1240
|
+
chunk = removed[i : i + 500]
|
|
1241
|
+
placeholders = ",".join("?" * len(chunk))
|
|
1242
|
+
await self._conn.execute(
|
|
1243
|
+
f"DELETE FROM messages WHERE id IN ({placeholders})", chunk
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
await self._conn.commit()
|
|
1247
|
+
|
|
1248
|
+
async def get_session_detail_json_bytes(self, session_id: str) -> Optional[bytes]:
|
|
1249
|
+
"""Build the SessionDetail JSON response without going through Pydantic.
|
|
1250
|
+
|
|
1251
|
+
Embeds the stored ``content_json`` and ``usage_json`` strings directly
|
|
1252
|
+
as JSON fragments, skipping the per-message parse + Pydantic construct
|
|
1253
|
+
+ Pydantic dump cycle that dominates response time on large chats.
|
|
1254
|
+
Used for the unlimited (no ``message_limit``) hot path.
|
|
1255
|
+
"""
|
|
1256
|
+
assert self._conn is not None
|
|
1257
|
+
|
|
1258
|
+
async with self._conn.execute(
|
|
1259
|
+
"""SELECT s.*,
|
|
1260
|
+
(SELECT role FROM messages WHERE session_id = s.id
|
|
1261
|
+
ORDER BY timestamp DESC LIMIT 1) AS last_message_role,
|
|
1262
|
+
sm.custom_title AS custom_title,
|
|
1263
|
+
sm.archived_at AS archived_at,
|
|
1264
|
+
sm.starred_at AS starred_at,
|
|
1265
|
+
sm.viewed_at AS viewed_at
|
|
1266
|
+
FROM sessions s
|
|
1267
|
+
LEFT JOIN session_meta sm ON sm.session_id = s.id
|
|
1268
|
+
WHERE s.id = ?""",
|
|
1269
|
+
(session_id,),
|
|
1270
|
+
) as cur:
|
|
1271
|
+
srow = await cur.fetchone()
|
|
1272
|
+
if srow is None:
|
|
1273
|
+
return None
|
|
1274
|
+
|
|
1275
|
+
async with self._conn.execute(
|
|
1276
|
+
# Tie-break by rowid (insertion order = JSONL file order) so a
|
|
1277
|
+
# thinking + text pair claude writes at the same ms timestamp
|
|
1278
|
+
# surfaces in the order the model produced them. Without this,
|
|
1279
|
+
# SQLite's tie-break is non-deterministic and the UI's
|
|
1280
|
+
# "is-session-active" heuristic (which inspects the LAST message
|
|
1281
|
+
# of a turn) can latch the indicator on permanently when the
|
|
1282
|
+
# trailing message happens to be a thinking-only one.
|
|
1283
|
+
"SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp ASC, rowid ASC",
|
|
1284
|
+
(session_id,),
|
|
1285
|
+
) as cur:
|
|
1286
|
+
mrows = await cur.fetchall()
|
|
1287
|
+
|
|
1288
|
+
session_dict = {
|
|
1289
|
+
"id": srow["id"],
|
|
1290
|
+
"project_id": srow["project_id"],
|
|
1291
|
+
"file_path": srow["file_path"],
|
|
1292
|
+
"title": srow["title"],
|
|
1293
|
+
"custom_title": srow["custom_title"],
|
|
1294
|
+
"archived_at": srow["archived_at"]
|
|
1295
|
+
if "archived_at" in srow.keys()
|
|
1296
|
+
else None,
|
|
1297
|
+
"starred_at": srow["starred_at"] if "starred_at" in srow.keys() else None,
|
|
1298
|
+
"viewed_at": srow["viewed_at"] if "viewed_at" in srow.keys() else None,
|
|
1299
|
+
"model": srow["model"],
|
|
1300
|
+
"started_at": srow["started_at"],
|
|
1301
|
+
"updated_at": srow["updated_at"],
|
|
1302
|
+
"message_count": srow["message_count"],
|
|
1303
|
+
"user_message_count": srow["user_message_count"],
|
|
1304
|
+
"cwd": srow["cwd"],
|
|
1305
|
+
"git_branch": srow["git_branch"],
|
|
1306
|
+
"is_worktree": bool(srow["is_worktree"]),
|
|
1307
|
+
"is_fork": bool(srow["is_fork"]) if "is_fork" in srow.keys() else False,
|
|
1308
|
+
"permission_mode": srow["permission_mode"],
|
|
1309
|
+
"provider": srow["provider"] if "provider" in srow.keys() else "claude",
|
|
1310
|
+
"last_message_role": srow["last_message_role"],
|
|
1311
|
+
# Live filesystem check, NOT a cached DB value. The previous
|
|
1312
|
+
# version read projects.resolved_path, which is set once at
|
|
1313
|
+
# scan time — it stayed stale (False) long after a removable
|
|
1314
|
+
# volume was remounted or a directory recreated. The cost is
|
|
1315
|
+
# one isdir() syscall per session-detail request, which is
|
|
1316
|
+
# cheap compared to the rest of the response build.
|
|
1317
|
+
"cwd_exists": (True if not srow["cwd"] else Path(srow["cwd"]).is_dir()),
|
|
1318
|
+
}
|
|
1319
|
+
session_bytes = json.dumps(
|
|
1320
|
+
session_dict, ensure_ascii=False, separators=(",", ":")
|
|
1321
|
+
).encode("utf-8")
|
|
1322
|
+
|
|
1323
|
+
parts: list[bytes] = [session_bytes[:-1], b',"messages":[']
|
|
1324
|
+
for i, m in enumerate(mrows):
|
|
1325
|
+
if i > 0:
|
|
1326
|
+
parts.append(b",")
|
|
1327
|
+
header = {
|
|
1328
|
+
"id": m["id"],
|
|
1329
|
+
"session_id": m["session_id"],
|
|
1330
|
+
"parent_id": m["parent_id"],
|
|
1331
|
+
"role": m["role"],
|
|
1332
|
+
"timestamp": m["timestamp"],
|
|
1333
|
+
"model": m["model"],
|
|
1334
|
+
"is_sidechain": bool(m["is_sidechain"]),
|
|
1335
|
+
"is_meta": bool(m["is_meta"]),
|
|
1336
|
+
"cwd": m["cwd"],
|
|
1337
|
+
"git_branch": m["git_branch"],
|
|
1338
|
+
"request_id": None,
|
|
1339
|
+
"source_tool_assistant_uuid": m["source_tool_assistant_uuid"],
|
|
1340
|
+
}
|
|
1341
|
+
header_bytes = json.dumps(
|
|
1342
|
+
header, ensure_ascii=False, separators=(",", ":")
|
|
1343
|
+
).encode("utf-8")
|
|
1344
|
+
cb = (m["content_json"] or "[]").encode("utf-8")
|
|
1345
|
+
usage = (m["usage_json"] or "null").encode("utf-8")
|
|
1346
|
+
parts.append(header_bytes[:-1])
|
|
1347
|
+
parts.append(b',"content_blocks":')
|
|
1348
|
+
parts.append(cb)
|
|
1349
|
+
parts.append(b',"usage":')
|
|
1350
|
+
parts.append(usage)
|
|
1351
|
+
parts.append(b"}")
|
|
1352
|
+
parts.append(b'],"total_message_count":null}')
|
|
1353
|
+
|
|
1354
|
+
return b"".join(parts)
|
|
1355
|
+
|
|
1356
|
+
async def get_session_detail(
|
|
1357
|
+
self, session_id: str, message_limit: Optional[int] = None
|
|
1358
|
+
) -> Optional[SessionDetail]:
|
|
1359
|
+
assert self._conn is not None
|
|
1360
|
+
|
|
1361
|
+
# Fetch the session row (include last_message_role subquery for consistency)
|
|
1362
|
+
async with self._conn.execute(
|
|
1363
|
+
"""SELECT s.*,
|
|
1364
|
+
(SELECT role FROM messages WHERE session_id = s.id
|
|
1365
|
+
ORDER BY timestamp DESC LIMIT 1) AS last_message_role,
|
|
1366
|
+
sm.custom_title AS custom_title,
|
|
1367
|
+
sm.archived_at AS archived_at,
|
|
1368
|
+
sm.starred_at AS starred_at,
|
|
1369
|
+
sm.viewed_at AS viewed_at
|
|
1370
|
+
FROM sessions s
|
|
1371
|
+
LEFT JOIN session_meta sm ON sm.session_id = s.id
|
|
1372
|
+
WHERE s.id = ?""",
|
|
1373
|
+
(session_id,),
|
|
1374
|
+
) as cursor:
|
|
1375
|
+
session_row = await cursor.fetchone()
|
|
1376
|
+
|
|
1377
|
+
if session_row is None:
|
|
1378
|
+
return None
|
|
1379
|
+
|
|
1380
|
+
# Count total messages for truncation info
|
|
1381
|
+
total_count: Optional[int] = None
|
|
1382
|
+
if message_limit is not None:
|
|
1383
|
+
async with self._conn.execute(
|
|
1384
|
+
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
|
|
1385
|
+
) as cursor:
|
|
1386
|
+
row = await cursor.fetchone()
|
|
1387
|
+
total_count = row[0]
|
|
1388
|
+
|
|
1389
|
+
# Fetch messages ordered by timestamp, optionally capped to last N
|
|
1390
|
+
if (
|
|
1391
|
+
message_limit is not None
|
|
1392
|
+
and total_count is not None
|
|
1393
|
+
and total_count > message_limit
|
|
1394
|
+
):
|
|
1395
|
+
# Fetch last N messages by using a subquery to get the tail
|
|
1396
|
+
query = (
|
|
1397
|
+
"SELECT * FROM messages WHERE session_id = ? "
|
|
1398
|
+
"ORDER BY timestamp DESC LIMIT ?"
|
|
1399
|
+
)
|
|
1400
|
+
async with self._conn.execute(query, (session_id, message_limit)) as cursor:
|
|
1401
|
+
msg_rows = await cursor.fetchall()
|
|
1402
|
+
# Reverse back to chronological order
|
|
1403
|
+
msg_rows = list(reversed(msg_rows))
|
|
1404
|
+
else:
|
|
1405
|
+
total_count = None # no truncation needed
|
|
1406
|
+
async with self._conn.execute(
|
|
1407
|
+
# See get_session_detail's matching ORDER BY — same tie-break.
|
|
1408
|
+
"SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp ASC, rowid ASC",
|
|
1409
|
+
(session_id,),
|
|
1410
|
+
) as cursor:
|
|
1411
|
+
msg_rows = await cursor.fetchall()
|
|
1412
|
+
|
|
1413
|
+
messages = [_row_to_message(row) for row in msg_rows]
|
|
1414
|
+
|
|
1415
|
+
session = _row_to_session(session_row)
|
|
1416
|
+
# Live filesystem check — see ``get_session_detail_json_bytes`` for
|
|
1417
|
+
# the rationale (stale projects.resolved_path was reporting cwd
|
|
1418
|
+
# missing on volumes that had since been remounted).
|
|
1419
|
+
cwd_exists = True if not session.cwd else Path(session.cwd).is_dir()
|
|
1420
|
+
return SessionDetail(
|
|
1421
|
+
**session.model_dump(),
|
|
1422
|
+
messages=messages,
|
|
1423
|
+
total_message_count=total_count,
|
|
1424
|
+
cwd_exists=cwd_exists,
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
async def delete_message(self, message_id: str) -> None:
|
|
1428
|
+
"""Delete a message, its FTS entry, and update the parent session's message counts."""
|
|
1429
|
+
assert self._conn is not None
|
|
1430
|
+
async with self._conn.execute(
|
|
1431
|
+
"SELECT session_id FROM messages WHERE id = ?", (message_id,)
|
|
1432
|
+
) as cursor:
|
|
1433
|
+
row = await cursor.fetchone()
|
|
1434
|
+
async with self._conn.execute(
|
|
1435
|
+
"SELECT rowid FROM messages_fts WHERE message_id = ?", (message_id,)
|
|
1436
|
+
) as cursor:
|
|
1437
|
+
fts_rows = await cursor.fetchall()
|
|
1438
|
+
for fts_row in fts_rows:
|
|
1439
|
+
await self._conn.execute(
|
|
1440
|
+
"DELETE FROM messages_fts WHERE rowid = ?", (fts_row[0],)
|
|
1441
|
+
)
|
|
1442
|
+
await self._conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
|
1443
|
+
if row:
|
|
1444
|
+
session_id = row["session_id"]
|
|
1445
|
+
await self._conn.execute(
|
|
1446
|
+
"""UPDATE sessions SET
|
|
1447
|
+
message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?),
|
|
1448
|
+
user_message_count = (
|
|
1449
|
+
SELECT COUNT(*) FROM messages
|
|
1450
|
+
WHERE session_id = ? AND role = 'user' AND is_meta = 0
|
|
1451
|
+
)
|
|
1452
|
+
WHERE id = ?""",
|
|
1453
|
+
(session_id, session_id, session_id),
|
|
1454
|
+
)
|
|
1455
|
+
await self._conn.commit()
|
|
1456
|
+
|
|
1457
|
+
async def delete_session_messages(self, session_id: str) -> None:
|
|
1458
|
+
"""Delete all messages (and their FTS entries) for a session."""
|
|
1459
|
+
assert self._conn is not None
|
|
1460
|
+
await self._conn.execute(
|
|
1461
|
+
"DELETE FROM messages_fts WHERE session_id = ?", (session_id,)
|
|
1462
|
+
)
|
|
1463
|
+
await self._conn.execute(
|
|
1464
|
+
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
|
1465
|
+
)
|
|
1466
|
+
await self._conn.commit()
|
|
1467
|
+
|
|
1468
|
+
async def delete_session(self, session_id: str) -> bool:
|
|
1469
|
+
"""Hard-delete a session: its messages, FTS entries, recaps,
|
|
1470
|
+
session_meta override, and the session row itself — all in one
|
|
1471
|
+
transaction.
|
|
1472
|
+
|
|
1473
|
+
Returns True if a session row was actually deleted, False if the
|
|
1474
|
+
session did not exist in the DB (idempotent: caller may still want
|
|
1475
|
+
to unlink the on-disk file).
|
|
1476
|
+
"""
|
|
1477
|
+
assert self._conn is not None
|
|
1478
|
+
# Check existence first so we can return an accurate bool without
|
|
1479
|
+
# relying on rowcount after the DELETE (which is always 0-or-1 here).
|
|
1480
|
+
async with self._conn.execute(
|
|
1481
|
+
"SELECT id FROM sessions WHERE id = ?", (session_id,)
|
|
1482
|
+
) as cursor:
|
|
1483
|
+
exists = await cursor.fetchone() is not None
|
|
1484
|
+
|
|
1485
|
+
# Delete child rows in dependency order before the parent session row.
|
|
1486
|
+
await self._conn.execute(
|
|
1487
|
+
"DELETE FROM messages_fts WHERE session_id = ?", (session_id,)
|
|
1488
|
+
)
|
|
1489
|
+
await self._conn.execute(
|
|
1490
|
+
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
|
1491
|
+
)
|
|
1492
|
+
await self._conn.execute(
|
|
1493
|
+
"DELETE FROM recaps WHERE session_id = ?", (session_id,)
|
|
1494
|
+
)
|
|
1495
|
+
# ephemeral_messages and its FTS table — the BEFORE DELETE trigger
|
|
1496
|
+
# on ephemeral_messages keeps ephemeral_messages_fts in sync, so we
|
|
1497
|
+
# only DELETE the parent rows here. PRAGMA foreign_keys is off
|
|
1498
|
+
# project-wide; without this explicit DELETE the rows would orphan
|
|
1499
|
+
# on session-delete (Phase 2 live-smoke finding).
|
|
1500
|
+
await self._conn.execute(
|
|
1501
|
+
"DELETE FROM ephemeral_messages WHERE session_id = ?", (session_id,)
|
|
1502
|
+
)
|
|
1503
|
+
await self._conn.execute(
|
|
1504
|
+
"DELETE FROM session_meta WHERE session_id = ?", (session_id,)
|
|
1505
|
+
)
|
|
1506
|
+
await self._conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
1507
|
+
await self._conn.commit()
|
|
1508
|
+
return exists
|
|
1509
|
+
|
|
1510
|
+
async def update_message_content(
|
|
1511
|
+
self, message_id: str, new_blocks: list[ContentBlock]
|
|
1512
|
+
) -> None:
|
|
1513
|
+
"""Replace a message's content_blocks in DB and rebuild its FTS entry."""
|
|
1514
|
+
assert self._conn is not None
|
|
1515
|
+
content_json = _serialize_content_blocks(new_blocks)
|
|
1516
|
+
await self._conn.execute(
|
|
1517
|
+
"UPDATE messages SET content_json = ? WHERE id = ?",
|
|
1518
|
+
(content_json, message_id),
|
|
1519
|
+
)
|
|
1520
|
+
async with self._conn.execute(
|
|
1521
|
+
"SELECT rowid FROM messages_fts WHERE message_id = ?", (message_id,)
|
|
1522
|
+
) as cursor:
|
|
1523
|
+
fts_rows = await cursor.fetchall()
|
|
1524
|
+
for fts_row in fts_rows:
|
|
1525
|
+
await self._conn.execute(
|
|
1526
|
+
"DELETE FROM messages_fts WHERE rowid = ?", (fts_row[0],)
|
|
1527
|
+
)
|
|
1528
|
+
fts_text = _extract_text_for_fts(new_blocks)
|
|
1529
|
+
async with self._conn.execute(
|
|
1530
|
+
"SELECT rowid, session_id, role FROM messages WHERE id = ?", (message_id,)
|
|
1531
|
+
) as cursor:
|
|
1532
|
+
row = await cursor.fetchone()
|
|
1533
|
+
if row and fts_text.strip():
|
|
1534
|
+
# Insert with an explicit rowid matching messages.rowid, keeping
|
|
1535
|
+
# the alignment migrate_fts_rowid_v1 establishes — otherwise this
|
|
1536
|
+
# row would fall back to needing a full-corpus scan to find again.
|
|
1537
|
+
await self._conn.execute(
|
|
1538
|
+
"INSERT INTO messages_fts (rowid, content, session_id, message_id, role) "
|
|
1539
|
+
"VALUES (?,?,?,?,?)",
|
|
1540
|
+
(row["rowid"], fts_text, row["session_id"], message_id, row["role"]),
|
|
1541
|
+
)
|
|
1542
|
+
await self._conn.commit()
|
|
1543
|
+
|
|
1544
|
+
async def get_session_file_path_for_message(self, message_id: str) -> str | None:
|
|
1545
|
+
"""Return the file_path of the session that owns the given message."""
|
|
1546
|
+
result = await self.get_session_info_for_message(message_id)
|
|
1547
|
+
return result[1] if result else None
|
|
1548
|
+
|
|
1549
|
+
async def get_session_info_for_message(
|
|
1550
|
+
self, message_id: str
|
|
1551
|
+
) -> tuple[str, str] | None:
|
|
1552
|
+
"""Return ``(session_id, file_path)`` for the session owning the message."""
|
|
1553
|
+
assert self._conn is not None
|
|
1554
|
+
async with self._conn.execute(
|
|
1555
|
+
"""SELECT s.id, s.file_path FROM sessions s
|
|
1556
|
+
JOIN messages m ON m.session_id = s.id
|
|
1557
|
+
WHERE m.id = ?""",
|
|
1558
|
+
(message_id,),
|
|
1559
|
+
) as cursor:
|
|
1560
|
+
row = await cursor.fetchone()
|
|
1561
|
+
return (row["id"], row["file_path"]) if row else None
|
|
1562
|
+
|
|
1563
|
+
async def get_all_messages(self) -> list[Message]:
|
|
1564
|
+
"""Fetch all messages from all sessions ordered by timestamp."""
|
|
1565
|
+
assert self._conn is not None
|
|
1566
|
+
async with self._conn.execute(
|
|
1567
|
+
"SELECT * FROM messages ORDER BY timestamp ASC, rowid ASC"
|
|
1568
|
+
) as cursor:
|
|
1569
|
+
rows = await cursor.fetchall()
|
|
1570
|
+
return [_row_to_message(row) for row in rows]
|
|
1571
|
+
|
|
1572
|
+
async def analytics_signature(self) -> tuple:
|
|
1573
|
+
"""Cheap fingerprint of the message corpus for analytics caching.
|
|
1574
|
+
|
|
1575
|
+
Computed over the small ``sessions`` table (one row per session),
|
|
1576
|
+
so it stays sub-millisecond regardless of message count. The tuple
|
|
1577
|
+
changes whenever a session is added, removed, or re-indexed — which
|
|
1578
|
+
is exactly when the all-message analytics need recomputing:
|
|
1579
|
+
- ``COUNT(*)`` → session added/removed
|
|
1580
|
+
- ``SUM(message_count)`` → messages appended within a session
|
|
1581
|
+
- ``MAX(file_mtime)`` → a session's JSONL was re-parsed
|
|
1582
|
+
- ``MAX(updated_at)`` → activity timestamp moved
|
|
1583
|
+
"""
|
|
1584
|
+
assert self._conn is not None
|
|
1585
|
+
async with self._conn.execute(
|
|
1586
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(message_count), 0) AS c, "
|
|
1587
|
+
"MAX(file_mtime) AS m, MAX(updated_at) AS u FROM sessions"
|
|
1588
|
+
) as cursor:
|
|
1589
|
+
row = await cursor.fetchone()
|
|
1590
|
+
return (row["n"], row["c"], row["m"], row["u"])
|
|
1591
|
+
|
|
1592
|
+
# -----------------------------------------------------------------------
|
|
1593
|
+
# Search
|
|
1594
|
+
# -----------------------------------------------------------------------
|
|
1595
|
+
|
|
1596
|
+
async def search(
|
|
1597
|
+
self,
|
|
1598
|
+
query: str,
|
|
1599
|
+
project_id: Optional[str] = None,
|
|
1600
|
+
limit: int = 50,
|
|
1601
|
+
offset: int = 0,
|
|
1602
|
+
) -> list[SearchHit]:
|
|
1603
|
+
assert self._conn is not None
|
|
1604
|
+
|
|
1605
|
+
# Each sub-query (messages/ephemeral) is sorted by timestamp DESC
|
|
1606
|
+
# independently, then merged and re-sorted below. To page correctly
|
|
1607
|
+
# past `offset`, each sub-query must fetch enough rows to cover the
|
|
1608
|
+
# requested page, not just `limit` from its own start.
|
|
1609
|
+
fetch_size = offset + limit
|
|
1610
|
+
|
|
1611
|
+
# FTS5 treats `-`, `"`, `:`, etc. as operators. Coerce user input to
|
|
1612
|
+
# space-separated word tokens so queries like "eye-candy" or "a:b" are
|
|
1613
|
+
# interpreted as ordinary terms (AND'd by default), not column filters
|
|
1614
|
+
# or NOT operators. This is a search-quality fix, not a security fix.
|
|
1615
|
+
# The same sanitiser is applied to both messages and ephemeral queries.
|
|
1616
|
+
import re as _re
|
|
1617
|
+
|
|
1618
|
+
sanitized = _re.sub(r"[^\w\s]", " ", query).strip()
|
|
1619
|
+
if not sanitized:
|
|
1620
|
+
return []
|
|
1621
|
+
match_query = sanitized
|
|
1622
|
+
|
|
1623
|
+
# Strategy (b): run two separate queries (messages_fts and
|
|
1624
|
+
# ephemeral_messages_fts) and merge in Python. This keeps the SQL
|
|
1625
|
+
# simple (avoids a UNION between tables with different column shapes)
|
|
1626
|
+
# and lets us attach source/kind/responds_to metadata cleanly.
|
|
1627
|
+
# Merged result is sorted by timestamp DESC, then truncated to `limit`.
|
|
1628
|
+
|
|
1629
|
+
if project_id is not None:
|
|
1630
|
+
msg_sql = """
|
|
1631
|
+
SELECT
|
|
1632
|
+
snippet(messages_fts, 0, '<b>', '</b>', '...', 20) AS snippet,
|
|
1633
|
+
f.session_id,
|
|
1634
|
+
f.message_id,
|
|
1635
|
+
f.role,
|
|
1636
|
+
s.title AS session_title,
|
|
1637
|
+
s.project_id,
|
|
1638
|
+
m.timestamp
|
|
1639
|
+
FROM messages_fts f
|
|
1640
|
+
JOIN sessions s ON s.id = f.session_id
|
|
1641
|
+
JOIN messages m ON m.id = f.message_id
|
|
1642
|
+
WHERE messages_fts MATCH ?
|
|
1643
|
+
AND s.project_id = ?
|
|
1644
|
+
ORDER BY m.timestamp DESC
|
|
1645
|
+
LIMIT ?
|
|
1646
|
+
"""
|
|
1647
|
+
msg_params: tuple = (match_query, project_id, fetch_size)
|
|
1648
|
+
else:
|
|
1649
|
+
msg_sql = """
|
|
1650
|
+
SELECT
|
|
1651
|
+
snippet(messages_fts, 0, '<b>', '</b>', '...', 20) AS snippet,
|
|
1652
|
+
f.session_id,
|
|
1653
|
+
f.message_id,
|
|
1654
|
+
f.role,
|
|
1655
|
+
s.title AS session_title,
|
|
1656
|
+
s.project_id,
|
|
1657
|
+
m.timestamp
|
|
1658
|
+
FROM messages_fts f
|
|
1659
|
+
JOIN sessions s ON s.id = f.session_id
|
|
1660
|
+
JOIN messages m ON m.id = f.message_id
|
|
1661
|
+
WHERE messages_fts MATCH ?
|
|
1662
|
+
ORDER BY m.timestamp DESC
|
|
1663
|
+
LIMIT ?
|
|
1664
|
+
"""
|
|
1665
|
+
msg_params = (match_query, fetch_size)
|
|
1666
|
+
|
|
1667
|
+
async with self._conn.execute(msg_sql, msg_params) as cursor:
|
|
1668
|
+
msg_rows = await cursor.fetchall()
|
|
1669
|
+
|
|
1670
|
+
hits: list[SearchHit] = []
|
|
1671
|
+
for row in msg_rows:
|
|
1672
|
+
hits.append(
|
|
1673
|
+
SearchHit(
|
|
1674
|
+
session_id=row["session_id"],
|
|
1675
|
+
session_title=row["session_title"],
|
|
1676
|
+
project_id=row["project_id"],
|
|
1677
|
+
message_id=row["message_id"],
|
|
1678
|
+
role=row["role"],
|
|
1679
|
+
snippet=row["snippet"],
|
|
1680
|
+
timestamp=_str_to_dt(row["timestamp"]),
|
|
1681
|
+
source="message",
|
|
1682
|
+
)
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# --- Ephemeral search ---
|
|
1686
|
+
# Ephemerals have no project_id in their own table; we join via sessions.
|
|
1687
|
+
if project_id is not None:
|
|
1688
|
+
eph_sql = """
|
|
1689
|
+
SELECT
|
|
1690
|
+
snippet(ephemeral_messages_fts, 0, '<b>', '</b>', '...', 20) AS snippet,
|
|
1691
|
+
e.session_id,
|
|
1692
|
+
CAST(e.id AS TEXT) AS message_id,
|
|
1693
|
+
e.role,
|
|
1694
|
+
s.title AS session_title,
|
|
1695
|
+
s.project_id,
|
|
1696
|
+
e.timestamp,
|
|
1697
|
+
e.kind,
|
|
1698
|
+
e.responds_to
|
|
1699
|
+
FROM ephemeral_messages_fts f
|
|
1700
|
+
JOIN ephemeral_messages e ON e.id = f.ephemeral_id
|
|
1701
|
+
JOIN sessions s ON s.id = e.session_id
|
|
1702
|
+
WHERE ephemeral_messages_fts MATCH ?
|
|
1703
|
+
AND s.project_id = ?
|
|
1704
|
+
ORDER BY e.timestamp DESC
|
|
1705
|
+
LIMIT ?
|
|
1706
|
+
"""
|
|
1707
|
+
eph_params: tuple = (match_query, project_id, fetch_size)
|
|
1708
|
+
else:
|
|
1709
|
+
eph_sql = """
|
|
1710
|
+
SELECT
|
|
1711
|
+
snippet(ephemeral_messages_fts, 0, '<b>', '</b>', '...', 20) AS snippet,
|
|
1712
|
+
e.session_id,
|
|
1713
|
+
CAST(e.id AS TEXT) AS message_id,
|
|
1714
|
+
e.role,
|
|
1715
|
+
s.title AS session_title,
|
|
1716
|
+
s.project_id,
|
|
1717
|
+
e.timestamp,
|
|
1718
|
+
e.kind,
|
|
1719
|
+
e.responds_to
|
|
1720
|
+
FROM ephemeral_messages_fts f
|
|
1721
|
+
JOIN ephemeral_messages e ON e.id = f.ephemeral_id
|
|
1722
|
+
JOIN sessions s ON s.id = e.session_id
|
|
1723
|
+
WHERE ephemeral_messages_fts MATCH ?
|
|
1724
|
+
ORDER BY e.timestamp DESC
|
|
1725
|
+
LIMIT ?
|
|
1726
|
+
"""
|
|
1727
|
+
eph_params = (match_query, fetch_size)
|
|
1728
|
+
|
|
1729
|
+
async with self._conn.execute(eph_sql, eph_params) as cursor:
|
|
1730
|
+
eph_rows = await cursor.fetchall()
|
|
1731
|
+
|
|
1732
|
+
for row in eph_rows:
|
|
1733
|
+
hits.append(
|
|
1734
|
+
SearchHit(
|
|
1735
|
+
session_id=row["session_id"],
|
|
1736
|
+
session_title=row["session_title"],
|
|
1737
|
+
project_id=row["project_id"],
|
|
1738
|
+
message_id=row["message_id"],
|
|
1739
|
+
role=row["role"],
|
|
1740
|
+
snippet=row["snippet"],
|
|
1741
|
+
timestamp=_str_to_dt(row["timestamp"]),
|
|
1742
|
+
source="ephemeral",
|
|
1743
|
+
kind=row["kind"],
|
|
1744
|
+
responds_to=row["responds_to"],
|
|
1745
|
+
)
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
# Merge: sort all hits by timestamp DESC (None timestamps sort last).
|
|
1749
|
+
hits.sort(key=lambda h: h.timestamp or datetime.min, reverse=True)
|
|
1750
|
+
return hits[offset : offset + limit]
|
|
1751
|
+
|
|
1752
|
+
# -----------------------------------------------------------------------
|
|
1753
|
+
# Stats
|
|
1754
|
+
# -----------------------------------------------------------------------
|
|
1755
|
+
|
|
1756
|
+
async def get_stats(self) -> StatsResponse:
|
|
1757
|
+
assert self._conn is not None
|
|
1758
|
+
|
|
1759
|
+
async with self._conn.execute("SELECT COUNT(*) AS cnt FROM projects") as cur:
|
|
1760
|
+
projects_count = (await cur.fetchone())["cnt"]
|
|
1761
|
+
|
|
1762
|
+
async with self._conn.execute("SELECT COUNT(*) AS cnt FROM sessions") as cur:
|
|
1763
|
+
sessions_count = (await cur.fetchone())["cnt"]
|
|
1764
|
+
|
|
1765
|
+
async with self._conn.execute("SELECT COUNT(*) AS cnt FROM messages") as cur:
|
|
1766
|
+
messages_count = (await cur.fetchone())["cnt"]
|
|
1767
|
+
|
|
1768
|
+
return StatsResponse(
|
|
1769
|
+
total_projects=projects_count,
|
|
1770
|
+
total_sessions=sessions_count,
|
|
1771
|
+
total_messages=messages_count,
|
|
1772
|
+
data_paths=[], # filled in by server.py
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1775
|
+
# -----------------------------------------------------------------------
|
|
1776
|
+
# Recaps
|
|
1777
|
+
# -----------------------------------------------------------------------
|
|
1778
|
+
|
|
1779
|
+
async def insert_recap(
|
|
1780
|
+
self,
|
|
1781
|
+
session_id: str,
|
|
1782
|
+
text: str,
|
|
1783
|
+
covers_until_message_uuid: Optional[str],
|
|
1784
|
+
) -> int:
|
|
1785
|
+
assert self._conn is not None
|
|
1786
|
+
created_at = datetime.now().isoformat()
|
|
1787
|
+
cursor = await self._conn.execute(
|
|
1788
|
+
"""
|
|
1789
|
+
INSERT INTO recaps (session_id, text, created_at, covers_until_message_uuid)
|
|
1790
|
+
VALUES (?, ?, ?, ?)
|
|
1791
|
+
""",
|
|
1792
|
+
(session_id, text, created_at, covers_until_message_uuid),
|
|
1793
|
+
)
|
|
1794
|
+
await self._conn.commit()
|
|
1795
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
1796
|
+
|
|
1797
|
+
async def list_recaps(
|
|
1798
|
+
self, session_id: str, *, include_dismissed: bool = False
|
|
1799
|
+
) -> list[dict]:
|
|
1800
|
+
assert self._conn is not None
|
|
1801
|
+
if include_dismissed:
|
|
1802
|
+
sql = (
|
|
1803
|
+
"SELECT id, session_id, text, created_at, "
|
|
1804
|
+
"covers_until_message_uuid, dismissed "
|
|
1805
|
+
"FROM recaps WHERE session_id = ? ORDER BY created_at ASC, id ASC"
|
|
1806
|
+
)
|
|
1807
|
+
params: tuple = (session_id,)
|
|
1808
|
+
else:
|
|
1809
|
+
sql = (
|
|
1810
|
+
"SELECT id, session_id, text, created_at, "
|
|
1811
|
+
"covers_until_message_uuid, dismissed "
|
|
1812
|
+
"FROM recaps WHERE session_id = ? AND dismissed = 0 "
|
|
1813
|
+
"ORDER BY created_at ASC, id ASC"
|
|
1814
|
+
)
|
|
1815
|
+
params = (session_id,)
|
|
1816
|
+
async with self._conn.execute(sql, params) as cursor:
|
|
1817
|
+
rows = await cursor.fetchall()
|
|
1818
|
+
return [
|
|
1819
|
+
{
|
|
1820
|
+
"id": row["id"],
|
|
1821
|
+
"session_id": row["session_id"],
|
|
1822
|
+
"text": row["text"],
|
|
1823
|
+
"created_at": row["created_at"],
|
|
1824
|
+
"covers_until_message_uuid": row["covers_until_message_uuid"],
|
|
1825
|
+
"dismissed": bool(row["dismissed"]),
|
|
1826
|
+
}
|
|
1827
|
+
for row in rows
|
|
1828
|
+
]
|
|
1829
|
+
|
|
1830
|
+
async def dismiss_recap(self, recap_id: int) -> bool:
|
|
1831
|
+
assert self._conn is not None
|
|
1832
|
+
cursor = await self._conn.execute(
|
|
1833
|
+
"UPDATE recaps SET dismissed = 1 WHERE id = ?", (recap_id,)
|
|
1834
|
+
)
|
|
1835
|
+
await self._conn.commit()
|
|
1836
|
+
return cursor.rowcount > 0
|
|
1837
|
+
|
|
1838
|
+
# -----------------------------------------------------------------------
|
|
1839
|
+
# Ephemeral messages (/btw and similar side-channel exchanges)
|
|
1840
|
+
# -----------------------------------------------------------------------
|
|
1841
|
+
|
|
1842
|
+
async def record_ephemeral_input(
|
|
1843
|
+
self,
|
|
1844
|
+
session_id: str,
|
|
1845
|
+
content: str,
|
|
1846
|
+
kind: str = "btw",
|
|
1847
|
+
timestamp: Optional[str] = None,
|
|
1848
|
+
) -> int:
|
|
1849
|
+
"""Persist a user-side ephemeral message (e.g. /btw input).
|
|
1850
|
+
|
|
1851
|
+
Returns the new row id. ``timestamp`` defaults to now (ISO format,
|
|
1852
|
+
same as other tables). The INSERT trigger keeps ``ephemeral_messages_fts``
|
|
1853
|
+
in sync automatically.
|
|
1854
|
+
"""
|
|
1855
|
+
assert self._conn is not None
|
|
1856
|
+
ts = timestamp if timestamp is not None else _utc_now_iso()
|
|
1857
|
+
cursor = await self._conn.execute(
|
|
1858
|
+
"""
|
|
1859
|
+
INSERT INTO ephemeral_messages
|
|
1860
|
+
(session_id, kind, role, content, responds_to, timestamp)
|
|
1861
|
+
VALUES (?, ?, 'user', ?, NULL, ?)
|
|
1862
|
+
""",
|
|
1863
|
+
(session_id, kind, content, ts),
|
|
1864
|
+
)
|
|
1865
|
+
await self._conn.commit()
|
|
1866
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
1867
|
+
|
|
1868
|
+
async def record_ephemeral_response(
|
|
1869
|
+
self,
|
|
1870
|
+
input_row_id: int,
|
|
1871
|
+
content: str,
|
|
1872
|
+
timestamp: Optional[str] = None,
|
|
1873
|
+
) -> int:
|
|
1874
|
+
"""Persist an assistant-side ephemeral response paired with *input_row_id*.
|
|
1875
|
+
|
|
1876
|
+
Inherits ``session_id`` and ``kind`` from the input row to guarantee
|
|
1877
|
+
consistency (avoids the caller having to re-pass them and risk skew).
|
|
1878
|
+
Raises ``aiosqlite.IntegrityError`` (wrapping ``sqlite3.IntegrityError``)
|
|
1879
|
+
if *input_row_id* does not exist — the self-FK is enforced explicitly
|
|
1880
|
+
via the lookup below rather than relying on SQLite FK pragma being on.
|
|
1881
|
+
|
|
1882
|
+
Returns the new row id.
|
|
1883
|
+
"""
|
|
1884
|
+
assert self._conn is not None
|
|
1885
|
+
async with self._conn.execute(
|
|
1886
|
+
"SELECT session_id, kind FROM ephemeral_messages WHERE id = ?",
|
|
1887
|
+
(input_row_id,),
|
|
1888
|
+
) as cursor:
|
|
1889
|
+
row = await cursor.fetchone()
|
|
1890
|
+
if row is None:
|
|
1891
|
+
import sqlite3
|
|
1892
|
+
|
|
1893
|
+
raise sqlite3.IntegrityError(
|
|
1894
|
+
f"record_ephemeral_response: input_row_id {input_row_id!r} does not exist"
|
|
1895
|
+
)
|
|
1896
|
+
session_id = row["session_id"]
|
|
1897
|
+
kind = row["kind"]
|
|
1898
|
+
ts = timestamp if timestamp is not None else _utc_now_iso()
|
|
1899
|
+
cursor = await self._conn.execute(
|
|
1900
|
+
"""
|
|
1901
|
+
INSERT INTO ephemeral_messages
|
|
1902
|
+
(session_id, kind, role, content, responds_to, timestamp)
|
|
1903
|
+
VALUES (?, ?, 'assistant', ?, ?, ?)
|
|
1904
|
+
""",
|
|
1905
|
+
(session_id, kind, content, input_row_id, ts),
|
|
1906
|
+
)
|
|
1907
|
+
await self._conn.commit()
|
|
1908
|
+
return cursor.lastrowid # type: ignore[return-value]
|
|
1909
|
+
|
|
1910
|
+
async def get_ephemeral_messages(self, session_id: str) -> list[dict]:
|
|
1911
|
+
"""Return all ephemeral messages for *session_id* ordered by timestamp.
|
|
1912
|
+
|
|
1913
|
+
Returns plain dicts (mirroring ``list_recaps``) so callers don't need
|
|
1914
|
+
to import a separate Pydantic model.
|
|
1915
|
+
"""
|
|
1916
|
+
assert self._conn is not None
|
|
1917
|
+
async with self._conn.execute(
|
|
1918
|
+
"""
|
|
1919
|
+
SELECT id, session_id, kind, role, content, responds_to, timestamp
|
|
1920
|
+
FROM ephemeral_messages
|
|
1921
|
+
WHERE session_id = ?
|
|
1922
|
+
ORDER BY timestamp ASC, id ASC
|
|
1923
|
+
""",
|
|
1924
|
+
(session_id,),
|
|
1925
|
+
) as cursor:
|
|
1926
|
+
rows = await cursor.fetchall()
|
|
1927
|
+
return [
|
|
1928
|
+
{
|
|
1929
|
+
"id": row["id"],
|
|
1930
|
+
"session_id": row["session_id"],
|
|
1931
|
+
"kind": row["kind"],
|
|
1932
|
+
"role": row["role"],
|
|
1933
|
+
"content": row["content"],
|
|
1934
|
+
"responds_to": row["responds_to"],
|
|
1935
|
+
"timestamp": row["timestamp"],
|
|
1936
|
+
}
|
|
1937
|
+
for row in rows
|
|
1938
|
+
]
|
|
1939
|
+
|
|
1940
|
+
async def search_ephemeral(
|
|
1941
|
+
self,
|
|
1942
|
+
query: str,
|
|
1943
|
+
session_id: Optional[str] = None,
|
|
1944
|
+
) -> list[dict]:
|
|
1945
|
+
"""FTS5 search over ephemeral messages.
|
|
1946
|
+
|
|
1947
|
+
Sanitises the query the same way ``search()`` does (strips FTS5
|
|
1948
|
+
operators). Optional *session_id* narrows to a single session.
|
|
1949
|
+
Returns plain dicts matching the shape of ``get_ephemeral_messages``.
|
|
1950
|
+
"""
|
|
1951
|
+
assert self._conn is not None
|
|
1952
|
+
import re as _re
|
|
1953
|
+
|
|
1954
|
+
sanitized = _re.sub(r"[^\w\s]", " ", query).strip()
|
|
1955
|
+
if not sanitized:
|
|
1956
|
+
return []
|
|
1957
|
+
|
|
1958
|
+
if session_id is not None:
|
|
1959
|
+
sql = """
|
|
1960
|
+
SELECT
|
|
1961
|
+
e.id, e.session_id, e.kind, e.role,
|
|
1962
|
+
e.content, e.responds_to, e.timestamp
|
|
1963
|
+
FROM ephemeral_messages_fts f
|
|
1964
|
+
JOIN ephemeral_messages e ON e.id = f.ephemeral_id
|
|
1965
|
+
WHERE ephemeral_messages_fts MATCH ?
|
|
1966
|
+
AND f.session_id = ?
|
|
1967
|
+
ORDER BY e.timestamp ASC, e.id ASC
|
|
1968
|
+
"""
|
|
1969
|
+
params: tuple = (sanitized, session_id)
|
|
1970
|
+
else:
|
|
1971
|
+
sql = """
|
|
1972
|
+
SELECT
|
|
1973
|
+
e.id, e.session_id, e.kind, e.role,
|
|
1974
|
+
e.content, e.responds_to, e.timestamp
|
|
1975
|
+
FROM ephemeral_messages_fts f
|
|
1976
|
+
JOIN ephemeral_messages e ON e.id = f.ephemeral_id
|
|
1977
|
+
WHERE ephemeral_messages_fts MATCH ?
|
|
1978
|
+
ORDER BY e.timestamp ASC, e.id ASC
|
|
1979
|
+
"""
|
|
1980
|
+
params = (sanitized,)
|
|
1981
|
+
|
|
1982
|
+
async with self._conn.execute(sql, params) as cursor:
|
|
1983
|
+
rows = await cursor.fetchall()
|
|
1984
|
+
return [
|
|
1985
|
+
{
|
|
1986
|
+
"id": row["id"],
|
|
1987
|
+
"session_id": row["session_id"],
|
|
1988
|
+
"kind": row["kind"],
|
|
1989
|
+
"role": row["role"],
|
|
1990
|
+
"content": row["content"],
|
|
1991
|
+
"responds_to": row["responds_to"],
|
|
1992
|
+
"timestamp": row["timestamp"],
|
|
1993
|
+
}
|
|
1994
|
+
for row in rows
|
|
1995
|
+
]
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
# ---------------------------------------------------------------------------
|
|
1999
|
+
# Private row → model converters
|
|
2000
|
+
# ---------------------------------------------------------------------------
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _row_to_session(row: aiosqlite.Row) -> Session:
|
|
2004
|
+
# custom_title + meta-flag columns are only present on rows from the
|
|
2005
|
+
# JOIN'd selects. Detail/list rows include them; upsert-time round-trips
|
|
2006
|
+
# don't. Each column is keys()-guarded so older call sites stay valid.
|
|
2007
|
+
keys = row.keys()
|
|
2008
|
+
return Session(
|
|
2009
|
+
id=row["id"],
|
|
2010
|
+
project_id=row["project_id"],
|
|
2011
|
+
file_path=row["file_path"],
|
|
2012
|
+
title=row["title"],
|
|
2013
|
+
custom_title=row["custom_title"] if "custom_title" in keys else None,
|
|
2014
|
+
archived_at=row["archived_at"] if "archived_at" in keys else None,
|
|
2015
|
+
starred_at=row["starred_at"] if "starred_at" in keys else None,
|
|
2016
|
+
viewed_at=row["viewed_at"] if "viewed_at" in keys else None,
|
|
2017
|
+
model=row["model"],
|
|
2018
|
+
started_at=_str_to_dt(row["started_at"]),
|
|
2019
|
+
updated_at=_str_to_dt(row["updated_at"]),
|
|
2020
|
+
message_count=row["message_count"],
|
|
2021
|
+
user_message_count=row["user_message_count"],
|
|
2022
|
+
cwd=row["cwd"],
|
|
2023
|
+
git_branch=row["git_branch"],
|
|
2024
|
+
is_worktree=bool(row["is_worktree"]),
|
|
2025
|
+
is_fork=bool(row["is_fork"]) if "is_fork" in keys else False,
|
|
2026
|
+
permission_mode=row["permission_mode"],
|
|
2027
|
+
provider=row["provider"] if "provider" in keys else "claude",
|
|
2028
|
+
last_message_role=row["last_message_role"],
|
|
2029
|
+
)
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _row_to_message(row: aiosqlite.Row) -> Message:
|
|
2033
|
+
usage: TokenUsage | None = None
|
|
2034
|
+
raw_usage = row["usage_json"]
|
|
2035
|
+
if raw_usage:
|
|
2036
|
+
try:
|
|
2037
|
+
usage = TokenUsage.model_validate_json(raw_usage)
|
|
2038
|
+
except Exception:
|
|
2039
|
+
pass
|
|
2040
|
+
return Message(
|
|
2041
|
+
id=row["id"],
|
|
2042
|
+
session_id=row["session_id"],
|
|
2043
|
+
parent_id=row["parent_id"],
|
|
2044
|
+
role=row["role"],
|
|
2045
|
+
content_blocks=_deserialize_content_blocks(row["content_json"]),
|
|
2046
|
+
timestamp=_str_to_dt(row["timestamp"]),
|
|
2047
|
+
model=row["model"],
|
|
2048
|
+
is_sidechain=bool(row["is_sidechain"]),
|
|
2049
|
+
is_meta=bool(row["is_meta"]),
|
|
2050
|
+
cwd=row["cwd"],
|
|
2051
|
+
git_branch=row["git_branch"],
|
|
2052
|
+
source_tool_assistant_uuid=row["source_tool_assistant_uuid"],
|
|
2053
|
+
usage=usage,
|
|
2054
|
+
)
|