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.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. 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
+ )