tanuki-telemetry 1.1.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.
Files changed (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
package/src/db.ts ADDED
@@ -0,0 +1,1009 @@
1
+ import Database from "better-sqlite3";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import sharp from "sharp";
5
+ import type {
6
+ Session,
7
+ Event,
8
+ Iteration,
9
+ Screenshot,
10
+ Artifact,
11
+ FinalResult,
12
+ Walkthrough,
13
+ WalkthroughAction,
14
+ WalkthroughScreenshot,
15
+ } from "./types.js";
16
+
17
+ const DB_PATH = process.env.DB_PATH || "/data/telemetry.db";
18
+
19
+ let db: Database.Database;
20
+
21
+ export function getDb(): Database.Database {
22
+ if (!db) {
23
+ db = new Database(DB_PATH);
24
+ db.pragma("journal_mode = WAL");
25
+ db.pragma("foreign_keys = ON");
26
+ initTables();
27
+ }
28
+ return db;
29
+ }
30
+
31
+ function initTables(): void {
32
+ const d = getDb();
33
+
34
+ d.exec(`
35
+ CREATE TABLE IF NOT EXISTS sessions (
36
+ id TEXT PRIMARY KEY,
37
+ worktree_name TEXT NOT NULL,
38
+ ticket_id TEXT,
39
+ ticket_title TEXT,
40
+ branch_name TEXT,
41
+ mode TEXT CHECK(mode IN ('local', 'remote')) DEFAULT 'local',
42
+ status TEXT CHECK(status IN ('in_progress', 'completed', 'failed', 'interrupted')) DEFAULT 'in_progress',
43
+ total_input_tokens INTEGER DEFAULT 0,
44
+ total_output_tokens INTEGER DEFAULT 0,
45
+ total_iterations INTEGER DEFAULT 0,
46
+ max_iterations INTEGER DEFAULT 10,
47
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
48
+ ended_at TEXT,
49
+ duration_seconds INTEGER,
50
+ final_result TEXT,
51
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS events (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ session_id TEXT NOT NULL REFERENCES sessions(id),
57
+ phase TEXT NOT NULL,
58
+ event_type TEXT NOT NULL,
59
+ message TEXT NOT NULL,
60
+ metadata TEXT,
61
+ tokens_used INTEGER DEFAULT 0,
62
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS iterations (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ session_id TEXT NOT NULL REFERENCES sessions(id),
68
+ iteration_number INTEGER NOT NULL,
69
+ trigger TEXT NOT NULL,
70
+ error_summary TEXT NOT NULL,
71
+ fix_description TEXT,
72
+ files_changed TEXT,
73
+ result TEXT CHECK(result IN ('pass', 'fail', 'partial')) NOT NULL,
74
+ duration_seconds INTEGER,
75
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS screenshots (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ session_id TEXT NOT NULL REFERENCES sessions(id),
81
+ iteration_number INTEGER,
82
+ phase TEXT NOT NULL,
83
+ description TEXT NOT NULL,
84
+ file_path TEXT NOT NULL,
85
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS plan_steps (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ session_id TEXT NOT NULL REFERENCES sessions(id),
91
+ step_number INTEGER NOT NULL,
92
+ title TEXT NOT NULL,
93
+ description TEXT NOT NULL DEFAULT '',
94
+ status TEXT CHECK(status IN ('pending', 'in_progress', 'completed', 'skipped', 'failed')) DEFAULT 'pending',
95
+ parent_step INTEGER,
96
+ file_targets TEXT,
97
+ outcome TEXT,
98
+ started_at TEXT,
99
+ completed_at TEXT,
100
+ duration_seconds INTEGER,
101
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
102
+ );
103
+
104
+ CREATE TABLE IF NOT EXISTS insights (
105
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106
+ session_id TEXT NOT NULL REFERENCES sessions(id),
107
+ insight_type TEXT CHECK(insight_type IN ('mistake', 'success_pattern', 'codebase_gotcha', 'optimization', 'rule_learned')) NOT NULL,
108
+ category TEXT NOT NULL,
109
+ title TEXT NOT NULL,
110
+ description TEXT NOT NULL,
111
+ evidence TEXT,
112
+ confidence REAL DEFAULT 0.5,
113
+ times_validated INTEGER DEFAULT 0,
114
+ file_patterns TEXT,
115
+ error_patterns TEXT,
116
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
117
+ );
118
+
119
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
120
+ CREATE INDEX IF NOT EXISTS idx_iterations_session ON iterations(session_id);
121
+ CREATE INDEX IF NOT EXISTS idx_screenshots_session ON screenshots(session_id);
122
+ CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON plan_steps(session_id);
123
+ CREATE INDEX IF NOT EXISTS idx_insights_session ON insights(session_id);
124
+ CREATE INDEX IF NOT EXISTS idx_insights_type ON insights(insight_type);
125
+ CREATE INDEX IF NOT EXISTS idx_insights_category ON insights(category);
126
+ `);
127
+
128
+ // Migration: add parent_session_id column to sessions
129
+ try {
130
+ d.exec(`ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id)`);
131
+ } catch {
132
+ // Column already exists — ignore duplicate column error
133
+ }
134
+ d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)`);
135
+
136
+ // Migration: add stored_path column to screenshots
137
+ try {
138
+ d.exec(`ALTER TABLE screenshots ADD COLUMN stored_path TEXT`);
139
+ } catch {
140
+ // Column already exists
141
+ }
142
+
143
+ // Migration: add thumb_path column to screenshots
144
+ try {
145
+ d.exec(`ALTER TABLE screenshots ADD COLUMN thumb_path TEXT`);
146
+ } catch {
147
+ // Column already exists
148
+ }
149
+
150
+ // Migration: add event_id column to screenshots
151
+ try {
152
+ d.exec(`ALTER TABLE screenshots ADD COLUMN event_id INTEGER REFERENCES events(id)`);
153
+ } catch {
154
+ // Column already exists
155
+ }
156
+
157
+ // Migration: add user_email column to sessions
158
+ try {
159
+ d.exec(`ALTER TABLE sessions ADD COLUMN user_email TEXT`);
160
+ } catch {
161
+ // Column already exists
162
+ }
163
+ d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_user_email ON sessions(user_email)`);
164
+
165
+ // Coordinator tables
166
+ d.exec(`
167
+ CREATE TABLE IF NOT EXISTS coordinator_state (
168
+ session_id TEXT PRIMARY KEY,
169
+ state TEXT NOT NULL,
170
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
171
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS coordinator_history (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ session_id TEXT NOT NULL,
177
+ snapshot TEXT NOT NULL,
178
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
179
+ );
180
+
181
+ CREATE INDEX IF NOT EXISTS idx_coordinator_history_session ON coordinator_history(session_id);
182
+ `);
183
+
184
+ // Migration: add artifacts table
185
+ d.exec(`
186
+ CREATE TABLE IF NOT EXISTS artifacts (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ session_id TEXT NOT NULL REFERENCES sessions(id),
189
+ artifact_type TEXT NOT NULL,
190
+ description TEXT NOT NULL,
191
+ file_path TEXT NOT NULL,
192
+ stored_path TEXT,
193
+ mime_type TEXT,
194
+ file_size INTEGER,
195
+ metadata TEXT,
196
+ event_id INTEGER REFERENCES events(id),
197
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_artifacts_session ON artifacts(session_id);
201
+ `);
202
+
203
+ // Walkthrough tables
204
+ d.exec(`
205
+ CREATE TABLE IF NOT EXISTS walkthroughs (
206
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
207
+ url TEXT NOT NULL,
208
+ app_name TEXT,
209
+ scenario TEXT,
210
+ status TEXT CHECK(status IN ('in_progress', 'pass', 'fail', 'partial')) DEFAULT 'in_progress',
211
+ summary TEXT,
212
+ total_actions INTEGER DEFAULT 0,
213
+ passed INTEGER DEFAULT 0,
214
+ failed INTEGER DEFAULT 0,
215
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
216
+ ended_at TEXT
217
+ );
218
+
219
+ CREATE TABLE IF NOT EXISTS walkthrough_actions (
220
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
221
+ walkthrough_id INTEGER NOT NULL REFERENCES walkthroughs(id),
222
+ action_type TEXT CHECK(action_type IN ('navigate', 'click', 'type', 'assert', 'wait', 'screenshot')) NOT NULL,
223
+ target TEXT NOT NULL,
224
+ value TEXT,
225
+ status TEXT CHECK(status IN ('pass', 'fail')) NOT NULL,
226
+ message TEXT,
227
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
228
+ );
229
+
230
+ CREATE TABLE IF NOT EXISTS walkthrough_screenshots (
231
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
232
+ walkthrough_id INTEGER NOT NULL REFERENCES walkthroughs(id),
233
+ action_id INTEGER REFERENCES walkthrough_actions(id),
234
+ name TEXT NOT NULL,
235
+ annotation TEXT,
236
+ stored_path TEXT NOT NULL,
237
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
238
+ );
239
+
240
+ CREATE INDEX IF NOT EXISTS idx_walkthrough_actions_wt ON walkthrough_actions(walkthrough_id);
241
+ CREATE INDEX IF NOT EXISTS idx_walkthrough_screenshots_wt ON walkthrough_screenshots(walkthrough_id);
242
+ `);
243
+
244
+ // Ensure storage directories exist
245
+ const screenshotsDir = "/data/screenshots";
246
+ if (!fs.existsSync(screenshotsDir)) {
247
+ fs.mkdirSync(screenshotsDir, { recursive: true });
248
+ }
249
+ const artifactsDir = "/data/artifacts";
250
+ if (!fs.existsSync(artifactsDir)) {
251
+ fs.mkdirSync(artifactsDir, { recursive: true });
252
+ }
253
+ const walkthroughScreenshotsDir = "/data/walkthrough-screenshots";
254
+ if (!fs.existsSync(walkthroughScreenshotsDir)) {
255
+ fs.mkdirSync(walkthroughScreenshotsDir, { recursive: true });
256
+ }
257
+ }
258
+
259
+ export function createSession(
260
+ id: string,
261
+ worktree_name: string,
262
+ ticket_id?: string,
263
+ ticket_title?: string,
264
+ branch_name?: string,
265
+ mode?: string,
266
+ max_iterations?: number,
267
+ parent_session_id?: string,
268
+ user_email?: string
269
+ ): void {
270
+ const d = getDb();
271
+ const stmt = d.prepare(`
272
+ INSERT INTO sessions (id, worktree_name, ticket_id, ticket_title, branch_name, mode, max_iterations, parent_session_id, user_email)
273
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
274
+ `);
275
+ stmt.run(
276
+ id,
277
+ worktree_name,
278
+ ticket_id ?? null,
279
+ ticket_title ?? null,
280
+ branch_name ?? null,
281
+ mode ?? "local",
282
+ max_iterations ?? 10,
283
+ parent_session_id ?? null,
284
+ user_email ?? null
285
+ );
286
+ }
287
+
288
+ export function insertEvent(
289
+ session_id: string,
290
+ phase: string,
291
+ event_type: string,
292
+ message: string,
293
+ metadata?: object,
294
+ tokens_used?: number,
295
+ input_tokens?: number,
296
+ output_tokens?: number
297
+ ): number {
298
+ const d = getDb();
299
+ const stmt = d.prepare(`
300
+ INSERT INTO events (session_id, phase, event_type, message, metadata, tokens_used)
301
+ VALUES (?, ?, ?, ?, ?, ?)
302
+ `);
303
+ const result = stmt.run(
304
+ session_id,
305
+ phase,
306
+ event_type,
307
+ message,
308
+ metadata ? JSON.stringify(metadata) : null,
309
+ tokens_used ?? 0
310
+ );
311
+
312
+ // Accumulate token counts on the session
313
+ const inTok = input_tokens ?? 0;
314
+ const outTok = output_tokens ?? 0;
315
+ if (inTok > 0 || outTok > 0) {
316
+ d.prepare(`
317
+ UPDATE sessions
318
+ SET total_input_tokens = total_input_tokens + ?,
319
+ total_output_tokens = total_output_tokens + ?
320
+ WHERE id = ?
321
+ `).run(inTok, outTok, session_id);
322
+ }
323
+
324
+ return Number(result.lastInsertRowid);
325
+ }
326
+
327
+ export function insertIteration(
328
+ session_id: string,
329
+ iteration_number: number,
330
+ trigger: string,
331
+ error_summary: string,
332
+ fix_description?: string,
333
+ files_changed?: string[],
334
+ result?: string,
335
+ duration_seconds?: number
336
+ ): number {
337
+ const d = getDb();
338
+
339
+ const stmt = d.prepare(`
340
+ INSERT INTO iterations (session_id, iteration_number, trigger, error_summary, fix_description, files_changed, result, duration_seconds)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
342
+ `);
343
+ const res = stmt.run(
344
+ session_id,
345
+ iteration_number,
346
+ trigger,
347
+ error_summary,
348
+ fix_description ?? null,
349
+ files_changed ? JSON.stringify(files_changed) : null,
350
+ result ?? "fail",
351
+ duration_seconds ?? null
352
+ );
353
+
354
+ // Update total_iterations on the session
355
+ d.prepare(`
356
+ UPDATE sessions SET total_iterations = (
357
+ SELECT COUNT(*) FROM iterations WHERE session_id = ?
358
+ ) WHERE id = ?
359
+ `).run(session_id, session_id);
360
+
361
+ return Number(res.lastInsertRowid);
362
+ }
363
+
364
+ export function insertScreenshot(
365
+ session_id: string,
366
+ iteration_number: number | undefined,
367
+ phase: string,
368
+ description: string,
369
+ file_path: string,
370
+ event_id?: number
371
+ ): number {
372
+ const d = getDb();
373
+
374
+ // Insert first to get the ID
375
+ const stmt = d.prepare(`
376
+ INSERT INTO screenshots (session_id, iteration_number, phase, description, file_path, event_id)
377
+ VALUES (?, ?, ?, ?, ?, ?)
378
+ `);
379
+ const result = stmt.run(
380
+ session_id,
381
+ iteration_number ?? null,
382
+ phase,
383
+ description,
384
+ file_path,
385
+ event_id ?? null
386
+ );
387
+ const screenshotId = Number(result.lastInsertRowid);
388
+
389
+ // Try to copy the file into /data/screenshots/ for self-contained serving
390
+ const ext = path.extname(file_path) || ".png";
391
+ const storedName = `${session_id}_${screenshotId}${ext}`;
392
+ const thumbName = `${session_id}_${screenshotId}_thumb${ext}`;
393
+ const storedPath = path.join("/data", "screenshots", storedName);
394
+ const thumbPath = path.join("/data", "screenshots", thumbName);
395
+
396
+ try {
397
+ // The file_path might be a host path — try multiple locations
398
+ const candidates = [
399
+ file_path,
400
+ // Map host path into /data mount: /Users/.../outputs/foo → /data/foo
401
+ file_path.replace(/^.*?outputs\//, "/data/"),
402
+ ];
403
+
404
+ for (const candidate of candidates) {
405
+ if (fs.existsSync(candidate)) {
406
+ // Copy full-size
407
+ fs.copyFileSync(candidate, storedPath);
408
+
409
+ // Generate thumbnail async (don't block the response)
410
+ sharp(candidate)
411
+ .resize(300)
412
+ .toFile(thumbPath)
413
+ .then(() => {
414
+ d.prepare("UPDATE screenshots SET stored_path = ?, thumb_path = ? WHERE id = ?").run(storedPath, thumbPath, screenshotId);
415
+ })
416
+ .catch(() => {
417
+ // Thumbnail generation failed — just store the full path
418
+ d.prepare("UPDATE screenshots SET stored_path = ? WHERE id = ?").run(storedPath, screenshotId);
419
+ });
420
+
421
+ break;
422
+ }
423
+ }
424
+ } catch {
425
+ // File copy failed — stored_path stays null, original file_path still works via volume mount
426
+ }
427
+
428
+ return screenshotId;
429
+ }
430
+
431
+ const MIME_TYPES: Record<string, string> = {
432
+ ".png": "image/png",
433
+ ".jpg": "image/jpeg",
434
+ ".jpeg": "image/jpeg",
435
+ ".gif": "image/gif",
436
+ ".svg": "image/svg+xml",
437
+ ".webp": "image/webp",
438
+ ".pdf": "application/pdf",
439
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
440
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
441
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
442
+ ".json": "application/json",
443
+ ".csv": "text/csv",
444
+ ".txt": "text/plain",
445
+ ".md": "text/markdown",
446
+ ".html": "text/html",
447
+ ".zip": "application/zip",
448
+ };
449
+
450
+ export function insertArtifact(
451
+ session_id: string,
452
+ file_path: string,
453
+ artifact_type: string,
454
+ description: string,
455
+ metadata?: object,
456
+ event_id?: number
457
+ ): number {
458
+ const d = getDb();
459
+
460
+ const ext = path.extname(file_path).toLowerCase();
461
+ const mime_type = MIME_TYPES[ext] || "application/octet-stream";
462
+
463
+ const stmt = d.prepare(`
464
+ INSERT INTO artifacts (session_id, artifact_type, description, file_path, mime_type, metadata, event_id)
465
+ VALUES (?, ?, ?, ?, ?, ?, ?)
466
+ `);
467
+ const result = stmt.run(
468
+ session_id,
469
+ artifact_type,
470
+ description,
471
+ file_path,
472
+ mime_type,
473
+ metadata ? JSON.stringify(metadata) : null,
474
+ event_id ?? null
475
+ );
476
+ const artifactId = Number(result.lastInsertRowid);
477
+
478
+ // Try to copy the file into /data/artifacts/ for self-contained serving
479
+ const storedName = `${session_id}_${artifactId}${ext}`;
480
+ const storedPath = path.join("/data", "artifacts", storedName);
481
+
482
+ try {
483
+ const candidates = [
484
+ file_path,
485
+ file_path.replace(/^.*?outputs\//, "/data/"),
486
+ ];
487
+
488
+ for (const candidate of candidates) {
489
+ if (fs.existsSync(candidate)) {
490
+ fs.copyFileSync(candidate, storedPath);
491
+ const stat = fs.statSync(storedPath);
492
+ d.prepare("UPDATE artifacts SET stored_path = ?, file_size = ? WHERE id = ?").run(
493
+ storedPath,
494
+ stat.size,
495
+ artifactId
496
+ );
497
+ break;
498
+ }
499
+ }
500
+ } catch {
501
+ // File copy failed — stored_path stays null
502
+ }
503
+
504
+ return artifactId;
505
+ }
506
+
507
+ export function endSession(
508
+ session_id: string,
509
+ status: string,
510
+ total_input_tokens?: number,
511
+ total_output_tokens?: number,
512
+ final_result?: FinalResult
513
+ ): { duration_seconds: number; total_iterations: number } {
514
+ const d = getDb();
515
+
516
+ const session = d
517
+ .prepare(`SELECT started_at, total_iterations, total_input_tokens, total_output_tokens FROM sessions WHERE id = ?`)
518
+ .get(session_id) as { started_at: string; total_iterations: number; total_input_tokens: number; total_output_tokens: number } | undefined;
519
+
520
+ if (!session) {
521
+ throw new Error(`Session ${session_id} not found`);
522
+ }
523
+
524
+ const startedAt = new Date(session.started_at + "Z");
525
+ const now = new Date();
526
+ const duration_seconds = Math.round((now.getTime() - startedAt.getTime()) / 1000);
527
+
528
+ // Use caller-provided totals only if they exceed the already-accumulated values.
529
+ // This prevents overwriting accumulated event-level tokens with 0.
530
+ let finalInput = Math.max(total_input_tokens ?? 0, session.total_input_tokens);
531
+ let finalOutput = Math.max(total_output_tokens ?? 0, session.total_output_tokens);
532
+
533
+ // Estimation fallback: if still 0/0, estimate from event count.
534
+ // Claude Code agents can't access their own token counts, so this provides
535
+ // a rough estimate rather than showing misleading zeroes.
536
+ if (finalInput === 0 && finalOutput === 0) {
537
+ const eventCount = (d.prepare(
538
+ "SELECT COUNT(*) as count FROM events WHERE session_id = ?"
539
+ ).get(session_id) as { count: number }).count;
540
+
541
+ if (eventCount > 0) {
542
+ // Each MCP tool call round-trip is ~3k input + ~1.5k output tokens on average
543
+ finalInput = eventCount * 3000;
544
+ finalOutput = eventCount * 1500;
545
+ }
546
+ }
547
+
548
+ d.prepare(`
549
+ UPDATE sessions
550
+ SET status = ?,
551
+ total_input_tokens = ?,
552
+ total_output_tokens = ?,
553
+ final_result = ?,
554
+ ended_at = datetime('now'),
555
+ duration_seconds = ?
556
+ WHERE id = ?
557
+ `).run(
558
+ status,
559
+ finalInput,
560
+ finalOutput,
561
+ final_result ? JSON.stringify(final_result) : null,
562
+ duration_seconds,
563
+ session_id
564
+ );
565
+
566
+ return { duration_seconds, total_iterations: session.total_iterations };
567
+ }
568
+
569
+ export function createPlan(
570
+ session_id: string,
571
+ steps: Array<{
572
+ step_number: number;
573
+ title: string;
574
+ description?: string;
575
+ parent_step?: number;
576
+ file_targets?: string[];
577
+ }>
578
+ ): number[] {
579
+ const d = getDb();
580
+ const stmt = d.prepare(`
581
+ INSERT INTO plan_steps (session_id, step_number, title, description, parent_step, file_targets)
582
+ VALUES (?, ?, ?, ?, ?, ?)
583
+ `);
584
+
585
+ const ids: number[] = [];
586
+ const insertAll = d.transaction(() => {
587
+ for (const step of steps) {
588
+ const result = stmt.run(
589
+ session_id,
590
+ step.step_number,
591
+ step.title,
592
+ step.description ?? "",
593
+ step.parent_step ?? null,
594
+ step.file_targets ? JSON.stringify(step.file_targets) : null
595
+ );
596
+ ids.push(Number(result.lastInsertRowid));
597
+ }
598
+ });
599
+ insertAll();
600
+ return ids;
601
+ }
602
+
603
+ export function updatePlanStep(
604
+ step_id: number,
605
+ status: string,
606
+ outcome?: string
607
+ ): void {
608
+ const d = getDb();
609
+
610
+ if (status === "in_progress") {
611
+ d.prepare(`
612
+ UPDATE plan_steps SET status = ?, started_at = datetime('now') WHERE id = ?
613
+ `).run(status, step_id);
614
+ } else if (status === "completed" || status === "failed" || status === "skipped") {
615
+ // Calculate duration from started_at
616
+ const step = d.prepare("SELECT started_at FROM plan_steps WHERE id = ?").get(step_id) as { started_at: string | null } | undefined;
617
+ let duration: number | null = null;
618
+ if (step?.started_at) {
619
+ duration = Math.round((Date.now() - new Date(step.started_at + "Z").getTime()) / 1000);
620
+ }
621
+ d.prepare(`
622
+ UPDATE plan_steps SET status = ?, outcome = ?, completed_at = datetime('now'), duration_seconds = ? WHERE id = ?
623
+ `).run(status, outcome ?? null, duration, step_id);
624
+ } else {
625
+ d.prepare(`UPDATE plan_steps SET status = ? WHERE id = ?`).run(status, step_id);
626
+ }
627
+ }
628
+
629
+ export function getPlanSteps(session_id: string): import("./types.js").PlanStep[] {
630
+ const d = getDb();
631
+ return d.prepare(
632
+ "SELECT * FROM plan_steps WHERE session_id = ? ORDER BY step_number ASC"
633
+ ).all(session_id) as import("./types.js").PlanStep[];
634
+ }
635
+
636
+ export function insertInsight(
637
+ session_id: string,
638
+ insight_type: string,
639
+ category: string,
640
+ title: string,
641
+ description: string,
642
+ evidence?: string,
643
+ confidence?: number,
644
+ file_patterns?: string[],
645
+ error_patterns?: string[]
646
+ ): number {
647
+ const d = getDb();
648
+ const stmt = d.prepare(`
649
+ INSERT INTO insights (session_id, insight_type, category, title, description, evidence, confidence, file_patterns, error_patterns)
650
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
651
+ `);
652
+ const result = stmt.run(
653
+ session_id,
654
+ insight_type,
655
+ category,
656
+ title,
657
+ description,
658
+ evidence ?? null,
659
+ confidence ?? 0.5,
660
+ file_patterns ? JSON.stringify(file_patterns) : null,
661
+ error_patterns ? JSON.stringify(error_patterns) : null
662
+ );
663
+ return Number(result.lastInsertRowid);
664
+ }
665
+
666
+ export function validateInsight(insight_id: number): void {
667
+ const d = getDb();
668
+ d.prepare(`
669
+ UPDATE insights SET times_validated = times_validated + 1, confidence = MIN(1.0, confidence + 0.1)
670
+ WHERE id = ?
671
+ `).run(insight_id);
672
+ }
673
+
674
+ export function getInsightsForContext(
675
+ category?: string,
676
+ file_pattern?: string,
677
+ limit: number = 20
678
+ ): import("./types.js").Insight[] {
679
+ const d = getDb();
680
+
681
+ if (category && file_pattern) {
682
+ return d.prepare(`
683
+ SELECT * FROM insights
684
+ WHERE category = ? AND (file_patterns LIKE ? OR file_patterns IS NULL)
685
+ ORDER BY confidence DESC, times_validated DESC
686
+ LIMIT ?
687
+ `).all(category, `%${file_pattern}%`, limit) as import("./types.js").Insight[];
688
+ }
689
+
690
+ if (category) {
691
+ return d.prepare(`
692
+ SELECT * FROM insights
693
+ WHERE category = ?
694
+ ORDER BY confidence DESC, times_validated DESC
695
+ LIMIT ?
696
+ `).all(category, limit) as import("./types.js").Insight[];
697
+ }
698
+
699
+ return d.prepare(`
700
+ SELECT * FROM insights
701
+ ORDER BY confidence DESC, times_validated DESC
702
+ LIMIT ?
703
+ `).all(limit) as import("./types.js").Insight[];
704
+ }
705
+
706
+ export function getSessionSummary(session_id?: string): {
707
+ session: Session;
708
+ events: Event[];
709
+ iterations: Iteration[];
710
+ screenshots: Screenshot[];
711
+ artifacts: Artifact[];
712
+ insights: import("./types.js").Insight[];
713
+ plan_steps: import("./types.js").PlanStep[];
714
+ child_sessions: Session[];
715
+ } | null {
716
+ const d = getDb();
717
+
718
+ let session: Session | undefined;
719
+ if (session_id) {
720
+ session = d
721
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
722
+ .get(session_id) as Session | undefined;
723
+ } else {
724
+ session = d
725
+ .prepare(`SELECT * FROM sessions ORDER BY created_at DESC LIMIT 1`)
726
+ .get() as Session | undefined;
727
+ }
728
+
729
+ if (!session) {
730
+ return null;
731
+ }
732
+
733
+ const events = d
734
+ .prepare(`SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC`)
735
+ .all(session.id) as Event[];
736
+
737
+ const iterations = d
738
+ .prepare(
739
+ `SELECT * FROM iterations WHERE session_id = ? ORDER BY iteration_number ASC`
740
+ )
741
+ .all(session.id) as Iteration[];
742
+
743
+ const screenshots = d
744
+ .prepare(`SELECT * FROM screenshots WHERE session_id = ? ORDER BY timestamp ASC`)
745
+ .all(session.id) as Screenshot[];
746
+
747
+ const artifacts = d
748
+ .prepare(`SELECT * FROM artifacts WHERE session_id = ? ORDER BY timestamp ASC`)
749
+ .all(session.id) as Artifact[];
750
+
751
+ const insights = d
752
+ .prepare(`SELECT * FROM insights WHERE session_id = ? ORDER BY confidence DESC`)
753
+ .all(session.id) as import("./types.js").Insight[];
754
+
755
+ const plan_steps = d
756
+ .prepare(`SELECT * FROM plan_steps WHERE session_id = ? ORDER BY step_number ASC`)
757
+ .all(session.id) as import("./types.js").PlanStep[];
758
+
759
+ const child_sessions = d
760
+ .prepare(`SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY created_at ASC`)
761
+ .all(session.id) as Session[];
762
+
763
+ return { session, events, iterations, screenshots, artifacts, insights, plan_steps, child_sessions };
764
+ }
765
+
766
+ export interface ComparisonFinding {
767
+ severity: string;
768
+ title: string;
769
+ details: string;
770
+ }
771
+
772
+ export interface ComparisonResult {
773
+ slide: number;
774
+ template: string;
775
+ diff_pct: number;
776
+ findings: ComparisonFinding[];
777
+ message: string;
778
+ screenshot_id: number | null;
779
+ screenshot_url: string | null;
780
+ timestamp: string;
781
+ }
782
+
783
+ export function getComparisonResults(
784
+ session_id: string,
785
+ min_severity?: string
786
+ ): { results: ComparisonResult[]; summary: Record<string, unknown> | null } {
787
+ const d = getDb();
788
+
789
+ // Get per-slide comparison events (phase=verification, event_type=action with findings in metadata)
790
+ const events = d.prepare(`
791
+ SELECT e.id, e.message, e.metadata, e.timestamp
792
+ FROM events e
793
+ WHERE e.session_id = ?
794
+ AND e.phase = 'verification'
795
+ AND e.event_type = 'action'
796
+ AND e.metadata LIKE '%"findings"%'
797
+ ORDER BY e.timestamp ASC
798
+ `).all(session_id) as Array<{ id: number; message: string; metadata: string | null; timestamp: string }>;
799
+
800
+ const severityRank: Record<string, number> = { CRITICAL: 3, NOTABLE: 2, MINOR: 1, GOOD: 0 };
801
+ const minRank = min_severity ? (severityRank[min_severity.toUpperCase()] ?? 0) : 0;
802
+
803
+ const results: ComparisonResult[] = [];
804
+
805
+ for (const ev of events) {
806
+ if (!ev.metadata) continue;
807
+ let meta: Record<string, unknown>;
808
+ try {
809
+ meta = JSON.parse(ev.metadata);
810
+ } catch {
811
+ continue;
812
+ }
813
+
814
+ const findings = (meta.findings as ComparisonFinding[] | undefined) ?? [];
815
+
816
+ // Filter by minimum severity if requested
817
+ const filtered = min_severity
818
+ ? findings.filter((f) => (severityRank[f.severity?.toUpperCase()] ?? 0) >= minRank)
819
+ : findings;
820
+
821
+ if (min_severity && filtered.length === 0) continue;
822
+
823
+ // Find linked screenshot
824
+ const screenshot = d.prepare(
825
+ "SELECT id FROM screenshots WHERE session_id = ? AND event_id = ? LIMIT 1"
826
+ ).get(session_id, ev.id) as { id: number } | undefined;
827
+
828
+ // If no event_id link, try matching by timestamp proximity
829
+ let screenshotId = screenshot?.id ?? null;
830
+ if (!screenshotId) {
831
+ const nearby = d.prepare(
832
+ "SELECT id FROM screenshots WHERE session_id = ? AND phase = 'verification' AND abs(julianday(timestamp) - julianday(?)) < 0.001 LIMIT 1"
833
+ ).get(session_id, ev.timestamp) as { id: number } | undefined;
834
+ screenshotId = nearby?.id ?? null;
835
+ }
836
+
837
+ results.push({
838
+ slide: (meta.slide as number) ?? 0,
839
+ template: (meta.template as string) ?? "unknown",
840
+ diff_pct: (meta.diff_pct as number) ?? 0,
841
+ findings: filtered,
842
+ message: ev.message,
843
+ screenshot_id: screenshotId,
844
+ screenshot_url: screenshotId ? `/api/screenshots/by-id/${screenshotId}` : null,
845
+ timestamp: ev.timestamp,
846
+ });
847
+ }
848
+
849
+ // Get the summary event if it exists
850
+ const summaryEvent = d.prepare(`
851
+ SELECT metadata FROM events
852
+ WHERE session_id = ?
853
+ AND phase = 'deliverables'
854
+ AND event_type = 'info'
855
+ AND metadata LIKE '%"total"%'
856
+ ORDER BY timestamp DESC LIMIT 1
857
+ `).get(session_id) as { metadata: string | null } | undefined;
858
+
859
+ let summary: Record<string, unknown> | null = null;
860
+ if (summaryEvent?.metadata) {
861
+ try {
862
+ summary = JSON.parse(summaryEvent.metadata);
863
+ } catch { /* ignore */ }
864
+ }
865
+
866
+ return { results, summary };
867
+ }
868
+
869
+ export function listSessions(
870
+ limit: number = 10,
871
+ status?: string,
872
+ parent_session_id?: string | null,
873
+ user_email?: string
874
+ ): Session[] {
875
+ const d = getDb();
876
+ const conditions: string[] = [];
877
+ const params: unknown[] = [];
878
+
879
+ if (parent_session_id) {
880
+ conditions.push("parent_session_id = ?");
881
+ params.push(parent_session_id);
882
+ } else {
883
+ // Default: only show top-level sessions (no parent)
884
+ conditions.push("parent_session_id IS NULL");
885
+ }
886
+
887
+ if (status) {
888
+ conditions.push("status = ?");
889
+ params.push(status);
890
+ }
891
+
892
+ if (user_email) {
893
+ conditions.push("user_email = ?");
894
+ params.push(user_email);
895
+ }
896
+
897
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
898
+ params.push(limit);
899
+
900
+ return d
901
+ .prepare(`SELECT * FROM sessions ${where} ORDER BY created_at DESC LIMIT ?`)
902
+ .all(...params) as Session[];
903
+ }
904
+
905
+ // --- Walkthrough functions ---
906
+
907
+ export function createWalkthrough(
908
+ url: string,
909
+ app_name?: string,
910
+ scenario?: string
911
+ ): number {
912
+ const d = getDb();
913
+ const result = d.prepare(`
914
+ INSERT INTO walkthroughs (url, app_name, scenario)
915
+ VALUES (?, ?, ?)
916
+ `).run(url, app_name ?? null, scenario ?? null);
917
+ return Number(result.lastInsertRowid);
918
+ }
919
+
920
+ export function insertWalkthroughAction(
921
+ walkthrough_id: number,
922
+ action_type: string,
923
+ target: string,
924
+ value?: string,
925
+ status?: string,
926
+ message?: string
927
+ ): number {
928
+ const d = getDb();
929
+ const result = d.prepare(`
930
+ INSERT INTO walkthrough_actions (walkthrough_id, action_type, target, value, status, message)
931
+ VALUES (?, ?, ?, ?, ?, ?)
932
+ `).run(walkthrough_id, action_type, target, value ?? null, status ?? "pass", message ?? null);
933
+ return Number(result.lastInsertRowid);
934
+ }
935
+
936
+ export function insertWalkthroughScreenshot(
937
+ walkthrough_id: number,
938
+ name: string,
939
+ image_data?: string,
940
+ annotation?: string,
941
+ action_id?: number,
942
+ file_path_input?: string
943
+ ): number {
944
+ const d = getDb();
945
+
946
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
947
+ const filename = `wt_${walkthrough_id}_${Date.now()}_${safeName}.png`;
948
+ const storedPath = path.join("/data", "walkthrough-screenshots", filename);
949
+
950
+ if (file_path_input && fs.existsSync(file_path_input)) {
951
+ // Copy from file path
952
+ fs.copyFileSync(file_path_input, storedPath);
953
+ } else if (image_data) {
954
+ // Decode base64 and save to disk
955
+ const buffer = Buffer.from(image_data, "base64");
956
+ fs.writeFileSync(storedPath, buffer);
957
+ } else {
958
+ throw new Error("Either image_data or file_path must be provided");
959
+ }
960
+
961
+ const result = d.prepare(`
962
+ INSERT INTO walkthrough_screenshots (walkthrough_id, action_id, name, annotation, stored_path)
963
+ VALUES (?, ?, ?, ?, ?)
964
+ `).run(walkthrough_id, action_id ?? null, name, annotation ?? null, storedPath);
965
+ return Number(result.lastInsertRowid);
966
+ }
967
+
968
+ export function endWalkthrough(
969
+ walkthrough_id: number,
970
+ status: string,
971
+ summary: string,
972
+ total_actions?: number,
973
+ passed?: number,
974
+ failed?: number
975
+ ): void {
976
+ const d = getDb();
977
+ d.prepare(`
978
+ UPDATE walkthroughs
979
+ SET status = ?, summary = ?, total_actions = ?, passed = ?, failed = ?, ended_at = datetime('now')
980
+ WHERE id = ?
981
+ `).run(status, summary, total_actions ?? 0, passed ?? 0, failed ?? 0, walkthrough_id);
982
+ }
983
+
984
+ export function listWalkthroughs(limit: number = 50): Walkthrough[] {
985
+ const d = getDb();
986
+ return d.prepare(
987
+ "SELECT * FROM walkthroughs ORDER BY started_at DESC LIMIT ?"
988
+ ).all(limit) as Walkthrough[];
989
+ }
990
+
991
+ export function getWalkthroughDetail(id: number): {
992
+ walkthrough: Walkthrough;
993
+ actions: WalkthroughAction[];
994
+ screenshots: WalkthroughScreenshot[];
995
+ } | null {
996
+ const d = getDb();
997
+ const walkthrough = d.prepare("SELECT * FROM walkthroughs WHERE id = ?").get(id) as Walkthrough | undefined;
998
+ if (!walkthrough) return null;
999
+
1000
+ const actions = d.prepare(
1001
+ "SELECT * FROM walkthrough_actions WHERE walkthrough_id = ? ORDER BY timestamp ASC"
1002
+ ).all(id) as WalkthroughAction[];
1003
+
1004
+ const screenshots = d.prepare(
1005
+ "SELECT * FROM walkthrough_screenshots WHERE walkthrough_id = ? ORDER BY timestamp ASC"
1006
+ ).all(id) as WalkthroughScreenshot[];
1007
+
1008
+ return { walkthrough, actions, screenshots };
1009
+ }