claudeck 1.3.1 → 1.4.0

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.
package/db/sqlite.js ADDED
@@ -0,0 +1,1697 @@
1
+ import Database from "better-sqlite3";
2
+ import { createHash } from "crypto";
3
+ import { dbPath } from "../server/paths.js";
4
+
5
+ const db = new Database(dbPath);
6
+
7
+ // Enable WAL mode for better concurrent performance
8
+ db.pragma("journal_mode = WAL");
9
+
10
+ // Create tables
11
+ db.exec(`
12
+ CREATE TABLE IF NOT EXISTS sessions (
13
+ id TEXT PRIMARY KEY,
14
+ claude_session_id TEXT,
15
+ project_name TEXT,
16
+ project_path TEXT,
17
+ created_at INTEGER DEFAULT (unixepoch()),
18
+ last_used_at INTEGER DEFAULT (unixepoch())
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS costs (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ session_id TEXT REFERENCES sessions(id),
24
+ cost_usd REAL,
25
+ duration_ms INTEGER,
26
+ num_turns INTEGER,
27
+ created_at INTEGER DEFAULT (unixepoch())
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS messages (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ session_id TEXT REFERENCES sessions(id),
33
+ role TEXT NOT NULL,
34
+ content TEXT NOT NULL,
35
+ created_at INTEGER DEFAULT (unixepoch())
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS claude_sessions (
39
+ session_id TEXT NOT NULL,
40
+ chat_id TEXT NOT NULL DEFAULT '',
41
+ claude_session_id TEXT NOT NULL,
42
+ PRIMARY KEY (session_id, chat_id)
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
46
+ endpoint TEXT PRIMARY KEY,
47
+ keys_p256dh TEXT NOT NULL,
48
+ keys_auth TEXT NOT NULL,
49
+ created_at INTEGER DEFAULT (unixepoch())
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS todos (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ text TEXT NOT NULL,
55
+ done INTEGER DEFAULT 0,
56
+ position INTEGER DEFAULT 0,
57
+ created_at INTEGER DEFAULT (unixepoch()),
58
+ updated_at INTEGER DEFAULT (unixepoch())
59
+ );
60
+ `);
61
+
62
+ // Migrations
63
+ try { db.exec(`ALTER TABLE messages ADD COLUMN chat_id TEXT DEFAULT NULL`); } catch { /* exists */ }
64
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN title TEXT DEFAULT NULL`); } catch { /* exists */ }
65
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN pinned INTEGER DEFAULT 0`); } catch { /* exists */ }
66
+ try { db.exec(`ALTER TABLE costs ADD COLUMN input_tokens INTEGER DEFAULT 0`); } catch { /* exists */ }
67
+ try { db.exec(`ALTER TABLE costs ADD COLUMN output_tokens INTEGER DEFAULT 0`); } catch { /* exists */ }
68
+ // New columns for costs table
69
+ try { db.exec(`ALTER TABLE costs ADD COLUMN model TEXT DEFAULT NULL`); } catch { /* exists */ }
70
+ try { db.exec(`ALTER TABLE costs ADD COLUMN stop_reason TEXT DEFAULT NULL`); } catch { /* exists */ }
71
+ try { db.exec(`ALTER TABLE costs ADD COLUMN is_error INTEGER DEFAULT 0`); } catch { /* exists */ }
72
+ try { db.exec(`ALTER TABLE costs ADD COLUMN cache_read_tokens INTEGER DEFAULT 0`); } catch { /* exists */ }
73
+ try { db.exec(`ALTER TABLE costs ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0`); } catch { /* exists */ }
74
+ // New columns for messages table (workflow metadata)
75
+ try { db.exec(`ALTER TABLE messages ADD COLUMN workflow_id TEXT DEFAULT NULL`); } catch { /* exists */ }
76
+ try { db.exec(`ALTER TABLE messages ADD COLUMN workflow_step_index INTEGER DEFAULT NULL`); } catch { /* exists */ }
77
+ try { db.exec(`ALTER TABLE messages ADD COLUMN workflow_step_label TEXT DEFAULT NULL`); } catch { /* exists */ }
78
+ // AI-generated session summary
79
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN summary TEXT DEFAULT NULL`); } catch { /* exists */ }
80
+ // Todo archive
81
+ try { db.exec(`ALTER TABLE todos ADD COLUMN archived INTEGER DEFAULT 0`); } catch { /* exists */ }
82
+ // Todo priority (0=none, 1=low, 2=medium, 3=high)
83
+ try { db.exec(`ALTER TABLE todos ADD COLUMN priority INTEGER DEFAULT 0`); } catch { /* exists */ }
84
+ // Session branching / conversation forking
85
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN parent_session_id TEXT DEFAULT NULL`); } catch { /* exists */ }
86
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN fork_message_id INTEGER DEFAULT NULL`); } catch { /* exists */ }
87
+
88
+ // Agent context (shared memory between agents in a chain/orchestration run)
89
+ db.exec(`
90
+ CREATE TABLE IF NOT EXISTS agent_context (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ run_id TEXT NOT NULL,
93
+ agent_id TEXT NOT NULL,
94
+ key TEXT NOT NULL,
95
+ value TEXT NOT NULL,
96
+ created_at INTEGER DEFAULT (unixepoch()),
97
+ UNIQUE(run_id, agent_id, key)
98
+ );
99
+ CREATE INDEX IF NOT EXISTS idx_agent_context_run ON agent_context(run_id);
100
+ `);
101
+
102
+ // Agent runs table (monitoring dashboard)
103
+ db.exec(`
104
+ CREATE TABLE IF NOT EXISTS agent_runs (
105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106
+ run_id TEXT NOT NULL,
107
+ agent_id TEXT NOT NULL,
108
+ agent_title TEXT NOT NULL,
109
+ run_type TEXT NOT NULL DEFAULT 'single',
110
+ parent_id TEXT,
111
+ status TEXT NOT NULL DEFAULT 'running',
112
+ turns INTEGER DEFAULT 0,
113
+ cost_usd REAL DEFAULT 0,
114
+ duration_ms INTEGER DEFAULT 0,
115
+ input_tokens INTEGER DEFAULT 0,
116
+ output_tokens INTEGER DEFAULT 0,
117
+ error TEXT,
118
+ started_at INTEGER DEFAULT (unixepoch()),
119
+ completed_at INTEGER
120
+ );
121
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_agent ON agent_runs(agent_id);
122
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_started ON agent_runs(started_at);
123
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_run_id ON agent_runs(run_id);
124
+ `);
125
+
126
+ // Persistent memories table (cross-session context)
127
+ db.exec(`
128
+ CREATE TABLE IF NOT EXISTS memories (
129
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
130
+ project_path TEXT NOT NULL,
131
+ category TEXT NOT NULL DEFAULT 'discovery',
132
+ content TEXT NOT NULL,
133
+ content_hash TEXT,
134
+ source_session_id TEXT,
135
+ source_agent_id TEXT,
136
+ relevance_score REAL DEFAULT 1.0,
137
+ created_at INTEGER DEFAULT (unixepoch()),
138
+ accessed_at INTEGER DEFAULT (unixepoch()),
139
+ expires_at INTEGER
140
+ );
141
+ CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_path);
142
+ CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
143
+ CREATE INDEX IF NOT EXISTS idx_memories_relevance ON memories(relevance_score DESC);
144
+ `);
145
+
146
+ // Migration: add content_hash column if missing (existing DBs)
147
+ try { db.exec(`ALTER TABLE memories ADD COLUMN content_hash TEXT`); } catch { /* already exists */ }
148
+ try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_hash ON memories(project_path, content_hash)`); } catch { /* already exists */ }
149
+
150
+ // FTS5 full-text search for memories
151
+ db.exec(`
152
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
153
+ content,
154
+ content='memories',
155
+ content_rowid='id'
156
+ );
157
+ `);
158
+
159
+ // Triggers to keep FTS in sync
160
+ db.exec(`
161
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
162
+ INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
163
+ END;
164
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
165
+ INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
166
+ END;
167
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE OF content ON memories BEGIN
168
+ INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
169
+ INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
170
+ END;
171
+ `);
172
+
173
+ // ── Notifications table ──────────────────────────────────
174
+ db.exec(`
175
+ CREATE TABLE IF NOT EXISTS notifications (
176
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
177
+ type TEXT NOT NULL,
178
+ title TEXT NOT NULL,
179
+ body TEXT,
180
+ metadata TEXT,
181
+ source_session_id TEXT,
182
+ source_agent_id TEXT,
183
+ read_at INTEGER DEFAULT NULL,
184
+ created_at INTEGER DEFAULT (unixepoch())
185
+ );
186
+ CREATE INDEX IF NOT EXISTS idx_notif_created ON notifications(created_at DESC);
187
+ CREATE INDEX IF NOT EXISTS idx_notif_unread ON notifications(read_at) WHERE read_at IS NULL;
188
+ `);
189
+
190
+ // ── Worktrees table ──────────────────────────────────────
191
+ db.exec(`
192
+ CREATE TABLE IF NOT EXISTS worktrees (
193
+ id TEXT PRIMARY KEY,
194
+ session_id TEXT,
195
+ project_path TEXT NOT NULL,
196
+ worktree_path TEXT NOT NULL,
197
+ branch_name TEXT NOT NULL,
198
+ base_branch TEXT NOT NULL,
199
+ status TEXT DEFAULT 'active',
200
+ user_prompt TEXT,
201
+ created_at INTEGER DEFAULT (unixepoch()),
202
+ completed_at INTEGER DEFAULT NULL
203
+ );
204
+ CREATE INDEX IF NOT EXISTS idx_wt_project ON worktrees(project_path);
205
+ CREATE INDEX IF NOT EXISTS idx_wt_status ON worktrees(status);
206
+ `);
207
+
208
+ // Backfill content_hash for existing rows
209
+ const unhashed = db.prepare(`SELECT id, project_path, content FROM memories WHERE content_hash IS NULL`).all();
210
+ if (unhashed.length > 0) {
211
+ const backfill = db.prepare(`UPDATE memories SET content_hash = ? WHERE id = ?`);
212
+ const backfillTx = db.transaction((rows) => {
213
+ for (const row of rows) {
214
+ const hash = createHash("sha256").update(`${row.project_path}:${row.content}`).digest("hex");
215
+ backfill.run(hash, row.id);
216
+ }
217
+ });
218
+ backfillTx(unhashed);
219
+ }
220
+
221
+ // Backfill FTS index for existing memories not yet indexed
222
+ try {
223
+ const ftsCount = db.prepare(`SELECT COUNT(*) as c FROM memories_fts`).get();
224
+ const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get();
225
+ if (ftsCount.c < memCount.c) {
226
+ db.exec(`INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')`);
227
+ }
228
+ } catch { /* ignore */ }
229
+
230
+ // Brags table
231
+ db.exec(`
232
+ CREATE TABLE IF NOT EXISTS brags (
233
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
234
+ todo_id INTEGER REFERENCES todos(id),
235
+ text TEXT NOT NULL,
236
+ summary TEXT NOT NULL,
237
+ created_at INTEGER DEFAULT (unixepoch())
238
+ );
239
+ `);
240
+
241
+ // Indexes for query performance
242
+ db.exec(`
243
+ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
244
+ CREATE INDEX IF NOT EXISTS idx_messages_session_chat ON messages(session_id, chat_id);
245
+ CREATE INDEX IF NOT EXISTS idx_costs_session_id ON costs(session_id);
246
+ CREATE INDEX IF NOT EXISTS idx_costs_created_at ON costs(created_at);
247
+ CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path);
248
+ CREATE INDEX IF NOT EXISTS idx_sessions_pinned_last_used ON sessions(pinned DESC, last_used_at DESC);
249
+ CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id) WHERE parent_session_id IS NOT NULL;
250
+ `);
251
+
252
+ // Deduplicated mode CASE subquery — used in 4 session listing queries
253
+ const MODE_CASE = `
254
+ CASE
255
+ WHEN EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.chat_id IS NOT NULL)
256
+ AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.chat_id IS NULL)
257
+ THEN 'both'
258
+ WHEN EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.chat_id IS NOT NULL)
259
+ THEN 'parallel'
260
+ ELSE 'single'
261
+ END AS mode`;
262
+
263
+ // Prepared statements
264
+ const stmts = {
265
+ createSession: db.prepare(
266
+ `INSERT OR IGNORE INTO sessions (id, claude_session_id, project_name, project_path)
267
+ VALUES (?, ?, ?, ?)`
268
+ ),
269
+ updateClaudeSessionId: db.prepare(
270
+ `UPDATE sessions SET claude_session_id = ? WHERE id = ?`
271
+ ),
272
+ getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
273
+ listSessions: db.prepare(
274
+ `SELECT s.*, ${MODE_CASE}
275
+ FROM sessions s ORDER BY s.pinned DESC, s.last_used_at DESC LIMIT ?`
276
+ ),
277
+ listSessionsByProject: db.prepare(
278
+ `SELECT s.*, ${MODE_CASE}
279
+ FROM sessions s WHERE s.project_path = ? ORDER BY s.pinned DESC, s.last_used_at DESC LIMIT ?`
280
+ ),
281
+ touchSession: db.prepare(
282
+ `UPDATE sessions SET last_used_at = unixepoch() WHERE id = ?`
283
+ ),
284
+ addCost: db.prepare(
285
+ `INSERT INTO costs (session_id, cost_usd, duration_ms, num_turns, input_tokens, output_tokens, model, stop_reason, is_error, cache_read_tokens, cache_creation_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
286
+ ),
287
+ addMessage: db.prepare(
288
+ `INSERT INTO messages (session_id, role, content, chat_id, workflow_id, workflow_step_index, workflow_step_label) VALUES (?, ?, ?, ?, ?, ?, ?)`
289
+ ),
290
+ getMessages: db.prepare(
291
+ `SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`
292
+ ),
293
+ getMessagesByChatId: db.prepare(
294
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id = ? ORDER BY id ASC`
295
+ ),
296
+ getMessagesNoChatId: db.prepare(
297
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id IS NULL ORDER BY id ASC`
298
+ ),
299
+ // Paginated variants — fetch last N messages (DESC then reverse client-side)
300
+ getRecentMessages: db.prepare(
301
+ `SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?`
302
+ ),
303
+ getRecentMessagesByChatId: db.prepare(
304
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id = ? ORDER BY id DESC LIMIT ?`
305
+ ),
306
+ getRecentMessagesNoChatId: db.prepare(
307
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id IS NULL ORDER BY id DESC LIMIT ?`
308
+ ),
309
+ // Cursor-based: messages older than a given ID
310
+ getOlderMessages: db.prepare(
311
+ `SELECT * FROM messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?`
312
+ ),
313
+ getOlderMessagesByChatId: db.prepare(
314
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id = ? AND id < ? ORDER BY id DESC LIMIT ?`
315
+ ),
316
+ getOlderMessagesNoChatId: db.prepare(
317
+ `SELECT * FROM messages WHERE session_id = ? AND chat_id IS NULL AND id < ? ORDER BY id DESC LIMIT ?`
318
+ ),
319
+ getTotalCost: db.prepare(`SELECT COALESCE(SUM(cost_usd), 0) AS total FROM costs`),
320
+ getProjectCost: db.prepare(
321
+ `SELECT COALESCE(SUM(c.cost_usd), 0) AS total
322
+ FROM costs c JOIN sessions s ON c.session_id = s.id
323
+ WHERE s.project_path = ?`
324
+ ),
325
+ setClaudeSession: db.prepare(
326
+ `INSERT OR REPLACE INTO claude_sessions (session_id, chat_id, claude_session_id) VALUES (?, ?, ?)`
327
+ ),
328
+ getClaudeSessionId: db.prepare(
329
+ `SELECT claude_session_id FROM claude_sessions WHERE session_id = ? AND chat_id = ?`
330
+ ),
331
+ allClaudeSessions: db.prepare(
332
+ `SELECT * FROM claude_sessions`
333
+ ),
334
+ updateSessionTitle: db.prepare(
335
+ `UPDATE sessions SET title = ? WHERE id = ?`
336
+ ),
337
+ toggleSessionPin: db.prepare(
338
+ `UPDATE sessions SET pinned = CASE WHEN pinned = 1 THEN 0 ELSE 1 END WHERE id = ?`
339
+ ),
340
+ updateSessionSummary: db.prepare(
341
+ `UPDATE sessions SET summary = ? WHERE id = ?`
342
+ ),
343
+ searchSessions: db.prepare(
344
+ `SELECT s.*, ${MODE_CASE}
345
+ FROM sessions s WHERE s.project_path = ? AND (s.title LIKE ? OR s.project_name LIKE ?) ORDER BY s.pinned DESC, s.last_used_at DESC LIMIT ?`
346
+ ),
347
+ searchSessionsAll: db.prepare(
348
+ `SELECT s.*, ${MODE_CASE}
349
+ FROM sessions s WHERE (s.title LIKE ? OR s.project_name LIKE ?) ORDER BY s.pinned DESC, s.last_used_at DESC LIMIT ?`
350
+ ),
351
+ // Session branching
352
+ getMessagesByIdRange: db.prepare(
353
+ `SELECT role, content, created_at FROM messages WHERE session_id = ? AND id <= ? AND chat_id IS NULL ORDER BY id ASC`
354
+ ),
355
+ getLastMessageId: db.prepare(
356
+ `SELECT MAX(id) as maxId FROM messages WHERE session_id = ? AND chat_id IS NULL`
357
+ ),
358
+ getBranches: db.prepare(
359
+ `SELECT s.*, ${MODE_CASE} FROM sessions s WHERE s.parent_session_id = ? ORDER BY s.created_at DESC`
360
+ ),
361
+ getBranchCount: db.prepare(
362
+ `SELECT COUNT(*) as count FROM sessions WHERE parent_session_id = ?`
363
+ ),
364
+ orphanChildren: db.prepare(
365
+ `UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?`
366
+ ),
367
+ getSessionCosts: db.prepare(
368
+ `SELECT s.id, s.title, s.project_name, s.last_used_at,
369
+ COALESCE(SUM(c.cost_usd), 0) AS total_cost,
370
+ COALESCE(SUM(c.num_turns), 0) AS turns,
371
+ COALESCE(SUM(c.input_tokens), 0) AS input_tokens,
372
+ COALESCE(SUM(c.output_tokens), 0) AS output_tokens
373
+ FROM sessions s
374
+ LEFT JOIN costs c ON c.session_id = s.id
375
+ WHERE s.project_path = ?
376
+ GROUP BY s.id
377
+ ORDER BY total_cost DESC`
378
+ ),
379
+ getSessionCostsAll: db.prepare(
380
+ `SELECT s.id, s.title, s.project_name, s.last_used_at,
381
+ COALESCE(SUM(c.cost_usd), 0) AS total_cost,
382
+ COALESCE(SUM(c.num_turns), 0) AS turns,
383
+ COALESCE(SUM(c.input_tokens), 0) AS input_tokens,
384
+ COALESCE(SUM(c.output_tokens), 0) AS output_tokens
385
+ FROM sessions s
386
+ LEFT JOIN costs c ON c.session_id = s.id
387
+ GROUP BY s.id
388
+ ORDER BY total_cost DESC`
389
+ ),
390
+ getCostTimeline: db.prepare(
391
+ `SELECT date(c.created_at, 'unixepoch') AS date,
392
+ SUM(c.cost_usd) AS cost
393
+ FROM costs c
394
+ WHERE c.created_at >= unixepoch() - 30 * 86400
395
+ GROUP BY date(c.created_at, 'unixepoch')
396
+ ORDER BY date ASC`
397
+ ),
398
+ // Todo CRUD
399
+ listTodos: db.prepare(`SELECT * FROM todos WHERE archived = 0 ORDER BY position ASC, id ASC`),
400
+ listArchivedTodos: db.prepare(`SELECT * FROM todos WHERE archived = 1 ORDER BY updated_at DESC`),
401
+ createTodo: db.prepare(`INSERT INTO todos (text, position) VALUES (?, (SELECT COALESCE(MAX(position),0)+1 FROM todos))`),
402
+ updateTodo: db.prepare(`UPDATE todos SET text = COALESCE(?, text), done = COALESCE(?, done), priority = COALESCE(?, priority), updated_at = unixepoch() WHERE id = ?`),
403
+ archiveTodo: db.prepare(`UPDATE todos SET archived = ?, updated_at = unixepoch() WHERE id = ?`),
404
+ deleteTodo: db.prepare(`DELETE FROM todos WHERE id = ?`),
405
+ todoCounts: db.prepare(`
406
+ SELECT
407
+ (SELECT COUNT(*) FROM todos WHERE archived = 0) AS active,
408
+ (SELECT COUNT(*) FROM todos WHERE archived = 1) AS archived,
409
+ (SELECT COUNT(*) FROM brags) AS brags
410
+ `),
411
+
412
+ // Brag CRUD
413
+ createBrag: db.prepare(`INSERT INTO brags (todo_id, text, summary) VALUES (?, ?, ?)`),
414
+ listBrags: db.prepare(`SELECT * FROM brags ORDER BY created_at DESC`),
415
+ deleteBrag: db.prepare(`DELETE FROM brags WHERE id = ?`),
416
+
417
+ yearlyActivity: db.prepare(
418
+ `SELECT
419
+ date(c.created_at, 'unixepoch') AS date,
420
+ COUNT(DISTINCT c.session_id) AS sessions,
421
+ COUNT(*) AS queries,
422
+ COALESCE(SUM(c.cost_usd), 0) AS cost,
423
+ COALESCE(SUM(c.input_tokens), 0) AS input_tokens,
424
+ COALESCE(SUM(c.output_tokens), 0) AS output_tokens,
425
+ COALESCE(SUM(c.num_turns), 0) AS turns
426
+ FROM costs c
427
+ WHERE c.created_at >= unixepoch() - 365 * 86400
428
+ GROUP BY date(c.created_at, 'unixepoch')
429
+ ORDER BY date ASC`
430
+ ),
431
+ getCostTimelineByProject: db.prepare(
432
+ `SELECT date(c.created_at, 'unixepoch') AS date,
433
+ SUM(c.cost_usd) AS cost
434
+ FROM costs c
435
+ JOIN sessions s ON c.session_id = s.id
436
+ WHERE s.project_path = ? AND c.created_at >= unixepoch() - 30 * 86400
437
+ GROUP BY date(c.created_at, 'unixepoch')
438
+ ORDER BY date ASC`
439
+ ),
440
+ getTotalTokens: db.prepare(
441
+ `SELECT COALESCE(SUM(input_tokens), 0) AS input_tokens,
442
+ COALESCE(SUM(output_tokens), 0) AS output_tokens
443
+ FROM costs`
444
+ ),
445
+ getProjectTokens: db.prepare(
446
+ `SELECT COALESCE(SUM(c.input_tokens), 0) AS input_tokens,
447
+ COALESCE(SUM(c.output_tokens), 0) AS output_tokens
448
+ FROM costs c JOIN sessions s ON c.session_id = s.id
449
+ WHERE s.project_path = ?`
450
+ ),
451
+ };
452
+
453
+ export async function createSession(id, claudeSessionId, projectName, projectPath) {
454
+ stmts.createSession.run(id, claudeSessionId, projectName, projectPath);
455
+ }
456
+
457
+ export async function updateClaudeSessionId(id, claudeSessionId) {
458
+ stmts.updateClaudeSessionId.run(claudeSessionId, id);
459
+ }
460
+
461
+ export async function getSession(id) {
462
+ return stmts.getSession.get(id);
463
+ }
464
+
465
+ export async function listSessions(limit = 20, projectPath) {
466
+ if (projectPath) {
467
+ return stmts.listSessionsByProject.all(projectPath, limit);
468
+ }
469
+ return stmts.listSessions.all(limit);
470
+ }
471
+
472
+ export async function touchSession(id) {
473
+ stmts.touchSession.run(id);
474
+ }
475
+
476
+ export async function addCost(sessionId, costUsd, durationMs, numTurns, inputTokens = 0, outputTokens = 0, { model = null, stopReason = null, isError = 0, cacheReadTokens = 0, cacheCreationTokens = 0 } = {}) {
477
+ stmts.addCost.run(sessionId, costUsd, durationMs, numTurns, inputTokens, outputTokens, model, stopReason, isError, cacheReadTokens, cacheCreationTokens);
478
+ }
479
+
480
+ export async function getTotalCost() {
481
+ return stmts.getTotalCost.get().total;
482
+ }
483
+
484
+ export async function getProjectCost(projectPath) {
485
+ return stmts.getProjectCost.get(projectPath).total;
486
+ }
487
+
488
+ export async function addMessage(sessionId, role, content, chatId = null, workflowMeta = null) {
489
+ stmts.addMessage.run(sessionId, role, content, chatId, workflowMeta?.workflowId ?? null, workflowMeta?.stepIndex ?? null, workflowMeta?.stepLabel ?? null);
490
+ }
491
+
492
+ export async function getMessages(sessionId) {
493
+ return stmts.getMessages.all(sessionId);
494
+ }
495
+
496
+ export async function getMessagesByChatId(sessionId, chatId) {
497
+ return stmts.getMessagesByChatId.all(sessionId, chatId);
498
+ }
499
+
500
+ export async function getMessagesNoChatId(sessionId) {
501
+ return stmts.getMessagesNoChatId.all(sessionId);
502
+ }
503
+
504
+ // Paginated message fetchers — return messages in ASC order
505
+ export async function getRecentMessages(sessionId, limit) {
506
+ return stmts.getRecentMessages.all(sessionId, limit).reverse();
507
+ }
508
+
509
+ export async function getRecentMessagesByChatId(sessionId, chatId, limit) {
510
+ return stmts.getRecentMessagesByChatId.all(sessionId, chatId, limit).reverse();
511
+ }
512
+
513
+ export async function getRecentMessagesNoChatId(sessionId, limit) {
514
+ return stmts.getRecentMessagesNoChatId.all(sessionId, limit).reverse();
515
+ }
516
+
517
+ export async function getOlderMessages(sessionId, beforeId, limit) {
518
+ return stmts.getOlderMessages.all(sessionId, beforeId, limit).reverse();
519
+ }
520
+
521
+ export async function getOlderMessagesByChatId(sessionId, chatId, beforeId, limit) {
522
+ return stmts.getOlderMessagesByChatId.all(sessionId, chatId, beforeId, limit).reverse();
523
+ }
524
+
525
+ export async function getOlderMessagesNoChatId(sessionId, beforeId, limit) {
526
+ return stmts.getOlderMessagesNoChatId.all(sessionId, beforeId, limit).reverse();
527
+ }
528
+
529
+ export async function setClaudeSession(sessionId, chatId, claudeSessionId) {
530
+ stmts.setClaudeSession.run(sessionId, chatId, claudeSessionId);
531
+ }
532
+
533
+ export async function getClaudeSessionId(sessionId, chatId) {
534
+ const row = stmts.getClaudeSessionId.get(sessionId, chatId);
535
+ return row ? row.claude_session_id : null;
536
+ }
537
+
538
+ export async function allClaudeSessions() {
539
+ return stmts.allClaudeSessions.all();
540
+ }
541
+
542
+ export async function updateSessionTitle(id, title) {
543
+ stmts.updateSessionTitle.run(title, id);
544
+ }
545
+
546
+ export async function toggleSessionPin(id) {
547
+ stmts.toggleSessionPin.run(id);
548
+ }
549
+
550
+ export async function updateSessionSummary(id, summary) {
551
+ stmts.updateSessionSummary.run(summary, id);
552
+ }
553
+
554
+ export async function searchSessions(query, limit = 20, projectPath) {
555
+ const pattern = `%${query}%`;
556
+ if (projectPath) {
557
+ return stmts.searchSessions.all(projectPath, pattern, pattern, limit);
558
+ }
559
+ return stmts.searchSessionsAll.all(pattern, pattern, limit);
560
+ }
561
+
562
+ const _deleteSessionTxn = db.transaction((id) => {
563
+ // Orphan child forks before deleting parent
564
+ stmts.orphanChildren.run(id);
565
+ db.prepare("DELETE FROM claude_sessions WHERE session_id = ?").run(id);
566
+ db.prepare("DELETE FROM costs WHERE session_id = ?").run(id);
567
+ db.prepare("DELETE FROM messages WHERE session_id = ?").run(id);
568
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
569
+ });
570
+ export async function deleteSession(id) {
571
+ return _deleteSessionTxn(id);
572
+ }
573
+
574
+ // ── Session Branching / Forking ─────────────────────────
575
+ const _forkSessionTxn = db.transaction((parentSessionId, forkMessageId) => {
576
+ const parent = stmts.getSession.get(parentSessionId);
577
+ if (!parent) throw new Error("Session not found");
578
+
579
+ if (!forkMessageId) {
580
+ const last = stmts.getLastMessageId.get(parentSessionId);
581
+ forkMessageId = last?.maxId;
582
+ if (!forkMessageId) throw new Error("No messages to fork");
583
+ }
584
+
585
+ const newId = createHash("sha256")
586
+ .update(parentSessionId + Date.now() + Math.random())
587
+ .digest("hex")
588
+ .slice(0, 36);
589
+ const title = `Fork of: ${parent.title || parent.project_name || "Untitled"}`;
590
+
591
+ db.prepare(
592
+ `INSERT INTO sessions (id, project_name, project_path, title, parent_session_id, fork_message_id)
593
+ VALUES (?, ?, ?, ?, ?, ?)`
594
+ ).run(newId, parent.project_name, parent.project_path, title, parentSessionId, forkMessageId);
595
+
596
+ const messages = stmts.getMessagesByIdRange.all(parentSessionId, forkMessageId);
597
+ const insertMsg = db.prepare(
598
+ "INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)"
599
+ );
600
+ for (const msg of messages) {
601
+ insertMsg.run(newId, msg.role, msg.content, msg.created_at);
602
+ }
603
+
604
+ return stmts.getSession.get(newId);
605
+ });
606
+ export async function forkSession(parentSessionId, forkMessageId) {
607
+ return _forkSessionTxn(parentSessionId, forkMessageId);
608
+ }
609
+
610
+ export async function getSessionBranches(sessionId) {
611
+ return stmts.getBranches.all(sessionId);
612
+ }
613
+
614
+ export async function getSessionBranchCount(sessionId) {
615
+ return stmts.getBranchCount.get(sessionId).count;
616
+ }
617
+
618
+ export async function getSessionLineage(sessionId) {
619
+ const ancestors = [];
620
+ let current = stmts.getSession.get(sessionId);
621
+ while (current && current.parent_session_id) {
622
+ const parent = stmts.getSession.get(current.parent_session_id);
623
+ if (!parent) break;
624
+ ancestors.unshift(parent);
625
+ current = parent;
626
+ }
627
+ // Get siblings (other forks of the same parent)
628
+ const session = stmts.getSession.get(sessionId);
629
+ let siblings = [];
630
+ if (session?.parent_session_id) {
631
+ siblings = stmts.getBranches.all(session.parent_session_id)
632
+ .filter(s => s.id !== sessionId);
633
+ }
634
+ return { ancestors, siblings };
635
+ }
636
+
637
+ export async function getSessionCosts(projectPath) {
638
+ if (projectPath) {
639
+ return stmts.getSessionCosts.all(projectPath);
640
+ }
641
+ return stmts.getSessionCostsAll.all();
642
+ }
643
+
644
+ export async function getCostTimeline(projectPath) {
645
+ if (projectPath) {
646
+ return stmts.getCostTimelineByProject.all(projectPath);
647
+ }
648
+ return stmts.getCostTimeline.all();
649
+ }
650
+
651
+ export async function getTotalTokens() {
652
+ return stmts.getTotalTokens.get();
653
+ }
654
+
655
+ export async function getProjectTokens(projectPath) {
656
+ return stmts.getProjectTokens.get(projectPath);
657
+ }
658
+
659
+ // ── Error categorization CASE (reused in multiple queries) ────
660
+ const ERROR_CATEGORY_CASE = `
661
+ CASE
662
+ WHEN json_extract(tr.content, '$.content') LIKE '%ENOENT%'
663
+ OR json_extract(tr.content, '$.content') LIKE '%does not exist%'
664
+ OR json_extract(tr.content, '$.content') LIKE '%No such file%'
665
+ THEN 'File Not Found'
666
+ WHEN json_extract(tr.content, '$.content') LIKE '%Denied by user%'
667
+ OR json_extract(tr.content, '$.content') LIKE '%Aborted by user%'
668
+ THEN 'User Denied'
669
+ WHEN json_extract(tr.content, '$.content') LIKE '%timed out%'
670
+ THEN 'Timeout'
671
+ WHEN json_extract(tr.content, '$.content') LIKE '%File has not been read%'
672
+ OR json_extract(tr.content, '$.content') LIKE '%File has been modified%'
673
+ THEN 'File State Error'
674
+ WHEN json_extract(tr.content, '$.content') LIKE '%EISDIR%'
675
+ OR json_extract(tr.content, '$.content') LIKE '%illegal operation on a directory%'
676
+ THEN 'Directory Error'
677
+ WHEN json_extract(tr.content, '$.content') LIKE '%Found % matches%'
678
+ THEN 'Multiple Matches'
679
+ WHEN json_extract(tr.content, '$.content') LIKE '%command not found%'
680
+ THEN 'Command Not Found'
681
+ WHEN json_extract(tr.content, '$.content') LIKE '%npm error%'
682
+ OR json_extract(tr.content, '$.content') LIKE '%SyntaxError%'
683
+ OR json_extract(tr.content, '$.content') LIKE '%error TS%'
684
+ THEN 'Build/Runtime Error'
685
+ ELSE 'Other'
686
+ END`;
687
+
688
+ // ── Analytics queries ──────────────────────────────────────────
689
+
690
+ const analyticsStmts = {
691
+ overviewAll: db.prepare(`
692
+ SELECT
693
+ (SELECT COUNT(*) FROM sessions) AS sessions,
694
+ COUNT(*) AS queries,
695
+ COALESCE(SUM(cost_usd), 0) AS totalCost,
696
+ COALESCE(SUM(num_turns), 0) AS totalTurns,
697
+ COALESCE(SUM(output_tokens), 0) AS totalOutputTokens
698
+ FROM costs
699
+ `),
700
+ overviewByProject: db.prepare(`
701
+ SELECT
702
+ COUNT(DISTINCT s.id) AS sessions,
703
+ COUNT(c.id) AS queries,
704
+ COALESCE(SUM(c.cost_usd), 0) AS totalCost,
705
+ COALESCE(SUM(c.num_turns), 0) AS totalTurns,
706
+ COALESCE(SUM(c.output_tokens), 0) AS totalOutputTokens
707
+ FROM sessions s
708
+ LEFT JOIN costs c ON c.session_id = s.id
709
+ WHERE s.project_path = ?
710
+ `),
711
+ errorRateAll: db.prepare(`
712
+ SELECT
713
+ COUNT(CASE WHEN json_extract(content, '$.isError') = 1 THEN 1 END) AS errors,
714
+ COUNT(*) AS total
715
+ FROM messages WHERE role = 'tool_result'
716
+ `),
717
+ errorRateByProject: db.prepare(`
718
+ SELECT
719
+ COUNT(CASE WHEN json_extract(m.content, '$.isError') = 1 THEN 1 END) AS errors,
720
+ COUNT(*) AS total
721
+ FROM messages m
722
+ JOIN sessions s ON m.session_id = s.id
723
+ WHERE m.role = 'tool_result' AND s.project_path = ?
724
+ `),
725
+ dailyBreakdownAll: db.prepare(`
726
+ SELECT
727
+ date(c.created_at, 'unixepoch') AS date,
728
+ COUNT(*) AS queries,
729
+ SUM(c.cost_usd) AS cost,
730
+ SUM(c.num_turns) AS turns,
731
+ SUM(c.output_tokens) AS output_tok
732
+ FROM costs c
733
+ WHERE c.created_at >= unixepoch() - 30 * 86400
734
+ GROUP BY date(c.created_at, 'unixepoch')
735
+ ORDER BY date ASC
736
+ `),
737
+ dailyBreakdownByProject: db.prepare(`
738
+ SELECT
739
+ date(c.created_at, 'unixepoch') AS date,
740
+ COUNT(*) AS queries,
741
+ SUM(c.cost_usd) AS cost,
742
+ SUM(c.num_turns) AS turns,
743
+ SUM(c.output_tokens) AS output_tok
744
+ FROM costs c
745
+ JOIN sessions s ON c.session_id = s.id
746
+ WHERE s.project_path = ? AND c.created_at >= unixepoch() - 30 * 86400
747
+ GROUP BY date(c.created_at, 'unixepoch')
748
+ ORDER BY date ASC
749
+ `),
750
+ hourlyActivityAll: db.prepare(`
751
+ SELECT
752
+ CAST(strftime('%H', c.created_at, 'unixepoch', 'localtime') AS INTEGER) AS hour,
753
+ COUNT(*) AS queries,
754
+ SUM(c.cost_usd) AS cost
755
+ FROM costs c
756
+ GROUP BY strftime('%H', c.created_at, 'unixepoch', 'localtime')
757
+ ORDER BY hour ASC
758
+ `),
759
+ hourlyActivityByProject: db.prepare(`
760
+ SELECT
761
+ CAST(strftime('%H', c.created_at, 'unixepoch', 'localtime') AS INTEGER) AS hour,
762
+ COUNT(*) AS queries,
763
+ SUM(c.cost_usd) AS cost
764
+ FROM costs c
765
+ JOIN sessions s ON c.session_id = s.id
766
+ WHERE s.project_path = ?
767
+ GROUP BY strftime('%H', c.created_at, 'unixepoch', 'localtime')
768
+ ORDER BY hour ASC
769
+ `),
770
+ projectBreakdown: db.prepare(`
771
+ SELECT
772
+ s.project_name AS name,
773
+ s.project_path AS path,
774
+ COUNT(DISTINCT s.id) AS sessions,
775
+ COUNT(c.id) AS queries,
776
+ COALESCE(SUM(c.cost_usd), 0) AS totalCost,
777
+ CASE WHEN COUNT(DISTINCT s.id) > 0
778
+ THEN COALESCE(SUM(c.cost_usd), 0) / COUNT(DISTINCT s.id)
779
+ ELSE 0 END AS avgCost,
780
+ CASE WHEN COUNT(DISTINCT s.id) > 0
781
+ THEN COALESCE(SUM(c.num_turns), 0) / COUNT(DISTINCT s.id)
782
+ ELSE 0 END AS avgTurns
783
+ FROM sessions s
784
+ LEFT JOIN costs c ON c.session_id = s.id
785
+ GROUP BY s.project_path
786
+ ORDER BY totalCost DESC
787
+ `),
788
+ topSessionsAll: db.prepare(`
789
+ SELECT
790
+ s.title,
791
+ s.project_name AS project,
792
+ COALESCE(SUM(c.cost_usd), 0) AS cost,
793
+ COALESCE(SUM(c.num_turns), 0) AS turns,
794
+ COUNT(c.id) AS queries,
795
+ COALESCE(SUM(c.duration_ms), 0) / 60000.0 AS duration_min
796
+ FROM sessions s
797
+ LEFT JOIN costs c ON c.session_id = s.id
798
+ GROUP BY s.id
799
+ HAVING cost > 0
800
+ ORDER BY cost DESC
801
+ LIMIT 10
802
+ `),
803
+ topSessionsByProject: db.prepare(`
804
+ SELECT
805
+ s.title,
806
+ s.project_name AS project,
807
+ COALESCE(SUM(c.cost_usd), 0) AS cost,
808
+ COALESCE(SUM(c.num_turns), 0) AS turns,
809
+ COUNT(c.id) AS queries,
810
+ COALESCE(SUM(c.duration_ms), 0) / 60000.0 AS duration_min
811
+ FROM sessions s
812
+ LEFT JOIN costs c ON c.session_id = s.id
813
+ WHERE s.project_path = ?
814
+ GROUP BY s.id
815
+ HAVING cost > 0
816
+ ORDER BY cost DESC
817
+ LIMIT 10
818
+ `),
819
+ toolUsageAll: db.prepare(`
820
+ SELECT
821
+ json_extract(content, '$.name') AS name,
822
+ COUNT(*) AS count
823
+ FROM messages
824
+ WHERE role = 'tool' AND json_extract(content, '$.name') IS NOT NULL
825
+ GROUP BY json_extract(content, '$.name')
826
+ ORDER BY count DESC
827
+ `),
828
+ toolUsageByProject: db.prepare(`
829
+ SELECT
830
+ json_extract(m.content, '$.name') AS name,
831
+ COUNT(*) AS count
832
+ FROM messages m
833
+ JOIN sessions s ON m.session_id = s.id
834
+ WHERE m.role = 'tool' AND s.project_path = ? AND json_extract(m.content, '$.name') IS NOT NULL
835
+ GROUP BY json_extract(m.content, '$.name')
836
+ ORDER BY count DESC
837
+ `),
838
+ toolErrorsAll: db.prepare(`
839
+ SELECT
840
+ json_extract(t.content, '$.name') AS name,
841
+ COUNT(CASE WHEN json_extract(tr.content, '$.isError') = 1 THEN 1 END) AS errors,
842
+ COUNT(*) AS total,
843
+ CAST(COUNT(CASE WHEN json_extract(tr.content, '$.isError') = 1 THEN 1 END) AS REAL) / NULLIF(COUNT(*), 0) * 100 AS errorRate
844
+ FROM messages t
845
+ JOIN messages tr ON tr.session_id = t.session_id
846
+ AND tr.role = 'tool_result'
847
+ AND json_extract(tr.content, '$.toolUseId') = json_extract(t.content, '$.id')
848
+ WHERE t.role = 'tool'
849
+ GROUP BY json_extract(t.content, '$.name')
850
+ HAVING errors > 0
851
+ ORDER BY errors DESC
852
+ `),
853
+ toolErrorsByProject: db.prepare(`
854
+ SELECT
855
+ json_extract(t.content, '$.name') AS name,
856
+ COUNT(CASE WHEN json_extract(tr.content, '$.isError') = 1 THEN 1 END) AS errors,
857
+ COUNT(*) AS total,
858
+ CAST(COUNT(CASE WHEN json_extract(tr.content, '$.isError') = 1 THEN 1 END) AS REAL) / NULLIF(COUNT(*), 0) * 100 AS errorRate
859
+ FROM messages t
860
+ JOIN messages tr ON tr.session_id = t.session_id
861
+ AND tr.role = 'tool_result'
862
+ AND json_extract(tr.content, '$.toolUseId') = json_extract(t.content, '$.id')
863
+ JOIN sessions s ON t.session_id = s.id
864
+ WHERE t.role = 'tool' AND s.project_path = ?
865
+ GROUP BY json_extract(t.content, '$.name')
866
+ HAVING errors > 0
867
+ ORDER BY errors DESC
868
+ `),
869
+ sessionDepthAll: db.prepare(`
870
+ SELECT
871
+ CASE
872
+ WHEN cnt = 1 THEN '1 query'
873
+ WHEN cnt BETWEEN 2 AND 3 THEN '2-3'
874
+ WHEN cnt BETWEEN 4 AND 6 THEN '4-6'
875
+ WHEN cnt BETWEEN 7 AND 10 THEN '7-10'
876
+ ELSE '10+'
877
+ END AS bucket,
878
+ COUNT(*) AS count,
879
+ AVG(total_cost) AS avgCost
880
+ FROM (
881
+ SELECT s.id, COUNT(c.id) AS cnt, COALESCE(SUM(c.cost_usd), 0) AS total_cost
882
+ FROM sessions s
883
+ LEFT JOIN costs c ON c.session_id = s.id
884
+ GROUP BY s.id
885
+ HAVING cnt > 0
886
+ )
887
+ GROUP BY bucket
888
+ ORDER BY MIN(cnt)
889
+ `),
890
+ sessionDepthByProject: db.prepare(`
891
+ SELECT
892
+ CASE
893
+ WHEN cnt = 1 THEN '1 query'
894
+ WHEN cnt BETWEEN 2 AND 3 THEN '2-3'
895
+ WHEN cnt BETWEEN 4 AND 6 THEN '4-6'
896
+ WHEN cnt BETWEEN 7 AND 10 THEN '7-10'
897
+ ELSE '10+'
898
+ END AS bucket,
899
+ COUNT(*) AS count,
900
+ AVG(total_cost) AS avgCost
901
+ FROM (
902
+ SELECT s.id, COUNT(c.id) AS cnt, COALESCE(SUM(c.cost_usd), 0) AS total_cost
903
+ FROM sessions s
904
+ LEFT JOIN costs c ON c.session_id = s.id
905
+ WHERE s.project_path = ?
906
+ GROUP BY s.id
907
+ HAVING cnt > 0
908
+ )
909
+ GROUP BY bucket
910
+ ORDER BY MIN(cnt)
911
+ `),
912
+ msgLengthAll: db.prepare(`
913
+ SELECT
914
+ CASE
915
+ WHEN len < 100 THEN '<100'
916
+ WHEN len BETWEEN 100 AND 499 THEN '100-499'
917
+ WHEN len BETWEEN 500 AND 999 THEN '500-999'
918
+ WHEN len BETWEEN 1000 AND 4999 THEN '1k-5k'
919
+ ELSE '5k+'
920
+ END AS bucket,
921
+ COUNT(*) AS count,
922
+ CAST(AVG(len) AS INTEGER) AS avgChars
923
+ FROM (
924
+ SELECT LENGTH(json_extract(content, '$.text')) AS len
925
+ FROM messages
926
+ WHERE role = 'user' AND json_extract(content, '$.text') IS NOT NULL
927
+ )
928
+ WHERE len > 0
929
+ GROUP BY bucket
930
+ ORDER BY MIN(len)
931
+ `),
932
+ msgLengthByProject: db.prepare(`
933
+ SELECT
934
+ CASE
935
+ WHEN len < 100 THEN '<100'
936
+ WHEN len BETWEEN 100 AND 499 THEN '100-499'
937
+ WHEN len BETWEEN 500 AND 999 THEN '500-999'
938
+ WHEN len BETWEEN 1000 AND 4999 THEN '1k-5k'
939
+ ELSE '5k+'
940
+ END AS bucket,
941
+ COUNT(*) AS count,
942
+ CAST(AVG(len) AS INTEGER) AS avgChars
943
+ FROM (
944
+ SELECT LENGTH(json_extract(m.content, '$.text')) AS len
945
+ FROM messages m
946
+ JOIN sessions s ON m.session_id = s.id
947
+ WHERE m.role = 'user' AND s.project_path = ? AND json_extract(m.content, '$.text') IS NOT NULL
948
+ )
949
+ WHERE len > 0
950
+ GROUP BY bucket
951
+ ORDER BY MIN(len)
952
+ `),
953
+ topBashCommandsAll: db.prepare(`
954
+ SELECT
955
+ SUBSTR(json_extract(content, '$.input.command'), 1, 80) AS command,
956
+ COUNT(*) AS count
957
+ FROM messages
958
+ WHERE role = 'tool' AND json_extract(content, '$.name') = 'Bash'
959
+ AND json_extract(content, '$.input.command') IS NOT NULL
960
+ GROUP BY SUBSTR(json_extract(content, '$.input.command'), 1, 80)
961
+ ORDER BY count DESC
962
+ LIMIT 10
963
+ `),
964
+ topBashCommandsByProject: db.prepare(`
965
+ SELECT
966
+ SUBSTR(json_extract(m.content, '$.input.command'), 1, 80) AS command,
967
+ COUNT(*) AS count
968
+ FROM messages m
969
+ JOIN sessions s ON m.session_id = s.id
970
+ WHERE m.role = 'tool' AND s.project_path = ? AND json_extract(m.content, '$.name') = 'Bash'
971
+ AND json_extract(m.content, '$.input.command') IS NOT NULL
972
+ GROUP BY SUBSTR(json_extract(m.content, '$.input.command'), 1, 80)
973
+ ORDER BY count DESC
974
+ LIMIT 10
975
+ `),
976
+ topFilesAll: db.prepare(`
977
+ SELECT
978
+ json_extract(content, '$.input.file_path') AS path,
979
+ COUNT(*) AS count,
980
+ json_extract(content, '$.name') AS tool
981
+ FROM messages
982
+ WHERE role = 'tool'
983
+ AND json_extract(content, '$.name') IN ('Read', 'Write', 'Edit')
984
+ AND json_extract(content, '$.input.file_path') IS NOT NULL
985
+ GROUP BY json_extract(content, '$.input.file_path'), json_extract(content, '$.name')
986
+ ORDER BY count DESC
987
+ LIMIT 15
988
+ `),
989
+ topFilesByProject: db.prepare(`
990
+ SELECT
991
+ json_extract(m.content, '$.input.file_path') AS path,
992
+ COUNT(*) AS count,
993
+ json_extract(m.content, '$.name') AS tool
994
+ FROM messages m
995
+ JOIN sessions s ON m.session_id = s.id
996
+ WHERE m.role = 'tool' AND s.project_path = ?
997
+ AND json_extract(m.content, '$.name') IN ('Read', 'Write', 'Edit')
998
+ AND json_extract(m.content, '$.input.file_path') IS NOT NULL
999
+ GROUP BY json_extract(m.content, '$.input.file_path'), json_extract(m.content, '$.name')
1000
+ ORDER BY count DESC
1001
+ LIMIT 15
1002
+ `),
1003
+
1004
+ // ── Error pattern analytics ──────────────────────────────────
1005
+ errorCategoriesAll: db.prepare(`
1006
+ SELECT ${ERROR_CATEGORY_CASE} AS category, COUNT(*) AS count
1007
+ FROM messages tr
1008
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1009
+ GROUP BY category
1010
+ ORDER BY count DESC
1011
+ `),
1012
+ errorCategoriesByProject: db.prepare(`
1013
+ SELECT ${ERROR_CATEGORY_CASE} AS category, COUNT(*) AS count
1014
+ FROM messages tr
1015
+ JOIN sessions s ON tr.session_id = s.id
1016
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1017
+ AND s.project_path = ?
1018
+ GROUP BY category
1019
+ ORDER BY count DESC
1020
+ `),
1021
+ errorTimelineAll: db.prepare(`
1022
+ SELECT date(tr.created_at, 'unixepoch') AS date, COUNT(*) AS errors
1023
+ FROM messages tr
1024
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1025
+ AND tr.created_at >= unixepoch() - 30 * 86400
1026
+ GROUP BY date(tr.created_at, 'unixepoch')
1027
+ ORDER BY date ASC
1028
+ `),
1029
+ errorTimelineByProject: db.prepare(`
1030
+ SELECT date(tr.created_at, 'unixepoch') AS date, COUNT(*) AS errors
1031
+ FROM messages tr
1032
+ JOIN sessions s ON tr.session_id = s.id
1033
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1034
+ AND s.project_path = ? AND tr.created_at >= unixepoch() - 30 * 86400
1035
+ GROUP BY date(tr.created_at, 'unixepoch')
1036
+ ORDER BY date ASC
1037
+ `),
1038
+ errorsByToolAll: db.prepare(`
1039
+ SELECT
1040
+ COALESCE(json_extract(t.content, '$.name'), 'Unknown') AS tool,
1041
+ ${ERROR_CATEGORY_CASE} AS category,
1042
+ COUNT(*) AS errors
1043
+ FROM messages tr
1044
+ LEFT JOIN messages t ON t.session_id = tr.session_id
1045
+ AND t.role = 'tool'
1046
+ AND json_extract(t.content, '$.id') = json_extract(tr.content, '$.toolUseId')
1047
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1048
+ GROUP BY tool, category
1049
+ ORDER BY errors DESC
1050
+ `),
1051
+ errorsByToolByProject: db.prepare(`
1052
+ SELECT
1053
+ COALESCE(json_extract(t.content, '$.name'), 'Unknown') AS tool,
1054
+ ${ERROR_CATEGORY_CASE} AS category,
1055
+ COUNT(*) AS errors
1056
+ FROM messages tr
1057
+ JOIN sessions s ON tr.session_id = s.id
1058
+ LEFT JOIN messages t ON t.session_id = tr.session_id
1059
+ AND t.role = 'tool'
1060
+ AND json_extract(t.content, '$.id') = json_extract(tr.content, '$.toolUseId')
1061
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1062
+ AND s.project_path = ?
1063
+ GROUP BY tool, category
1064
+ ORDER BY errors DESC
1065
+ `),
1066
+ recentErrorsAll: db.prepare(`
1067
+ SELECT
1068
+ COALESCE(json_extract(t.content, '$.name'), 'Unknown') AS tool,
1069
+ SUBSTR(json_extract(tr.content, '$.content'), 1, 200) AS preview,
1070
+ json_extract(tr.content, '$.content') AS full_content,
1071
+ s.title AS session_title,
1072
+ tr.created_at AS timestamp
1073
+ FROM messages tr
1074
+ JOIN sessions s ON tr.session_id = s.id
1075
+ LEFT JOIN messages t ON t.session_id = tr.session_id
1076
+ AND t.role = 'tool'
1077
+ AND json_extract(t.content, '$.id') = json_extract(tr.content, '$.toolUseId')
1078
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1079
+ ORDER BY tr.created_at DESC
1080
+ LIMIT 20
1081
+ `),
1082
+ recentErrorsByProject: db.prepare(`
1083
+ SELECT
1084
+ COALESCE(json_extract(t.content, '$.name'), 'Unknown') AS tool,
1085
+ SUBSTR(json_extract(tr.content, '$.content'), 1, 200) AS preview,
1086
+ json_extract(tr.content, '$.content') AS full_content,
1087
+ s.title AS session_title,
1088
+ tr.created_at AS timestamp
1089
+ FROM messages tr
1090
+ JOIN sessions s ON tr.session_id = s.id
1091
+ LEFT JOIN messages t ON t.session_id = tr.session_id
1092
+ AND t.role = 'tool'
1093
+ AND json_extract(t.content, '$.id') = json_extract(tr.content, '$.toolUseId')
1094
+ WHERE tr.role = 'tool_result' AND json_extract(tr.content, '$.isError') = 1
1095
+ AND s.project_path = ?
1096
+ ORDER BY tr.created_at DESC
1097
+ LIMIT 20
1098
+ `),
1099
+
1100
+ // ── Model usage & cache efficiency ─────────────────────────
1101
+ modelUsageAll: db.prepare(`
1102
+ SELECT
1103
+ COALESCE(model, 'unknown') AS model,
1104
+ COUNT(*) AS count,
1105
+ COALESCE(SUM(cost_usd), 0) AS cost,
1106
+ COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens
1107
+ FROM costs
1108
+ GROUP BY COALESCE(model, 'unknown')
1109
+ ORDER BY cost DESC
1110
+ `),
1111
+ modelUsageByProject: db.prepare(`
1112
+ SELECT
1113
+ COALESCE(c.model, 'unknown') AS model,
1114
+ COUNT(*) AS count,
1115
+ COALESCE(SUM(c.cost_usd), 0) AS cost,
1116
+ COALESCE(SUM(c.input_tokens + c.output_tokens), 0) AS tokens
1117
+ FROM costs c
1118
+ JOIN sessions s ON c.session_id = s.id
1119
+ WHERE s.project_path = ?
1120
+ GROUP BY COALESCE(c.model, 'unknown')
1121
+ ORDER BY cost DESC
1122
+ `),
1123
+ cacheEfficiencyAll: db.prepare(`
1124
+ SELECT
1125
+ date(c.created_at, 'unixepoch') AS date,
1126
+ COALESCE(SUM(c.cache_read_tokens), 0) AS cache_read,
1127
+ COALESCE(SUM(c.cache_creation_tokens), 0) AS cache_creation,
1128
+ COALESCE(SUM(c.input_tokens), 0) AS total_input
1129
+ FROM costs c
1130
+ WHERE c.created_at >= unixepoch() - 30 * 86400
1131
+ GROUP BY date(c.created_at, 'unixepoch')
1132
+ ORDER BY date ASC
1133
+ `),
1134
+ cacheEfficiencyByProject: db.prepare(`
1135
+ SELECT
1136
+ date(c.created_at, 'unixepoch') AS date,
1137
+ COALESCE(SUM(c.cache_read_tokens), 0) AS cache_read,
1138
+ COALESCE(SUM(c.cache_creation_tokens), 0) AS cache_creation,
1139
+ COALESCE(SUM(c.input_tokens), 0) AS total_input
1140
+ FROM costs c
1141
+ JOIN sessions s ON c.session_id = s.id
1142
+ WHERE s.project_path = ? AND c.created_at >= unixepoch() - 30 * 86400
1143
+ GROUP BY date(c.created_at, 'unixepoch')
1144
+ ORDER BY date ASC
1145
+ `),
1146
+ };
1147
+
1148
+ export async function getAnalyticsOverview(projectPath) {
1149
+ const overview = projectPath
1150
+ ? analyticsStmts.overviewByProject.get(projectPath)
1151
+ : analyticsStmts.overviewAll.get();
1152
+ const errors = projectPath
1153
+ ? analyticsStmts.errorRateByProject.get(projectPath)
1154
+ : analyticsStmts.errorRateAll.get();
1155
+ return {
1156
+ ...overview,
1157
+ errorRate: errors.total > 0 ? (errors.errors / errors.total * 100) : 0,
1158
+ };
1159
+ }
1160
+
1161
+ export async function getDailyBreakdown(projectPath) {
1162
+ return projectPath
1163
+ ? analyticsStmts.dailyBreakdownByProject.all(projectPath)
1164
+ : analyticsStmts.dailyBreakdownAll.all();
1165
+ }
1166
+
1167
+ export async function getHourlyActivity(projectPath) {
1168
+ return projectPath
1169
+ ? analyticsStmts.hourlyActivityByProject.all(projectPath)
1170
+ : analyticsStmts.hourlyActivityAll.all();
1171
+ }
1172
+
1173
+ export async function getProjectBreakdown() {
1174
+ return analyticsStmts.projectBreakdown.all();
1175
+ }
1176
+
1177
+ export async function getTopSessionsByCost(projectPath) {
1178
+ return projectPath
1179
+ ? analyticsStmts.topSessionsByProject.all(projectPath)
1180
+ : analyticsStmts.topSessionsAll.all();
1181
+ }
1182
+
1183
+ export async function getToolUsage(projectPath) {
1184
+ return projectPath
1185
+ ? analyticsStmts.toolUsageByProject.all(projectPath)
1186
+ : analyticsStmts.toolUsageAll.all();
1187
+ }
1188
+
1189
+ export async function getToolErrors(projectPath) {
1190
+ return projectPath
1191
+ ? analyticsStmts.toolErrorsByProject.all(projectPath)
1192
+ : analyticsStmts.toolErrorsAll.all();
1193
+ }
1194
+
1195
+ export async function getSessionDepth(projectPath) {
1196
+ return projectPath
1197
+ ? analyticsStmts.sessionDepthByProject.all(projectPath)
1198
+ : analyticsStmts.sessionDepthAll.all();
1199
+ }
1200
+
1201
+ export async function getMsgLengthDistribution(projectPath) {
1202
+ return projectPath
1203
+ ? analyticsStmts.msgLengthByProject.all(projectPath)
1204
+ : analyticsStmts.msgLengthAll.all();
1205
+ }
1206
+
1207
+ export async function getTopBashCommands(projectPath) {
1208
+ return projectPath
1209
+ ? analyticsStmts.topBashCommandsByProject.all(projectPath)
1210
+ : analyticsStmts.topBashCommandsAll.all();
1211
+ }
1212
+
1213
+ export async function getTopFiles(projectPath) {
1214
+ return projectPath
1215
+ ? analyticsStmts.topFilesByProject.all(projectPath)
1216
+ : analyticsStmts.topFilesAll.all();
1217
+ }
1218
+
1219
+ export async function getErrorCategories(projectPath) {
1220
+ return projectPath
1221
+ ? analyticsStmts.errorCategoriesByProject.all(projectPath)
1222
+ : analyticsStmts.errorCategoriesAll.all();
1223
+ }
1224
+
1225
+ export async function getErrorTimeline(projectPath) {
1226
+ return projectPath
1227
+ ? analyticsStmts.errorTimelineByProject.all(projectPath)
1228
+ : analyticsStmts.errorTimelineAll.all();
1229
+ }
1230
+
1231
+ export async function getErrorsByTool(projectPath) {
1232
+ return projectPath
1233
+ ? analyticsStmts.errorsByToolByProject.all(projectPath)
1234
+ : analyticsStmts.errorsByToolAll.all();
1235
+ }
1236
+
1237
+ export async function getRecentErrors(projectPath) {
1238
+ return projectPath
1239
+ ? analyticsStmts.recentErrorsByProject.all(projectPath)
1240
+ : analyticsStmts.recentErrorsAll.all();
1241
+ }
1242
+
1243
+ export async function getModelUsage(projectPath) {
1244
+ return projectPath
1245
+ ? analyticsStmts.modelUsageByProject.all(projectPath)
1246
+ : analyticsStmts.modelUsageAll.all();
1247
+ }
1248
+
1249
+ export async function getYearlyActivity() {
1250
+ return stmts.yearlyActivity.all();
1251
+ }
1252
+
1253
+ export async function getCacheEfficiency(projectPath) {
1254
+ return projectPath
1255
+ ? analyticsStmts.cacheEfficiencyByProject.all(projectPath)
1256
+ : analyticsStmts.cacheEfficiencyAll.all();
1257
+ }
1258
+
1259
+ // ── Todo CRUD ────────────────────────────────────────────────
1260
+ export async function listTodos(archived = false) {
1261
+ return archived ? stmts.listArchivedTodos.all() : stmts.listTodos.all();
1262
+ }
1263
+ export async function createTodo(text) { return stmts.createTodo.run(text); }
1264
+ export async function updateTodo(id, text, done, priority) { return stmts.updateTodo.run(text, done, priority, id); }
1265
+ export async function archiveTodo(id, archived) { return stmts.archiveTodo.run(archived ? 1 : 0, id); }
1266
+ export async function deleteTodo(id) { return stmts.deleteTodo.run(id); }
1267
+
1268
+ export async function getTodoCounts() { return stmts.todoCounts.get(); }
1269
+
1270
+ // ── Brag CRUD ─────────────────────────────────────────────────
1271
+ export async function createBrag(todoId, text, summary) { return stmts.createBrag.run(todoId, text, summary); }
1272
+ export async function listBrags() { return stmts.listBrags.all(); }
1273
+ export async function deleteBrag(id) { return stmts.deleteBrag.run(id); }
1274
+
1275
+ // ── Push subscription queries ────────────────────────────────
1276
+ const pushStmts = {
1277
+ upsert: db.prepare(
1278
+ `INSERT INTO push_subscriptions (endpoint, keys_p256dh, keys_auth)
1279
+ VALUES (?, ?, ?)
1280
+ ON CONFLICT(endpoint) DO UPDATE SET keys_p256dh = excluded.keys_p256dh, keys_auth = excluded.keys_auth`
1281
+ ),
1282
+ delete: db.prepare(`DELETE FROM push_subscriptions WHERE endpoint = ?`),
1283
+ getAll: db.prepare(`SELECT * FROM push_subscriptions`),
1284
+ };
1285
+
1286
+ export async function upsertPushSubscription(endpoint, p256dh, auth) {
1287
+ pushStmts.upsert.run(endpoint, p256dh, auth);
1288
+ }
1289
+
1290
+ export async function deletePushSubscription(endpoint) {
1291
+ pushStmts.delete.run(endpoint);
1292
+ }
1293
+
1294
+ export async function getAllPushSubscriptions() {
1295
+ return pushStmts.getAll.all();
1296
+ }
1297
+
1298
+ // ── Agent context (shared memory) ─────────────────────────
1299
+ const ctxStmts = {
1300
+ set: db.prepare(
1301
+ `INSERT INTO agent_context (run_id, agent_id, key, value)
1302
+ VALUES (?, ?, ?, ?)
1303
+ ON CONFLICT(run_id, agent_id, key) DO UPDATE SET value = excluded.value`
1304
+ ),
1305
+ get: db.prepare(
1306
+ `SELECT value FROM agent_context WHERE run_id = ? AND agent_id = ? AND key = ?`
1307
+ ),
1308
+ getAllForRun: db.prepare(
1309
+ `SELECT agent_id, key, value, created_at FROM agent_context WHERE run_id = ? ORDER BY created_at ASC`
1310
+ ),
1311
+ getByKey: db.prepare(
1312
+ `SELECT agent_id, value FROM agent_context WHERE run_id = ? AND key = ?`
1313
+ ),
1314
+ deleteRun: db.prepare(
1315
+ `DELETE FROM agent_context WHERE run_id = ?`
1316
+ ),
1317
+ };
1318
+
1319
+ export async function setAgentContext(runId, agentId, key, value) {
1320
+ ctxStmts.set.run(runId, agentId, key, typeof value === "string" ? value : JSON.stringify(value));
1321
+ }
1322
+
1323
+ export async function getAgentContext(runId, agentId, key) {
1324
+ const row = ctxStmts.get.get(runId, agentId, key);
1325
+ return row ? row.value : null;
1326
+ }
1327
+
1328
+ export async function getAllAgentContext(runId) {
1329
+ return ctxStmts.getAllForRun.all(runId);
1330
+ }
1331
+
1332
+ export async function getAgentContextByKey(runId, key) {
1333
+ return ctxStmts.getByKey.all(runId, key);
1334
+ }
1335
+
1336
+ export async function deleteAgentContext(runId) {
1337
+ ctxStmts.deleteRun.run(runId);
1338
+ }
1339
+
1340
+ // ── Agent runs (monitoring) ────────────────────────────
1341
+ const runStmts = {
1342
+ insert: db.prepare(
1343
+ `INSERT INTO agent_runs (run_id, agent_id, agent_title, run_type, parent_id, status)
1344
+ VALUES (?, ?, ?, ?, ?, 'running')`
1345
+ ),
1346
+ complete: db.prepare(
1347
+ `UPDATE agent_runs SET status = ?, turns = ?, cost_usd = ?, duration_ms = ?,
1348
+ input_tokens = ?, output_tokens = ?, error = ?, completed_at = unixepoch()
1349
+ WHERE run_id = ? AND agent_id = ?`
1350
+ ),
1351
+ listRecent: db.prepare(
1352
+ `SELECT * FROM agent_runs ORDER BY started_at DESC LIMIT ?`
1353
+ ),
1354
+ agentSummary: db.prepare(
1355
+ `SELECT
1356
+ agent_id, agent_title,
1357
+ COUNT(*) AS runs,
1358
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS successes,
1359
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors,
1360
+ COALESCE(SUM(cost_usd), 0) AS total_cost,
1361
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN cost_usd END), 0) AS avg_cost,
1362
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_ms END), 0) AS avg_duration,
1363
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN turns END), 0) AS avg_turns,
1364
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1365
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens
1366
+ FROM agent_runs
1367
+ GROUP BY agent_id
1368
+ ORDER BY total_cost DESC`
1369
+ ),
1370
+ overview: db.prepare(
1371
+ `SELECT
1372
+ COUNT(*) AS total_runs,
1373
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
1374
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errored,
1375
+ COALESCE(SUM(cost_usd), 0) AS total_cost,
1376
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_ms END), 0) AS avg_duration,
1377
+ COALESCE(AVG(CASE WHEN status = 'completed' THEN turns END), 0) AS avg_turns,
1378
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1379
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens
1380
+ FROM agent_runs`
1381
+ ),
1382
+ byType: db.prepare(
1383
+ `SELECT
1384
+ run_type,
1385
+ COUNT(*) AS runs,
1386
+ COALESCE(SUM(cost_usd), 0) AS cost,
1387
+ COALESCE(AVG(duration_ms), 0) AS avg_duration
1388
+ FROM agent_runs
1389
+ GROUP BY run_type
1390
+ ORDER BY runs DESC`
1391
+ ),
1392
+ dailyRuns: db.prepare(
1393
+ `SELECT
1394
+ date(started_at, 'unixepoch') AS date,
1395
+ COUNT(*) AS runs,
1396
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed,
1397
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errored,
1398
+ COALESCE(SUM(cost_usd), 0) AS cost
1399
+ FROM agent_runs
1400
+ WHERE started_at >= unixepoch() - 30 * 86400
1401
+ GROUP BY date(started_at, 'unixepoch')
1402
+ ORDER BY date ASC`
1403
+ ),
1404
+ };
1405
+
1406
+ export async function recordAgentRunStart(runId, agentId, agentTitle, runType = 'single', parentId = null) {
1407
+ runStmts.insert.run(runId, agentId, agentTitle, runType, parentId);
1408
+ }
1409
+
1410
+ export async function recordAgentRunComplete(runId, agentId, status, turns, costUsd, durationMs, inputTokens, outputTokens, error = null) {
1411
+ runStmts.complete.run(status, turns, costUsd, durationMs, inputTokens, outputTokens, error, runId, agentId);
1412
+ }
1413
+
1414
+ export async function getAgentRunsRecent(limit = 50) {
1415
+ return runStmts.listRecent.all(limit);
1416
+ }
1417
+
1418
+ export async function getAgentRunsSummary() {
1419
+ return runStmts.agentSummary.all();
1420
+ }
1421
+
1422
+ export async function getAgentRunsOverview() {
1423
+ return runStmts.overview.get();
1424
+ }
1425
+
1426
+ export async function getAgentRunsByType() {
1427
+ return runStmts.byType.all();
1428
+ }
1429
+
1430
+ export async function getAgentRunsDaily() {
1431
+ return runStmts.dailyRuns.all();
1432
+ }
1433
+
1434
+ // ── Notifications ────────────────────────────────────────
1435
+ const notifStmts = {
1436
+ insert: db.prepare(
1437
+ `INSERT INTO notifications (type, title, body, metadata, source_session_id, source_agent_id)
1438
+ VALUES (?, ?, ?, ?, ?, ?)`
1439
+ ),
1440
+ history: db.prepare(
1441
+ `SELECT * FROM notifications ORDER BY created_at DESC LIMIT ? OFFSET ?`
1442
+ ),
1443
+ historyUnread: db.prepare(
1444
+ `SELECT * FROM notifications WHERE read_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?`
1445
+ ),
1446
+ historyByType: db.prepare(
1447
+ `SELECT * FROM notifications WHERE type = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
1448
+ ),
1449
+ historyByTypeUnread: db.prepare(
1450
+ `SELECT * FROM notifications WHERE type = ? AND read_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?`
1451
+ ),
1452
+ unreadCount: db.prepare(
1453
+ `SELECT COUNT(*) as count FROM notifications WHERE read_at IS NULL`
1454
+ ),
1455
+ markRead: db.prepare(
1456
+ `UPDATE notifications SET read_at = unixepoch() WHERE id = ? AND read_at IS NULL`
1457
+ ),
1458
+ markAllRead: db.prepare(
1459
+ `UPDATE notifications SET read_at = unixepoch() WHERE read_at IS NULL`
1460
+ ),
1461
+ markReadBefore: db.prepare(
1462
+ `UPDATE notifications SET read_at = unixepoch() WHERE read_at IS NULL AND created_at < ?`
1463
+ ),
1464
+ purgeOld: db.prepare(
1465
+ `DELETE FROM notifications WHERE created_at < unixepoch() - (? * 86400)`
1466
+ ),
1467
+ markStaleRead: db.prepare(
1468
+ `UPDATE notifications SET read_at = unixepoch() WHERE read_at IS NULL AND created_at < unixepoch() - (7 * 86400)`
1469
+ ),
1470
+ };
1471
+
1472
+ export async function createNotification(type, title, body = null, metadata = null, sourceSessionId = null, sourceAgentId = null) {
1473
+ const result = notifStmts.insert.run(type, title, body, metadata, sourceSessionId, sourceAgentId);
1474
+ return {
1475
+ id: result.lastInsertRowid,
1476
+ type, title, body, metadata,
1477
+ source_session_id: sourceSessionId,
1478
+ source_agent_id: sourceAgentId,
1479
+ read_at: null,
1480
+ created_at: Math.floor(Date.now() / 1000),
1481
+ };
1482
+ }
1483
+
1484
+ export async function getNotificationHistory(limit = 20, offset = 0, unreadOnly = false, type = null) {
1485
+ if (type && unreadOnly) return notifStmts.historyByTypeUnread.all(type, limit, offset);
1486
+ if (type) return notifStmts.historyByType.all(type, limit, offset);
1487
+ if (unreadOnly) return notifStmts.historyUnread.all(limit, offset);
1488
+ return notifStmts.history.all(limit, offset);
1489
+ }
1490
+
1491
+ export async function getUnreadNotificationCount() {
1492
+ return notifStmts.unreadCount.get().count;
1493
+ }
1494
+
1495
+ export async function markNotificationsRead(ids) {
1496
+ const tx = db.transaction((idList) => {
1497
+ for (const id of idList) notifStmts.markRead.run(id);
1498
+ });
1499
+ tx(ids);
1500
+ }
1501
+
1502
+ export async function markAllNotificationsRead() {
1503
+ notifStmts.markAllRead.run();
1504
+ }
1505
+
1506
+ export async function markNotificationsReadBefore(timestamp) {
1507
+ notifStmts.markReadBefore.run(timestamp);
1508
+ }
1509
+
1510
+ export async function purgeOldNotifications(days = 90) {
1511
+ notifStmts.markStaleRead.run();
1512
+ notifStmts.purgeOld.run(days);
1513
+ }
1514
+
1515
+ // ── Worktrees ─────────────────────────────────────────────
1516
+ const wtStmts = {
1517
+ create: db.prepare(
1518
+ `INSERT INTO worktrees (id, session_id, project_path, worktree_path, branch_name, base_branch, status, user_prompt)
1519
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?)`
1520
+ ),
1521
+ get: db.prepare(`SELECT * FROM worktrees WHERE id = ?`),
1522
+ listByProject: db.prepare(
1523
+ `SELECT * FROM worktrees WHERE project_path = ? ORDER BY created_at DESC`
1524
+ ),
1525
+ listActive: db.prepare(
1526
+ `SELECT * FROM worktrees WHERE status IN ('active', 'completed') ORDER BY created_at DESC`
1527
+ ),
1528
+ updateStatus: db.prepare(
1529
+ `UPDATE worktrees SET status = ?, completed_at = unixepoch() WHERE id = ?`
1530
+ ),
1531
+ updateSession: db.prepare(
1532
+ `UPDATE worktrees SET session_id = ? WHERE id = ?`
1533
+ ),
1534
+ delete: db.prepare(`DELETE FROM worktrees WHERE id = ?`),
1535
+ };
1536
+
1537
+ export async function createWorktreeRecord(id, sessionId, projectPath, worktreePath, branchName, baseBranch, userPrompt) {
1538
+ wtStmts.create.run(id, sessionId, projectPath, worktreePath, branchName, baseBranch, userPrompt);
1539
+ }
1540
+
1541
+ export async function getWorktreeRecord(id) {
1542
+ return wtStmts.get.get(id);
1543
+ }
1544
+
1545
+ export async function listWorktreesByProject(projectPath) {
1546
+ return wtStmts.listByProject.all(projectPath);
1547
+ }
1548
+
1549
+ export async function listActiveWorktrees() {
1550
+ return wtStmts.listActive.all();
1551
+ }
1552
+
1553
+ export async function updateWorktreeStatus(id, status) {
1554
+ wtStmts.updateStatus.run(status, id);
1555
+ }
1556
+
1557
+ export async function updateWorktreeSession(id, sessionId) {
1558
+ wtStmts.updateSession.run(sessionId, id);
1559
+ }
1560
+
1561
+ export async function deleteWorktreeRecord(id) {
1562
+ wtStmts.delete.run(id);
1563
+ }
1564
+
1565
+ // ── Memories (persistent cross-session context) ──────────
1566
+ function hashContent(projectPath, content) {
1567
+ return createHash("sha256").update(`${projectPath}:${content}`).digest("hex");
1568
+ }
1569
+
1570
+ const memStmts = {
1571
+ insert: db.prepare(
1572
+ `INSERT OR IGNORE INTO memories (project_path, category, content, content_hash, source_session_id, source_agent_id)
1573
+ VALUES (?, ?, ?, ?, ?, ?)`
1574
+ ),
1575
+ findByHash: db.prepare(
1576
+ `SELECT id FROM memories WHERE project_path = ? AND content_hash = ?`
1577
+ ),
1578
+ list: db.prepare(
1579
+ `SELECT * FROM memories WHERE project_path = ?
1580
+ ORDER BY relevance_score DESC, accessed_at DESC`
1581
+ ),
1582
+ listByCategory: db.prepare(
1583
+ `SELECT * FROM memories WHERE project_path = ? AND category = ?
1584
+ ORDER BY relevance_score DESC, accessed_at DESC`
1585
+ ),
1586
+ searchFts: db.prepare(
1587
+ `SELECT m.* FROM memories m
1588
+ JOIN memories_fts fts ON fts.rowid = m.id
1589
+ WHERE m.project_path = ? AND memories_fts MATCH ?
1590
+ ORDER BY rank, m.relevance_score DESC LIMIT ?`
1591
+ ),
1592
+ searchLike: db.prepare(
1593
+ `SELECT * FROM memories WHERE project_path = ? AND content LIKE ?
1594
+ ORDER BY relevance_score DESC LIMIT ?`
1595
+ ),
1596
+ topRelevant: db.prepare(
1597
+ `SELECT * FROM memories WHERE project_path = ?
1598
+ ORDER BY relevance_score DESC, accessed_at DESC LIMIT ?`
1599
+ ),
1600
+ update: db.prepare(
1601
+ `UPDATE memories SET content = ?, category = ? WHERE id = ?`
1602
+ ),
1603
+ touch: db.prepare(
1604
+ `UPDATE memories SET accessed_at = unixepoch(),
1605
+ relevance_score = MIN(relevance_score + 0.1, 2.0) WHERE id = ?`
1606
+ ),
1607
+ decay: db.prepare(
1608
+ `UPDATE memories SET relevance_score = MAX(relevance_score * 0.95, 0.1)
1609
+ WHERE project_path = ? AND accessed_at < unixepoch() - ?`
1610
+ ),
1611
+ delete: db.prepare(`DELETE FROM memories WHERE id = ?`),
1612
+ deleteExpired: db.prepare(
1613
+ `DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < unixepoch()`
1614
+ ),
1615
+ count: db.prepare(
1616
+ `SELECT category, COUNT(*) as count FROM memories
1617
+ WHERE project_path = ? GROUP BY category`
1618
+ ),
1619
+ stats: db.prepare(
1620
+ `SELECT COUNT(*) as total,
1621
+ SUM(CASE WHEN accessed_at > unixepoch() - 86400 THEN 1 ELSE 0 END) as accessed_today,
1622
+ AVG(relevance_score) as avg_relevance
1623
+ FROM memories WHERE project_path = ?`
1624
+ ),
1625
+ };
1626
+
1627
+ export async function createMemory(projectPath, category, content, sourceSessionId = null, sourceAgentId = null) {
1628
+ const hash = hashContent(projectPath, content);
1629
+ // Dedup: if identical content already exists, just touch it
1630
+ const existing = memStmts.findByHash.get(projectPath, hash);
1631
+ if (existing) {
1632
+ memStmts.touch.run(existing.id);
1633
+ return { lastInsertRowid: existing.id, changes: 0, isDuplicate: true };
1634
+ }
1635
+ return memStmts.insert.run(projectPath, category, content, hash, sourceSessionId, sourceAgentId);
1636
+ }
1637
+
1638
+ export async function listMemories(projectPath, category = null) {
1639
+ if (category) return memStmts.listByCategory.all(projectPath, category);
1640
+ return memStmts.list.all(projectPath);
1641
+ }
1642
+
1643
+ export async function searchMemories(projectPath, queryText, limit = 20) {
1644
+ // Try FTS5 first, fall back to LIKE for non-FTS-compatible queries
1645
+ try {
1646
+ const ftsQuery = queryText.split(/\s+/).filter(Boolean).map(w => `"${w}"`).join(" OR ");
1647
+ if (ftsQuery) {
1648
+ return memStmts.searchFts.all(projectPath, ftsQuery, limit);
1649
+ }
1650
+ } catch {
1651
+ // FTS parse error — fall back
1652
+ }
1653
+ return memStmts.searchLike.all(projectPath, `%${queryText}%`, limit);
1654
+ }
1655
+
1656
+ export async function getTopMemories(projectPath, limit = 10) {
1657
+ return memStmts.topRelevant.all(projectPath, limit);
1658
+ }
1659
+
1660
+ export async function updateMemory(id, content, category) {
1661
+ return memStmts.update.run(content, category, id);
1662
+ }
1663
+
1664
+ export async function touchMemory(id) {
1665
+ return memStmts.touch.run(id);
1666
+ }
1667
+
1668
+ export async function decayMemories(projectPath, olderThanSecs = 604800) {
1669
+ return memStmts.decay.run(projectPath, olderThanSecs);
1670
+ }
1671
+
1672
+ export async function deleteMemory(id) {
1673
+ return memStmts.delete.run(id);
1674
+ }
1675
+
1676
+ export async function deleteExpiredMemories() {
1677
+ return memStmts.deleteExpired.run();
1678
+ }
1679
+
1680
+ export async function getMemoryCounts(projectPath) {
1681
+ return memStmts.count.all(projectPath);
1682
+ }
1683
+
1684
+ export async function getMemoryStats(projectPath) {
1685
+ return memStmts.stats.get(projectPath);
1686
+ }
1687
+
1688
+ // Run decay + cleanup for a project (call on session start)
1689
+ export async function maintainMemories(projectPath) {
1690
+ decayMemories(projectPath, 604800); // 7 days
1691
+ deleteExpiredMemories();
1692
+ }
1693
+
1694
+ /** @sqlite-specific — returns raw better-sqlite3 instance */
1695
+ export function getDb() {
1696
+ return db;
1697
+ }