laminark 0.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 (55) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/plugin/.claude-plugin/plugin.json +13 -0
  4. package/plugin/.mcp.json +12 -0
  5. package/plugin/CLAUDE.md +10 -0
  6. package/plugin/commands/recall.md +55 -0
  7. package/plugin/commands/remember.md +34 -0
  8. package/plugin/commands/resume.md +45 -0
  9. package/plugin/commands/stash.md +34 -0
  10. package/plugin/commands/status.md +33 -0
  11. package/plugin/dist/analysis/worker.d.ts +1 -0
  12. package/plugin/dist/analysis/worker.js +233 -0
  13. package/plugin/dist/analysis/worker.js.map +1 -0
  14. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  15. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  16. package/plugin/dist/hooks/handler.d.ts +286 -0
  17. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  18. package/plugin/dist/hooks/handler.js +2413 -0
  19. package/plugin/dist/hooks/handler.js.map +1 -0
  20. package/plugin/dist/index.d.ts +447 -0
  21. package/plugin/dist/index.d.ts.map +1 -0
  22. package/plugin/dist/index.js +7334 -0
  23. package/plugin/dist/index.js.map +1 -0
  24. package/plugin/dist/observations-CorAAc1A.d.mts +192 -0
  25. package/plugin/dist/observations-CorAAc1A.d.mts.map +1 -0
  26. package/plugin/dist/tool-registry-e710BvXq.mjs +3574 -0
  27. package/plugin/dist/tool-registry-e710BvXq.mjs.map +1 -0
  28. package/plugin/hooks/hooks.json +78 -0
  29. package/plugin/laminark.db +0 -0
  30. package/plugin/package.json +17 -0
  31. package/plugin/scripts/README.md +65 -0
  32. package/plugin/scripts/bump-version.sh +42 -0
  33. package/plugin/scripts/dev-sync.sh +58 -0
  34. package/plugin/scripts/ensure-deps.sh +15 -0
  35. package/plugin/scripts/install.sh +139 -0
  36. package/plugin/scripts/local-install.sh +138 -0
  37. package/plugin/scripts/uninstall.sh +133 -0
  38. package/plugin/scripts/update.sh +39 -0
  39. package/plugin/scripts/verify-install.sh +87 -0
  40. package/plugin/skills/status/SKILL.md +6 -0
  41. package/plugin/ui/activity.js +197 -0
  42. package/plugin/ui/app.js +1612 -0
  43. package/plugin/ui/graph.js +2560 -0
  44. package/plugin/ui/help/activity-feed.png +0 -0
  45. package/plugin/ui/help/analysis-panel.png +0 -0
  46. package/plugin/ui/help/graph-toolbar.png +0 -0
  47. package/plugin/ui/help/graph-view.png +0 -0
  48. package/plugin/ui/help/settings.png +0 -0
  49. package/plugin/ui/help/timeline.png +0 -0
  50. package/plugin/ui/help.js +932 -0
  51. package/plugin/ui/index.html +756 -0
  52. package/plugin/ui/settings.js +1414 -0
  53. package/plugin/ui/styles.css +3856 -0
  54. package/plugin/ui/timeline.js +652 -0
  55. package/plugin/ui/tools.js +826 -0
@@ -0,0 +1,3574 @@
1
+ import { a as isDebugEnabled, t as getConfigDir } from "./config-t8LZeB-u.mjs";
2
+ import Database from "better-sqlite3";
3
+ import * as sqliteVec from "sqlite-vec";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+ import { z } from "zod";
8
+
9
+ //#region src/shared/debug.ts
10
+ /**
11
+ * Internal cached state for debug mode.
12
+ * Resolved on first call and never changes (debug mode is process-lifetime).
13
+ */
14
+ let _enabled = null;
15
+ function enabled() {
16
+ if (_enabled === null) _enabled = isDebugEnabled();
17
+ return _enabled;
18
+ }
19
+ /**
20
+ * Logs a debug message to stderr when debug mode is active.
21
+ *
22
+ * When debug is disabled (the default), this is a near-zero-cost no-op after the
23
+ * first call -- the cached flag short-circuits immediately.
24
+ *
25
+ * Format: `[ISO_TIMESTAMP] [LAMINARK:category] message {json_data}`
26
+ *
27
+ * @param category - Debug category (e.g., 'db', 'obs', 'search', 'session')
28
+ * @param message - Human-readable log message
29
+ * @param data - Optional structured data to include (keep lightweight -- no large payloads)
30
+ */
31
+ function debug(category, message, data) {
32
+ if (!enabled()) return;
33
+ let line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [LAMINARK:${category}] ${message}`;
34
+ if (data !== void 0) line += ` ${JSON.stringify(data)}`;
35
+ process.stderr.write(line + "\n");
36
+ }
37
+ /**
38
+ * Wraps a synchronous function with timing instrumentation.
39
+ *
40
+ * When debug is disabled, calls `fn()` directly with zero overhead --
41
+ * no timing measurement, no wrapping.
42
+ *
43
+ * @param category - Debug category for the log line
44
+ * @param message - Description of the operation being timed
45
+ * @param fn - Synchronous function to execute and time
46
+ * @returns The return value of `fn()`
47
+ */
48
+ function debugTimed(category, message, fn) {
49
+ if (!enabled()) return fn();
50
+ const start = performance.now();
51
+ const result = fn();
52
+ debug(category, `${message} (${(performance.now() - start).toFixed(2)}ms)`);
53
+ return result;
54
+ }
55
+
56
+ //#endregion
57
+ //#region src/storage/migrations.ts
58
+ /**
59
+ * All schema migrations in order.
60
+ *
61
+ * Migration 001: Observations table with INTEGER PRIMARY KEY AUTOINCREMENT
62
+ * (critical for FTS5 content_rowid stability across VACUUM).
63
+ * Migration 002: Sessions table for session lifecycle tracking.
64
+ * Migration 003: FTS5 external content table with porter+unicode61 tokenizer
65
+ * and three sync triggers (INSERT, UPDATE, DELETE).
66
+ * Migration 004: sqlite-vec vec0 table for 384-dim embeddings (conditional).
67
+ * Migration 005: Add title column to observations and rebuild FTS5 with
68
+ * title+content dual-column indexing.
69
+ * Migration 006: Recreate vec0 table with cosine distance metric (conditional).
70
+ * Migration 007: Context stashes table for topic detection thread snapshots.
71
+ * Migration 008: Threshold history table for EWMA adaptive threshold seeding.
72
+ * Migration 009: Shift decisions table for topic shift decision logging.
73
+ * Migration 010: Project metadata table for project selector UI.
74
+ * Migration 011: Add project_hash to graph tables and backfill from observations.
75
+ * Migration 012: Add classification and classified_at columns for LLM-based observation classification.
76
+ * Migration 013: Research buffer table for exploration tool event buffering.
77
+ * Migration 014: Add kind column to observations with backfill from source field.
78
+ * Migration 015: Update graph taxonomy -- remove Tool/Person nodes, tighten CHECK constraints.
79
+ * Migration 016: Tool registry table for discovered tools with scope-aware uniqueness.
80
+ * Migration 017: Tool usage events table for per-event temporal tracking.
81
+ * Migration 018: Tool registry FTS5 + vec0 tables for hybrid search on tool descriptions.
82
+ * Migration 019: Add status column (active/stale/demoted) to tool_registry for staleness management.
83
+ * Migration 020: Debug path tables (debug_paths + path_waypoints) for resolution path tracking.
84
+ * Migration 021: Thought branch tables for coherent work unit tracking.
85
+ * Migration 022: Add trigger_hints column to tool_registry for proactive suggestion matching.
86
+ */
87
+ const MIGRATIONS = [
88
+ {
89
+ version: 1,
90
+ name: "create_observations",
91
+ up: `
92
+ CREATE TABLE observations (
93
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ id TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
95
+ project_hash TEXT NOT NULL,
96
+ content TEXT NOT NULL,
97
+ source TEXT NOT NULL DEFAULT 'unknown',
98
+ session_id TEXT,
99
+ embedding BLOB,
100
+ embedding_model TEXT,
101
+ embedding_version TEXT,
102
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
103
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
104
+ deleted_at TEXT
105
+ );
106
+
107
+ CREATE INDEX idx_observations_project ON observations(project_hash);
108
+ CREATE INDEX idx_observations_session ON observations(session_id);
109
+ CREATE INDEX idx_observations_created ON observations(created_at);
110
+ CREATE INDEX idx_observations_deleted ON observations(deleted_at) WHERE deleted_at IS NOT NULL;
111
+ `
112
+ },
113
+ {
114
+ version: 2,
115
+ name: "create_sessions",
116
+ up: `
117
+ CREATE TABLE sessions (
118
+ id TEXT PRIMARY KEY,
119
+ project_hash TEXT NOT NULL,
120
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
121
+ ended_at TEXT,
122
+ summary TEXT
123
+ );
124
+
125
+ CREATE INDEX idx_sessions_project ON sessions(project_hash);
126
+ CREATE INDEX idx_sessions_started ON sessions(started_at);
127
+ `
128
+ },
129
+ {
130
+ version: 3,
131
+ name: "create_fts5_observations",
132
+ up: `
133
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
134
+ content,
135
+ content='observations',
136
+ content_rowid='rowid',
137
+ tokenize='porter unicode61'
138
+ );
139
+
140
+ -- Sync trigger: INSERT
141
+ CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
142
+ INSERT INTO observations_fts(rowid, content)
143
+ VALUES (new.rowid, new.content);
144
+ END;
145
+
146
+ -- Sync trigger: UPDATE (delete old entry, insert new)
147
+ CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
148
+ INSERT INTO observations_fts(observations_fts, rowid, content)
149
+ VALUES('delete', old.rowid, old.content);
150
+ INSERT INTO observations_fts(rowid, content)
151
+ VALUES (new.rowid, new.content);
152
+ END;
153
+
154
+ -- Sync trigger: DELETE
155
+ CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
156
+ INSERT INTO observations_fts(observations_fts, rowid, content)
157
+ VALUES('delete', old.rowid, old.content);
158
+ END;
159
+ `
160
+ },
161
+ {
162
+ version: 4,
163
+ name: "create_vec0_embeddings",
164
+ up: `
165
+ CREATE VIRTUAL TABLE IF NOT EXISTS observation_embeddings USING vec0(
166
+ observation_id TEXT PRIMARY KEY,
167
+ embedding float[384]
168
+ );
169
+ `
170
+ },
171
+ {
172
+ version: 5,
173
+ name: "add_observation_title",
174
+ up: `
175
+ ALTER TABLE observations ADD COLUMN title TEXT;
176
+
177
+ DROP TRIGGER observations_ai;
178
+ DROP TRIGGER observations_au;
179
+ DROP TRIGGER observations_ad;
180
+ DROP TABLE observations_fts;
181
+
182
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
183
+ title,
184
+ content,
185
+ content='observations',
186
+ content_rowid='rowid',
187
+ tokenize='porter unicode61'
188
+ );
189
+
190
+ CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
191
+ INSERT INTO observations_fts(rowid, title, content)
192
+ VALUES (new.rowid, new.title, new.content);
193
+ END;
194
+
195
+ CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
196
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
197
+ VALUES('delete', old.rowid, old.title, old.content);
198
+ INSERT INTO observations_fts(rowid, title, content)
199
+ VALUES (new.rowid, new.title, new.content);
200
+ END;
201
+
202
+ CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
203
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
204
+ VALUES('delete', old.rowid, old.title, old.content);
205
+ END;
206
+
207
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
208
+ `
209
+ },
210
+ {
211
+ version: 6,
212
+ name: "recreate_vec0_cosine_distance",
213
+ up: `
214
+ DROP TABLE IF EXISTS observation_embeddings;
215
+ CREATE VIRTUAL TABLE IF NOT EXISTS observation_embeddings USING vec0(
216
+ observation_id TEXT PRIMARY KEY,
217
+ embedding float[384] distance_metric=cosine
218
+ );
219
+ `
220
+ },
221
+ {
222
+ version: 7,
223
+ name: "create_context_stashes",
224
+ up: `
225
+ CREATE TABLE context_stashes (
226
+ id TEXT PRIMARY KEY,
227
+ project_id TEXT NOT NULL,
228
+ session_id TEXT NOT NULL,
229
+ topic_label TEXT NOT NULL,
230
+ summary TEXT NOT NULL,
231
+ observation_snapshots TEXT NOT NULL,
232
+ observation_ids TEXT NOT NULL,
233
+ status TEXT NOT NULL DEFAULT 'stashed',
234
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
235
+ resumed_at TEXT
236
+ );
237
+
238
+ CREATE INDEX idx_stashes_project_status_created
239
+ ON context_stashes(project_id, status, created_at DESC);
240
+
241
+ CREATE INDEX idx_stashes_session
242
+ ON context_stashes(session_id);
243
+ `
244
+ },
245
+ {
246
+ version: 8,
247
+ name: "create_threshold_history",
248
+ up: `
249
+ CREATE TABLE threshold_history (
250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
251
+ project_id TEXT NOT NULL,
252
+ session_id TEXT NOT NULL,
253
+ final_ewma_distance REAL NOT NULL,
254
+ final_ewma_variance REAL NOT NULL,
255
+ observation_count INTEGER NOT NULL,
256
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
257
+ );
258
+
259
+ CREATE INDEX idx_threshold_history_project
260
+ ON threshold_history(project_id, created_at DESC);
261
+ `
262
+ },
263
+ {
264
+ version: 9,
265
+ name: "create_shift_decisions",
266
+ up: `
267
+ CREATE TABLE shift_decisions (
268
+ id TEXT PRIMARY KEY,
269
+ project_id TEXT NOT NULL,
270
+ session_id TEXT NOT NULL,
271
+ observation_id TEXT,
272
+ distance REAL NOT NULL,
273
+ threshold REAL NOT NULL,
274
+ ewma_distance REAL,
275
+ ewma_variance REAL,
276
+ sensitivity_multiplier REAL,
277
+ shifted INTEGER NOT NULL,
278
+ confidence REAL,
279
+ stash_id TEXT,
280
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
281
+ );
282
+
283
+ CREATE INDEX idx_shift_decisions_session
284
+ ON shift_decisions(project_id, session_id, created_at DESC);
285
+
286
+ CREATE INDEX idx_shift_decisions_shifted
287
+ ON shift_decisions(shifted, created_at DESC);
288
+ `
289
+ },
290
+ {
291
+ version: 10,
292
+ name: "create_project_metadata",
293
+ up: `
294
+ CREATE TABLE IF NOT EXISTS project_metadata (
295
+ project_hash TEXT PRIMARY KEY,
296
+ project_path TEXT NOT NULL,
297
+ display_name TEXT,
298
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
299
+ );
300
+ `
301
+ },
302
+ {
303
+ version: 11,
304
+ name: "add_project_hash_to_graph_tables",
305
+ up: (db) => {
306
+ const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
307
+ const columnExists = (table, column) => {
308
+ return db.prepare(`PRAGMA table_info('${table}')`).all().some((c) => c.name === column);
309
+ };
310
+ if (tableExists("graph_nodes") && !columnExists("graph_nodes", "project_hash")) {
311
+ db.exec("ALTER TABLE graph_nodes ADD COLUMN project_hash TEXT");
312
+ db.exec(`
313
+ UPDATE graph_nodes SET project_hash = (
314
+ SELECT o.project_hash FROM observations o
315
+ WHERE o.id IN (
316
+ SELECT value FROM json_each(graph_nodes.observation_ids)
317
+ )
318
+ LIMIT 1
319
+ ) WHERE project_hash IS NULL
320
+ `);
321
+ }
322
+ if (tableExists("graph_edges") && !columnExists("graph_edges", "project_hash")) {
323
+ db.exec("ALTER TABLE graph_edges ADD COLUMN project_hash TEXT");
324
+ db.exec(`
325
+ UPDATE graph_edges SET project_hash = (
326
+ SELECT gn.project_hash FROM graph_nodes gn
327
+ WHERE gn.id = graph_edges.source_id
328
+ ) WHERE project_hash IS NULL
329
+ `);
330
+ }
331
+ if (tableExists("graph_nodes")) db.exec("CREATE INDEX IF NOT EXISTS idx_graph_nodes_project ON graph_nodes(project_hash)");
332
+ if (tableExists("graph_edges")) db.exec("CREATE INDEX IF NOT EXISTS idx_graph_edges_project ON graph_edges(project_hash)");
333
+ }
334
+ },
335
+ {
336
+ version: 12,
337
+ name: "add_observation_classification",
338
+ up: `
339
+ ALTER TABLE observations ADD COLUMN classification TEXT;
340
+ ALTER TABLE observations ADD COLUMN classified_at TEXT;
341
+ CREATE INDEX idx_observations_classification
342
+ ON observations(classification) WHERE classification IS NOT NULL;
343
+ `
344
+ },
345
+ {
346
+ version: 13,
347
+ name: "create_research_buffer",
348
+ up: `
349
+ CREATE TABLE research_buffer (
350
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
351
+ project_hash TEXT NOT NULL,
352
+ session_id TEXT,
353
+ tool_name TEXT NOT NULL,
354
+ target TEXT NOT NULL,
355
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
356
+ );
357
+ CREATE INDEX idx_research_buffer_session ON research_buffer(session_id, created_at DESC);
358
+ `
359
+ },
360
+ {
361
+ version: 14,
362
+ name: "add_observation_kind",
363
+ up: (db) => {
364
+ db.exec("ALTER TABLE observations ADD COLUMN kind TEXT DEFAULT 'finding'");
365
+ db.exec(`
366
+ UPDATE observations SET kind = 'change'
367
+ WHERE source LIKE 'hook:Write' OR source LIKE 'hook:Edit'
368
+ `);
369
+ db.exec(`
370
+ UPDATE observations SET kind = 'verification'
371
+ WHERE source LIKE 'hook:Bash'
372
+ `);
373
+ db.exec(`
374
+ UPDATE observations SET kind = 'reference'
375
+ WHERE source LIKE 'hook:WebFetch' OR source LIKE 'hook:WebSearch'
376
+ `);
377
+ db.exec(`
378
+ UPDATE observations SET kind = 'finding'
379
+ WHERE source IN ('mcp:save_memory', 'manual', 'slash:remember')
380
+ AND kind = 'finding'
381
+ `);
382
+ db.exec(`
383
+ UPDATE observations
384
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
385
+ WHERE (source LIKE 'hook:Read' OR source LIKE 'hook:Glob' OR source LIKE 'hook:Grep')
386
+ AND deleted_at IS NULL
387
+ `);
388
+ db.exec("CREATE INDEX idx_observations_kind ON observations(kind)");
389
+ }
390
+ },
391
+ {
392
+ version: 15,
393
+ name: "update_graph_taxonomy",
394
+ up: (db) => {
395
+ const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
396
+ if (!tableExists("graph_nodes")) return;
397
+ db.exec("DELETE FROM graph_nodes WHERE type IN ('Tool', 'Person')");
398
+ db.exec("DELETE FROM graph_edges WHERE type IN ('uses', 'depends_on', 'decided_by', 'part_of')");
399
+ db.exec(`
400
+ CREATE TABLE graph_nodes_new (
401
+ id TEXT PRIMARY KEY,
402
+ type TEXT NOT NULL CHECK(type IN ('Project','File','Decision','Problem','Solution','Reference')),
403
+ name TEXT NOT NULL,
404
+ metadata TEXT DEFAULT '{}',
405
+ observation_ids TEXT DEFAULT '[]',
406
+ project_hash TEXT,
407
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
408
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
409
+ );
410
+
411
+ INSERT INTO graph_nodes_new SELECT * FROM graph_nodes;
412
+
413
+ DROP TABLE graph_nodes;
414
+ ALTER TABLE graph_nodes_new RENAME TO graph_nodes;
415
+
416
+ CREATE INDEX idx_graph_nodes_type ON graph_nodes(type);
417
+ CREATE INDEX idx_graph_nodes_name ON graph_nodes(name);
418
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_project ON graph_nodes(project_hash);
419
+ `);
420
+ db.exec(`
421
+ CREATE TABLE graph_edges_new (
422
+ id TEXT PRIMARY KEY,
423
+ source_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
424
+ target_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
425
+ type TEXT NOT NULL CHECK(type IN ('related_to','solved_by','caused_by','modifies','informed_by','references','verified_by','preceded_by')),
426
+ weight REAL NOT NULL DEFAULT 1.0 CHECK(weight >= 0.0 AND weight <= 1.0),
427
+ metadata TEXT DEFAULT '{}',
428
+ project_hash TEXT,
429
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
430
+ );
431
+
432
+ INSERT INTO graph_edges_new SELECT * FROM graph_edges;
433
+
434
+ DROP TABLE graph_edges;
435
+ ALTER TABLE graph_edges_new RENAME TO graph_edges;
436
+
437
+ CREATE INDEX idx_graph_edges_source ON graph_edges(source_id);
438
+ CREATE INDEX idx_graph_edges_target ON graph_edges(target_id);
439
+ CREATE INDEX idx_graph_edges_type ON graph_edges(type);
440
+ CREATE UNIQUE INDEX idx_graph_edges_unique ON graph_edges(source_id, target_id, type);
441
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_project ON graph_edges(project_hash);
442
+ `);
443
+ }
444
+ },
445
+ {
446
+ version: 16,
447
+ name: "create_tool_registry",
448
+ up: `
449
+ CREATE TABLE tool_registry (
450
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
451
+ name TEXT NOT NULL,
452
+ tool_type TEXT NOT NULL,
453
+ scope TEXT NOT NULL,
454
+ source TEXT NOT NULL,
455
+ project_hash TEXT,
456
+ description TEXT,
457
+ server_name TEXT,
458
+ usage_count INTEGER NOT NULL DEFAULT 0,
459
+ last_used_at TEXT,
460
+ discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
461
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
462
+ );
463
+
464
+ CREATE UNIQUE INDEX idx_tool_registry_name_project
465
+ ON tool_registry(name, COALESCE(project_hash, ''));
466
+ CREATE INDEX idx_tool_registry_scope
467
+ ON tool_registry(scope);
468
+ CREATE INDEX idx_tool_registry_project
469
+ ON tool_registry(project_hash) WHERE project_hash IS NOT NULL;
470
+ CREATE INDEX idx_tool_registry_usage
471
+ ON tool_registry(usage_count DESC, last_used_at DESC);
472
+ `
473
+ },
474
+ {
475
+ version: 17,
476
+ name: "create_tool_usage_events",
477
+ up: `
478
+ CREATE TABLE tool_usage_events (
479
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
480
+ tool_name TEXT NOT NULL,
481
+ session_id TEXT,
482
+ project_hash TEXT,
483
+ success INTEGER NOT NULL DEFAULT 1,
484
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
485
+ );
486
+
487
+ CREATE INDEX idx_tool_usage_events_tool
488
+ ON tool_usage_events(tool_name, created_at DESC);
489
+ CREATE INDEX idx_tool_usage_events_session
490
+ ON tool_usage_events(session_id) WHERE session_id IS NOT NULL;
491
+ CREATE INDEX idx_tool_usage_events_project_time
492
+ ON tool_usage_events(project_hash, created_at DESC);
493
+ `
494
+ },
495
+ {
496
+ version: 18,
497
+ name: "create_tool_registry_search",
498
+ up: (db) => {
499
+ db.exec(`
500
+ CREATE VIRTUAL TABLE tool_registry_fts USING fts5(
501
+ name,
502
+ description,
503
+ content='tool_registry',
504
+ content_rowid='id',
505
+ tokenize='porter unicode61'
506
+ );
507
+
508
+ -- Sync trigger: INSERT
509
+ CREATE TRIGGER tool_registry_ai AFTER INSERT ON tool_registry BEGIN
510
+ INSERT INTO tool_registry_fts(rowid, name, description)
511
+ VALUES (new.id, new.name, new.description);
512
+ END;
513
+
514
+ -- Sync trigger: UPDATE (delete old entry, insert new)
515
+ CREATE TRIGGER tool_registry_au AFTER UPDATE ON tool_registry BEGIN
516
+ INSERT INTO tool_registry_fts(tool_registry_fts, rowid, name, description)
517
+ VALUES('delete', old.id, old.name, old.description);
518
+ INSERT INTO tool_registry_fts(rowid, name, description)
519
+ VALUES (new.id, new.name, new.description);
520
+ END;
521
+
522
+ -- Sync trigger: DELETE
523
+ CREATE TRIGGER tool_registry_ad AFTER DELETE ON tool_registry BEGIN
524
+ INSERT INTO tool_registry_fts(tool_registry_fts, rowid, name, description)
525
+ VALUES('delete', old.id, old.name, old.description);
526
+ END;
527
+
528
+ -- Rebuild to index existing tool_registry rows
529
+ INSERT INTO tool_registry_fts(tool_registry_fts) VALUES('rebuild');
530
+ `);
531
+ try {
532
+ db.exec(`
533
+ CREATE VIRTUAL TABLE IF NOT EXISTS tool_registry_embeddings USING vec0(
534
+ tool_id INTEGER PRIMARY KEY,
535
+ embedding float[384] distance_metric=cosine
536
+ );
537
+ `);
538
+ } catch {}
539
+ }
540
+ },
541
+ {
542
+ version: 19,
543
+ name: "add_tool_registry_status",
544
+ up: `
545
+ ALTER TABLE tool_registry ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
546
+ CREATE INDEX idx_tool_registry_status ON tool_registry(status);
547
+ `
548
+ },
549
+ {
550
+ version: 20,
551
+ name: "create_debug_path_tables",
552
+ up: `
553
+ CREATE TABLE IF NOT EXISTS debug_paths (
554
+ id TEXT PRIMARY KEY,
555
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved', 'abandoned')),
556
+ trigger_summary TEXT NOT NULL,
557
+ resolution_summary TEXT,
558
+ kiss_summary TEXT,
559
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
560
+ resolved_at TEXT,
561
+ project_hash TEXT NOT NULL
562
+ );
563
+
564
+ CREATE TABLE IF NOT EXISTS path_waypoints (
565
+ id TEXT PRIMARY KEY,
566
+ path_id TEXT NOT NULL REFERENCES debug_paths(id) ON DELETE CASCADE,
567
+ observation_id TEXT,
568
+ waypoint_type TEXT NOT NULL CHECK(waypoint_type IN ('error', 'attempt', 'failure', 'success', 'pivot', 'revert', 'discovery', 'resolution')),
569
+ sequence_order INTEGER NOT NULL,
570
+ summary TEXT NOT NULL,
571
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
572
+ );
573
+
574
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_project_status
575
+ ON debug_paths(project_hash, status);
576
+
577
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_started
578
+ ON debug_paths(started_at DESC);
579
+
580
+ CREATE INDEX IF NOT EXISTS idx_path_waypoints_path_order
581
+ ON path_waypoints(path_id, sequence_order);
582
+ `
583
+ },
584
+ {
585
+ version: 21,
586
+ name: "create_thought_branch_tables",
587
+ up: `
588
+ CREATE TABLE IF NOT EXISTS thought_branches (
589
+ id TEXT PRIMARY KEY,
590
+ project_hash TEXT NOT NULL,
591
+ session_id TEXT,
592
+ status TEXT NOT NULL DEFAULT 'active'
593
+ CHECK(status IN ('active', 'completed', 'abandoned', 'merged')),
594
+ branch_type TEXT NOT NULL DEFAULT 'unknown'
595
+ CHECK(branch_type IN ('investigation', 'bug_fix', 'feature', 'refactor', 'research', 'unknown')),
596
+ arc_stage TEXT NOT NULL DEFAULT 'investigation'
597
+ CHECK(arc_stage IN ('investigation', 'diagnosis', 'planning', 'execution', 'verification', 'completed')),
598
+ title TEXT,
599
+ summary TEXT,
600
+ parent_branch_id TEXT REFERENCES thought_branches(id),
601
+ linked_debug_path_id TEXT,
602
+ trigger_source TEXT,
603
+ trigger_observation_id TEXT,
604
+ observation_count INTEGER NOT NULL DEFAULT 0,
605
+ tool_pattern TEXT DEFAULT '{}',
606
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
607
+ ended_at TEXT,
608
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
609
+ );
610
+
611
+ CREATE TABLE IF NOT EXISTS branch_observations (
612
+ branch_id TEXT NOT NULL REFERENCES thought_branches(id) ON DELETE CASCADE,
613
+ observation_id TEXT NOT NULL,
614
+ sequence_order INTEGER NOT NULL,
615
+ tool_name TEXT,
616
+ arc_stage_at_add TEXT,
617
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
618
+ PRIMARY KEY (branch_id, observation_id)
619
+ );
620
+
621
+ CREATE INDEX IF NOT EXISTS idx_thought_branches_project_status
622
+ ON thought_branches(project_hash, status);
623
+ CREATE INDEX IF NOT EXISTS idx_thought_branches_session
624
+ ON thought_branches(session_id);
625
+ CREATE INDEX IF NOT EXISTS idx_thought_branches_started
626
+ ON thought_branches(started_at DESC);
627
+ CREATE INDEX IF NOT EXISTS idx_branch_observations_obs
628
+ ON branch_observations(observation_id);
629
+ `
630
+ },
631
+ {
632
+ version: 22,
633
+ name: "add_tool_registry_trigger_hints",
634
+ up: `
635
+ ALTER TABLE tool_registry ADD COLUMN trigger_hints TEXT;
636
+ `
637
+ }
638
+ ];
639
+ /**
640
+ * Applies unapplied schema migrations in order.
641
+ *
642
+ * Creates a _migrations tracking table if it does not exist, then applies
643
+ * each migration whose version exceeds the current max applied version.
644
+ * Each migration runs inside a transaction for atomicity.
645
+ *
646
+ * Migrations 004 and 006 (vec0 tables) are only applied when hasVectorSupport
647
+ * is true. If sqlite-vec is not available, they are silently skipped and will
648
+ * be applied on a future run when the extension becomes available.
649
+ *
650
+ * @param db - An open better-sqlite3 database connection
651
+ * @param hasVectorSupport - Whether sqlite-vec loaded successfully
652
+ */
653
+ function runMigrations(db, hasVectorSupport) {
654
+ db.exec(`
655
+ CREATE TABLE IF NOT EXISTS _migrations (
656
+ version INTEGER PRIMARY KEY,
657
+ name TEXT NOT NULL,
658
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
659
+ )
660
+ `);
661
+ const maxVersion = db.prepare("SELECT COALESCE(MAX(version), 0) FROM _migrations").pluck().get();
662
+ const insertMigration = db.prepare("INSERT INTO _migrations (version, name) VALUES (?, ?)");
663
+ const applyMigration = db.transaction((m) => {
664
+ if (typeof m.up === "function") m.up(db);
665
+ else db.exec(m.up);
666
+ insertMigration.run(m.version, m.name);
667
+ });
668
+ for (const migration of MIGRATIONS) {
669
+ if (migration.version <= maxVersion) continue;
670
+ if ((migration.version === 4 || migration.version === 6) && !hasVectorSupport) continue;
671
+ applyMigration(migration);
672
+ }
673
+ }
674
+
675
+ //#endregion
676
+ //#region src/storage/database.ts
677
+ /**
678
+ * Opens a SQLite database with WAL mode, correct PRAGMA order,
679
+ * optional sqlite-vec extension loading, and schema migrations.
680
+ *
681
+ * Single connection per process by design -- better-sqlite3 is synchronous,
682
+ * so connection pooling adds zero benefit.
683
+ *
684
+ * @param config - Database path and busy timeout configuration
685
+ * @returns A configured LaminarkDatabase instance
686
+ */
687
+ function openDatabase(config) {
688
+ mkdirSync(dirname(config.dbPath), { recursive: true });
689
+ const db = new Database(config.dbPath);
690
+ const journalMode = db.pragma("journal_mode = WAL", { simple: true });
691
+ if (journalMode !== "wal") console.warn(`WARNING: WAL mode not active (got '${journalMode}'). Database may be on a read-only filesystem or otherwise restricted.`);
692
+ db.pragma(`busy_timeout = ${config.busyTimeout}`);
693
+ db.pragma("synchronous = NORMAL");
694
+ db.pragma("cache_size = -64000");
695
+ db.pragma("foreign_keys = ON");
696
+ db.pragma("temp_store = MEMORY");
697
+ db.pragma("wal_autocheckpoint = 1000");
698
+ debug("db", "PRAGMAs configured", {
699
+ journalMode,
700
+ busyTimeout: config.busyTimeout
701
+ });
702
+ let hasVectorSupport = false;
703
+ try {
704
+ sqliteVec.load(db);
705
+ hasVectorSupport = true;
706
+ } catch {}
707
+ debug("db", hasVectorSupport ? "sqlite-vec loaded" : "sqlite-vec unavailable, keyword-only mode");
708
+ runMigrations(db, hasVectorSupport);
709
+ debug("db", "Database opened", {
710
+ path: config.dbPath,
711
+ hasVectorSupport
712
+ });
713
+ return {
714
+ db,
715
+ hasVectorSupport,
716
+ close() {
717
+ try {
718
+ db.pragma("wal_checkpoint(PASSIVE)");
719
+ } catch {}
720
+ debug("db", "Database closed");
721
+ db.close();
722
+ },
723
+ checkpoint() {
724
+ db.pragma("wal_checkpoint(PASSIVE)");
725
+ }
726
+ };
727
+ }
728
+
729
+ //#endregion
730
+ //#region src/shared/types.ts
731
+ /**
732
+ * ObservationRow -- the raw database row shape.
733
+ * Uses snake_case to match SQL column names directly.
734
+ * rowid is INTEGER PRIMARY KEY AUTOINCREMENT for FTS5 content_rowid compatibility.
735
+ */
736
+ const ObservationRowSchema = z.object({
737
+ rowid: z.number(),
738
+ id: z.string(),
739
+ project_hash: z.string(),
740
+ content: z.string(),
741
+ title: z.string().nullable(),
742
+ source: z.string(),
743
+ session_id: z.string().nullable(),
744
+ embedding: z.instanceof(Buffer).nullable(),
745
+ embedding_model: z.string().nullable(),
746
+ embedding_version: z.string().nullable(),
747
+ kind: z.string().default("finding"),
748
+ classification: z.string().nullable(),
749
+ classified_at: z.string().nullable(),
750
+ created_at: z.string(),
751
+ updated_at: z.string(),
752
+ deleted_at: z.string().nullable()
753
+ });
754
+ /**
755
+ * ObservationInsert -- input for creating observations.
756
+ * Validated at runtime via Zod schema.
757
+ */
758
+ const ObservationInsertSchema = z.object({
759
+ content: z.string().min(1).max(1e5),
760
+ title: z.string().max(200).nullable().default(null),
761
+ source: z.string().default("unknown"),
762
+ kind: z.string().default("finding"),
763
+ sessionId: z.string().nullable().default(null),
764
+ embedding: z.instanceof(Float32Array).nullable().default(null),
765
+ embeddingModel: z.string().nullable().default(null),
766
+ embeddingVersion: z.string().nullable().default(null)
767
+ });
768
+ /**
769
+ * Maps a snake_case ObservationRow (from SQLite) to a camelCase Observation.
770
+ * Converts embedding Buffer to Float32Array for application use.
771
+ */
772
+ function rowToObservation(row) {
773
+ return {
774
+ rowid: row.rowid,
775
+ id: row.id,
776
+ projectHash: row.project_hash,
777
+ content: row.content,
778
+ title: row.title,
779
+ source: row.source,
780
+ sessionId: row.session_id,
781
+ kind: row.kind ?? "finding",
782
+ embedding: row.embedding ? new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4) : null,
783
+ embeddingModel: row.embedding_model,
784
+ embeddingVersion: row.embedding_version,
785
+ classification: row.classification,
786
+ classifiedAt: row.classified_at,
787
+ createdAt: row.created_at,
788
+ updatedAt: row.updated_at,
789
+ deletedAt: row.deleted_at
790
+ };
791
+ }
792
+
793
+ //#endregion
794
+ //#region src/storage/observations.ts
795
+ /**
796
+ * Repository for observation CRUD operations.
797
+ *
798
+ * Every query is scoped to the projectHash provided at construction time.
799
+ * Callers cannot accidentally query the wrong project -- project isolation
800
+ * is baked into every prepared statement.
801
+ *
802
+ * All SQL statements are prepared once in the constructor and reused for
803
+ * every call (better-sqlite3 performance best practice).
804
+ */
805
+ var ObservationRepository = class {
806
+ db;
807
+ projectHash;
808
+ stmtInsert;
809
+ stmtGetById;
810
+ stmtGetByIdIncludingDeleted;
811
+ stmtSoftDelete;
812
+ stmtRestore;
813
+ stmtCount;
814
+ constructor(db, projectHash) {
815
+ this.db = db;
816
+ this.projectHash = projectHash;
817
+ this.stmtInsert = db.prepare(`
818
+ INSERT INTO observations (id, project_hash, content, title, source, kind, session_id, embedding, embedding_model, embedding_version)
819
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
820
+ `);
821
+ this.stmtGetById = db.prepare(`
822
+ SELECT * FROM observations
823
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
824
+ `);
825
+ this.stmtGetByIdIncludingDeleted = db.prepare(`
826
+ SELECT * FROM observations
827
+ WHERE id = ? AND project_hash = ?
828
+ `);
829
+ this.stmtSoftDelete = db.prepare(`
830
+ UPDATE observations
831
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
832
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
833
+ `);
834
+ this.stmtRestore = db.prepare(`
835
+ UPDATE observations
836
+ SET deleted_at = NULL, updated_at = datetime('now')
837
+ WHERE id = ? AND project_hash = ?
838
+ `);
839
+ this.stmtCount = db.prepare(`
840
+ SELECT COUNT(*) AS count FROM observations
841
+ WHERE project_hash = ? AND deleted_at IS NULL
842
+ `);
843
+ debug("obs", "ObservationRepository initialized", { projectHash });
844
+ }
845
+ /**
846
+ * Creates a new observation scoped to this repository's project.
847
+ * Validates input with Zod at runtime.
848
+ */
849
+ create(input) {
850
+ const validated = ObservationInsertSchema.parse(input);
851
+ const id = randomBytes(16).toString("hex");
852
+ const embeddingBuffer = validated.embedding ? Buffer.from(validated.embedding.buffer, validated.embedding.byteOffset, validated.embedding.byteLength) : null;
853
+ debug("obs", "Creating observation", {
854
+ source: validated.source,
855
+ contentLength: validated.content.length
856
+ });
857
+ this.stmtInsert.run(id, this.projectHash, validated.content, validated.title, validated.source, validated.kind, validated.sessionId, embeddingBuffer, validated.embeddingModel, validated.embeddingVersion);
858
+ const row = this.stmtGetById.get(id, this.projectHash);
859
+ if (!row) throw new Error("Failed to retrieve newly created observation");
860
+ debug("obs", "Observation created", { id });
861
+ return rowToObservation(row);
862
+ }
863
+ /**
864
+ * Resolves a full or prefix ID to the full 32-char ID.
865
+ * Observation IDs are 32-char hex strings. Search results display only the
866
+ * first 8 chars via shortId(). This method allows callers to pass either
867
+ * a full ID or an 8-char (or any-length) prefix and get the full ID back.
868
+ * Returns null if no unique match is found.
869
+ */
870
+ resolveId(id) {
871
+ if (id.length === 32) return id;
872
+ const rows = this.db.prepare("SELECT id FROM observations WHERE project_hash = ? AND id LIKE ? ESCAPE '\\' LIMIT 2").all(this.projectHash, id.replace(/[%_\\]/g, "\\$&") + "%");
873
+ if (rows.length === 1) return rows[0].id;
874
+ if (rows.length > 1) debug("obs", "Ambiguous ID prefix - multiple matches", {
875
+ prefix: id,
876
+ count: rows.length
877
+ });
878
+ return null;
879
+ }
880
+ /**
881
+ * Gets an observation by ID, scoped to this project.
882
+ * Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
883
+ * display IDs shown in search results).
884
+ * Returns null if not found or soft-deleted.
885
+ */
886
+ getById(id) {
887
+ const resolvedId = this.resolveId(id);
888
+ if (!resolvedId) return null;
889
+ const row = this.stmtGetById.get(resolvedId, this.projectHash);
890
+ return row ? rowToObservation(row) : null;
891
+ }
892
+ /**
893
+ * Lists observations for this project, ordered by created_at DESC.
894
+ * Excludes soft-deleted observations.
895
+ */
896
+ list(options) {
897
+ debug("obs", "Listing observations", { ...options });
898
+ const limit = options?.limit ?? 50;
899
+ const offset = options?.offset ?? 0;
900
+ const includeUnclassified = options?.includeUnclassified ?? false;
901
+ let sql = "SELECT * FROM observations WHERE project_hash = ? AND deleted_at IS NULL";
902
+ const params = [this.projectHash];
903
+ if (!includeUnclassified) sql += " AND ((classification IS NOT NULL AND classification != 'noise') OR created_at >= datetime('now', '-60 seconds'))";
904
+ if (options?.kind) {
905
+ sql += " AND kind = ?";
906
+ params.push(options.kind);
907
+ }
908
+ if (options?.sessionId) {
909
+ sql += " AND session_id = ?";
910
+ params.push(options.sessionId);
911
+ }
912
+ if (options?.since) {
913
+ sql += " AND created_at >= ?";
914
+ params.push(options.since);
915
+ }
916
+ sql += " ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?";
917
+ params.push(limit, offset);
918
+ const rows = this.db.prepare(sql).all(...params);
919
+ debug("obs", "Listed observations", { count: rows.length });
920
+ return rows.map(rowToObservation);
921
+ }
922
+ /**
923
+ * Updates an observation's content, embedding fields, or both.
924
+ * Always sets updated_at to current time.
925
+ * Scoped to this project; returns null if not found or soft-deleted.
926
+ */
927
+ update(id, updates) {
928
+ debug("obs", "Updating observation", { id });
929
+ const setClauses = ["updated_at = datetime('now')"];
930
+ const params = [];
931
+ if (updates.content !== void 0) {
932
+ setClauses.push("content = ?");
933
+ params.push(updates.content);
934
+ }
935
+ if (updates.embedding !== void 0) {
936
+ setClauses.push("embedding = ?");
937
+ params.push(updates.embedding ? Buffer.from(updates.embedding.buffer, updates.embedding.byteOffset, updates.embedding.byteLength) : null);
938
+ }
939
+ if (updates.embeddingModel !== void 0) {
940
+ setClauses.push("embedding_model = ?");
941
+ params.push(updates.embeddingModel);
942
+ }
943
+ if (updates.embeddingVersion !== void 0) {
944
+ setClauses.push("embedding_version = ?");
945
+ params.push(updates.embeddingVersion);
946
+ }
947
+ params.push(id, this.projectHash);
948
+ const sql = `UPDATE observations SET ${setClauses.join(", ")} WHERE id = ? AND project_hash = ? AND deleted_at IS NULL`;
949
+ if (this.db.prepare(sql).run(...params).changes === 0) {
950
+ debug("obs", "Observation not found for update", { id });
951
+ return null;
952
+ }
953
+ debug("obs", "Observation updated", { id });
954
+ return this.getById(id);
955
+ }
956
+ /**
957
+ * Soft-deletes an observation by setting deleted_at.
958
+ * Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
959
+ * display IDs shown in search results).
960
+ * Returns true if the observation was found and deleted.
961
+ */
962
+ softDelete(id) {
963
+ debug("obs", "Soft-deleting observation", { id });
964
+ const resolvedId = this.resolveId(id) ?? id;
965
+ const result = this.stmtSoftDelete.run(resolvedId, this.projectHash);
966
+ debug("obs", result.changes > 0 ? "Observation soft-deleted" : "Observation not found for delete", { id: resolvedId });
967
+ return result.changes > 0;
968
+ }
969
+ /**
970
+ * Restores a soft-deleted observation by clearing deleted_at.
971
+ * Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
972
+ * display IDs shown in search results).
973
+ * Returns true if the observation was found and restored.
974
+ */
975
+ restore(id) {
976
+ const resolvedId = this.resolveId(id) ?? id;
977
+ return this.stmtRestore.run(resolvedId, this.projectHash).changes > 0;
978
+ }
979
+ /**
980
+ * Updates the classification of an observation.
981
+ * Sets classified_at to current time. Returns true if found and updated.
982
+ */
983
+ updateClassification(id, classification) {
984
+ debug("obs", "Updating classification", {
985
+ id,
986
+ classification
987
+ });
988
+ return this.db.prepare(`
989
+ UPDATE observations
990
+ SET classification = ?, classified_at = datetime('now'), updated_at = datetime('now')
991
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
992
+ `).run(classification, id, this.projectHash).changes > 0;
993
+ }
994
+ /**
995
+ * Creates an observation with an initial classification (bypasses classifier).
996
+ * Used for explicit user saves that should be immediately visible.
997
+ */
998
+ createClassified(input, classification) {
999
+ const obs = this.create(input);
1000
+ this.updateClassification(obs.id, classification);
1001
+ return this.getById(obs.id);
1002
+ }
1003
+ /**
1004
+ * Fetches unclassified observations for the background classifier.
1005
+ * Returns observations ordered by created_at ASC (oldest first).
1006
+ */
1007
+ listUnclassified(limit = 20) {
1008
+ return this.db.prepare(`
1009
+ SELECT * FROM observations
1010
+ WHERE project_hash = ? AND classification IS NULL AND deleted_at IS NULL
1011
+ ORDER BY created_at ASC
1012
+ LIMIT ?
1013
+ `).all(this.projectHash, limit).map(rowToObservation);
1014
+ }
1015
+ /**
1016
+ * Lists unclassified observations across ALL projects.
1017
+ * Used by HaikuProcessor to avoid missing observations from other projects.
1018
+ */
1019
+ static listAllUnclassified(db, limit = 20) {
1020
+ return db.prepare(`
1021
+ SELECT * FROM observations
1022
+ WHERE classification IS NULL AND deleted_at IS NULL
1023
+ ORDER BY created_at ASC
1024
+ LIMIT ?
1025
+ `).all(limit).map(rowToObservation);
1026
+ }
1027
+ /**
1028
+ * Fetches observations surrounding a given timestamp for classification context.
1029
+ * Returns observations regardless of classification status.
1030
+ */
1031
+ listContext(aroundTime, windowSize = 5) {
1032
+ const beforeRows = this.db.prepare(`
1033
+ SELECT * FROM observations
1034
+ WHERE project_hash = ? AND deleted_at IS NULL AND created_at <= ?
1035
+ ORDER BY created_at DESC, rowid DESC
1036
+ LIMIT ?
1037
+ `).all(this.projectHash, aroundTime, windowSize + 1);
1038
+ const afterRows = this.db.prepare(`
1039
+ SELECT * FROM observations
1040
+ WHERE project_hash = ? AND deleted_at IS NULL AND created_at > ?
1041
+ ORDER BY created_at ASC, rowid ASC
1042
+ LIMIT ?
1043
+ `).all(this.projectHash, aroundTime, windowSize);
1044
+ const allRows = [...beforeRows.reverse(), ...afterRows];
1045
+ const seen = /* @__PURE__ */ new Set();
1046
+ return allRows.filter((r) => {
1047
+ if (seen.has(r.id)) return false;
1048
+ seen.add(r.id);
1049
+ return true;
1050
+ }).map(rowToObservation);
1051
+ }
1052
+ /**
1053
+ * Counts non-deleted observations for this project.
1054
+ */
1055
+ count() {
1056
+ return this.stmtCount.get(this.projectHash).count;
1057
+ }
1058
+ /**
1059
+ * Gets an observation by ID, including soft-deleted observations.
1060
+ * Accepts full 32-char IDs or shorter prefix strings (e.g. the 8-char
1061
+ * display IDs shown in search results).
1062
+ * Used by the recall tool for restore operations (must find purged items).
1063
+ */
1064
+ getByIdIncludingDeleted(id) {
1065
+ debug("obs", "Getting observation including deleted", { id });
1066
+ const FULL_ID_LENGTH = 32;
1067
+ let resolvedId;
1068
+ if (id.length === FULL_ID_LENGTH) resolvedId = id;
1069
+ else {
1070
+ const rows = this.db.prepare("SELECT id FROM observations WHERE project_hash = ? AND id LIKE ? ESCAPE '\\' LIMIT 2").all(this.projectHash, id.replace(/[%_\\]/g, "\\$&") + "%");
1071
+ if (rows.length === 0) return null;
1072
+ if (rows.length > 1) {
1073
+ debug("obs", "Ambiguous ID prefix for getByIdIncludingDeleted", {
1074
+ prefix: id,
1075
+ count: rows.length
1076
+ });
1077
+ return null;
1078
+ }
1079
+ resolvedId = rows[0].id;
1080
+ }
1081
+ const row = this.stmtGetByIdIncludingDeleted.get(resolvedId, this.projectHash);
1082
+ return row ? rowToObservation(row) : null;
1083
+ }
1084
+ /**
1085
+ * Lists observations for this project, including soft-deleted ones.
1086
+ * Used by recall with include_purged: true to show all items.
1087
+ */
1088
+ listIncludingDeleted(options) {
1089
+ const limit = options?.limit ?? 50;
1090
+ const offset = options?.offset ?? 0;
1091
+ debug("obs", "Listing observations including deleted", {
1092
+ limit,
1093
+ offset
1094
+ });
1095
+ const rows = this.db.prepare("SELECT * FROM observations WHERE project_hash = ? ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?").all(this.projectHash, limit, offset);
1096
+ debug("obs", "Listed observations including deleted", { count: rows.length });
1097
+ return rows.map(rowToObservation);
1098
+ }
1099
+ /**
1100
+ * Searches observations by title substring (partial match via LIKE).
1101
+ * Optionally includes soft-deleted items.
1102
+ */
1103
+ getByTitle(title, options) {
1104
+ const limit = options?.limit ?? 20;
1105
+ const includePurged = options?.includePurged ?? false;
1106
+ debug("obs", "Searching by title", {
1107
+ title,
1108
+ limit,
1109
+ includePurged
1110
+ });
1111
+ let sql = "SELECT * FROM observations WHERE project_hash = ? AND title LIKE ?";
1112
+ if (!includePurged) sql += " AND deleted_at IS NULL";
1113
+ sql += " AND classification IS NOT NULL AND classification != 'noise'";
1114
+ sql += " ORDER BY created_at DESC, rowid DESC LIMIT ?";
1115
+ const rows = this.db.prepare(sql).all(this.projectHash, `%${title}%`, limit);
1116
+ debug("obs", "Title search completed", { count: rows.length });
1117
+ return rows.map(rowToObservation);
1118
+ }
1119
+ };
1120
+
1121
+ //#endregion
1122
+ //#region src/storage/sessions.ts
1123
+ /**
1124
+ * Maps a snake_case SessionRow to a camelCase Session interface.
1125
+ */
1126
+ function rowToSession(row) {
1127
+ return {
1128
+ id: row.id,
1129
+ projectHash: row.project_hash,
1130
+ startedAt: row.started_at,
1131
+ endedAt: row.ended_at,
1132
+ summary: row.summary
1133
+ };
1134
+ }
1135
+ /**
1136
+ * Repository for session lifecycle management.
1137
+ *
1138
+ * Every query is scoped to the projectHash provided at construction time.
1139
+ * All SQL statements are prepared once in the constructor.
1140
+ */
1141
+ var SessionRepository = class {
1142
+ db;
1143
+ projectHash;
1144
+ stmtCreate;
1145
+ stmtGetById;
1146
+ stmtGetActive;
1147
+ constructor(db, projectHash) {
1148
+ this.db = db;
1149
+ this.projectHash = projectHash;
1150
+ this.stmtCreate = db.prepare(`
1151
+ INSERT INTO sessions (id, project_hash)
1152
+ VALUES (?, ?)
1153
+ `);
1154
+ this.stmtGetById = db.prepare(`
1155
+ SELECT * FROM sessions
1156
+ WHERE id = ? AND project_hash = ?
1157
+ `);
1158
+ this.stmtGetActive = db.prepare(`
1159
+ SELECT * FROM sessions
1160
+ WHERE ended_at IS NULL AND project_hash = ?
1161
+ ORDER BY started_at DESC
1162
+ LIMIT 1
1163
+ `);
1164
+ debug("session", "SessionRepository initialized", { projectHash });
1165
+ }
1166
+ /**
1167
+ * Creates a new session with the given ID, scoped to this project.
1168
+ */
1169
+ create(id) {
1170
+ this.stmtCreate.run(id, this.projectHash);
1171
+ const row = this.stmtGetById.get(id, this.projectHash);
1172
+ if (!row) throw new Error("Failed to retrieve newly created session");
1173
+ debug("session", "Session created", { id });
1174
+ return rowToSession(row);
1175
+ }
1176
+ /**
1177
+ * Ends a session by setting ended_at and optionally a summary.
1178
+ * Returns the updated session or null if not found.
1179
+ */
1180
+ end(id, summary) {
1181
+ const setClauses = ["ended_at = datetime('now')"];
1182
+ const params = [];
1183
+ if (summary !== void 0) {
1184
+ setClauses.push("summary = ?");
1185
+ params.push(summary);
1186
+ }
1187
+ params.push(id, this.projectHash);
1188
+ const sql = `UPDATE sessions SET ${setClauses.join(", ")} WHERE id = ? AND project_hash = ?`;
1189
+ if (this.db.prepare(sql).run(...params).changes === 0) return null;
1190
+ debug("session", "Session ended", {
1191
+ id,
1192
+ hasSummary: !!summary
1193
+ });
1194
+ return this.getById(id);
1195
+ }
1196
+ /**
1197
+ * Gets a session by ID, scoped to this project.
1198
+ */
1199
+ getById(id) {
1200
+ const row = this.stmtGetById.get(id, this.projectHash);
1201
+ return row ? rowToSession(row) : null;
1202
+ }
1203
+ /**
1204
+ * Gets the most recent sessions for this project, ordered by started_at DESC.
1205
+ */
1206
+ getLatest(limit) {
1207
+ const effectiveLimit = limit ?? 10;
1208
+ return this.db.prepare(`SELECT * FROM sessions WHERE project_hash = ? ORDER BY started_at DESC, rowid DESC LIMIT ?`).all(this.projectHash, effectiveLimit).map(rowToSession);
1209
+ }
1210
+ /**
1211
+ * Gets the currently active (not ended) session for this project.
1212
+ * Returns the most recently started active session, or null if none.
1213
+ */
1214
+ getActive() {
1215
+ const row = this.stmtGetActive.get(this.projectHash);
1216
+ return row ? rowToSession(row) : null;
1217
+ }
1218
+ /**
1219
+ * Updates the summary column on an existing session row.
1220
+ * Sets updated_at (via ended_at preservation) to track when the summary was written.
1221
+ *
1222
+ * Used by the curation module after compressing session observations.
1223
+ */
1224
+ updateSessionSummary(sessionId, summary) {
1225
+ if (this.db.prepare(`UPDATE sessions SET summary = ? WHERE id = ? AND project_hash = ?`).run(summary, sessionId, this.projectHash).changes === 0) {
1226
+ debug("session", "Session not found for summary update", { sessionId });
1227
+ return;
1228
+ }
1229
+ debug("session", "Session summary updated", {
1230
+ sessionId,
1231
+ summaryLength: summary.length
1232
+ });
1233
+ }
1234
+ };
1235
+
1236
+ //#endregion
1237
+ //#region src/storage/search.ts
1238
+ /**
1239
+ * FTS5 search engine with BM25 ranking, snippet extraction, and strict project scoping.
1240
+ *
1241
+ * All queries are scoped to the projectHash provided at construction time.
1242
+ * Queries are sanitized to prevent FTS5 syntax errors and injection.
1243
+ */
1244
+ var SearchEngine = class {
1245
+ db;
1246
+ projectHash;
1247
+ constructor(db, projectHash) {
1248
+ this.db = db;
1249
+ this.projectHash = projectHash;
1250
+ }
1251
+ /**
1252
+ * Full-text search with BM25 ranking and snippet extraction.
1253
+ *
1254
+ * bm25() returns NEGATIVE values where more negative = more relevant.
1255
+ * ORDER BY rank (ascending) puts best matches first.
1256
+ *
1257
+ * @param query - User's search query (sanitized for FTS5 safety)
1258
+ * @param options - Optional limit and sessionId filter
1259
+ * @returns SearchResult[] ordered by relevance (best match first)
1260
+ */
1261
+ searchKeyword(query, options) {
1262
+ const sanitized = this.sanitizeQuery(query);
1263
+ if (!sanitized) return [];
1264
+ const limit = options?.limit ?? 20;
1265
+ let sql = `
1266
+ SELECT
1267
+ o.*,
1268
+ bm25(observations_fts, 2.0, 1.0) AS rank,
1269
+ snippet(observations_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
1270
+ FROM observations_fts
1271
+ JOIN observations o ON o.rowid = observations_fts.rowid
1272
+ WHERE observations_fts MATCH ?
1273
+ AND o.project_hash = ?
1274
+ AND o.deleted_at IS NULL
1275
+ AND (o.classification IS NULL OR o.classification != 'noise')
1276
+ `;
1277
+ const params = [sanitized, this.projectHash];
1278
+ if (options?.sessionId) {
1279
+ sql += " AND o.session_id = ?";
1280
+ params.push(options.sessionId);
1281
+ }
1282
+ sql += " ORDER BY rank LIMIT ?";
1283
+ params.push(limit);
1284
+ const results = debugTimed("search", "FTS5 keyword search", () => {
1285
+ return this.db.prepare(sql).all(...params).map((row) => ({
1286
+ observation: rowToObservation(row),
1287
+ score: Math.abs(row.rank),
1288
+ matchType: "fts",
1289
+ snippet: row.snippet
1290
+ }));
1291
+ });
1292
+ debug("search", "Keyword search completed", {
1293
+ query: sanitized,
1294
+ resultCount: results.length
1295
+ });
1296
+ return results;
1297
+ }
1298
+ /**
1299
+ * Prefix search for autocomplete-style matching.
1300
+ * Appends `*` to each word for prefix matching.
1301
+ */
1302
+ searchByPrefix(prefix, limit) {
1303
+ const words = prefix.trim().split(/\s+/).filter(Boolean);
1304
+ if (words.length === 0) return [];
1305
+ const sanitizedWords = words.map((w) => this.sanitizeWord(w)).filter(Boolean);
1306
+ if (sanitizedWords.length === 0) return [];
1307
+ const ftsQuery = sanitizedWords.map((w) => `${w}*`).join(" ");
1308
+ const effectiveLimit = limit ?? 20;
1309
+ const sql = `
1310
+ SELECT
1311
+ o.*,
1312
+ bm25(observations_fts, 2.0, 1.0) AS rank,
1313
+ snippet(observations_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
1314
+ FROM observations_fts
1315
+ JOIN observations o ON o.rowid = observations_fts.rowid
1316
+ WHERE observations_fts MATCH ?
1317
+ AND o.project_hash = ?
1318
+ AND o.deleted_at IS NULL
1319
+ AND (o.classification IS NULL OR o.classification != 'noise')
1320
+ ORDER BY rank
1321
+ LIMIT ?
1322
+ `;
1323
+ const results = debugTimed("search", "FTS5 prefix search", () => {
1324
+ return this.db.prepare(sql).all(ftsQuery, this.projectHash, effectiveLimit).map((row) => ({
1325
+ observation: rowToObservation(row),
1326
+ score: Math.abs(row.rank),
1327
+ matchType: "fts",
1328
+ snippet: row.snippet
1329
+ }));
1330
+ });
1331
+ debug("search", "Prefix search completed", {
1332
+ prefix,
1333
+ resultCount: results.length
1334
+ });
1335
+ return results;
1336
+ }
1337
+ /**
1338
+ * Rebuild the FTS5 index if it gets out of sync.
1339
+ */
1340
+ rebuildIndex() {
1341
+ debug("search", "Rebuilding FTS5 index");
1342
+ this.db.exec("INSERT INTO observations_fts(observations_fts) VALUES('rebuild')");
1343
+ }
1344
+ /**
1345
+ * Sanitizes a user query for safe FTS5 MATCH usage.
1346
+ * Removes FTS5 operators and special characters.
1347
+ * Returns null if the query is empty after sanitization.
1348
+ */
1349
+ sanitizeQuery(query) {
1350
+ const words = query.trim().split(/\s+/).filter(Boolean);
1351
+ if (words.length === 0) return null;
1352
+ const sanitizedWords = words.map((w) => this.sanitizeWord(w)).filter(Boolean);
1353
+ if (sanitizedWords.length === 0) return null;
1354
+ return sanitizedWords.join(" ");
1355
+ }
1356
+ /**
1357
+ * Sanitizes a single word for FTS5 safety.
1358
+ * Removes quotes, parentheses, asterisks, and FTS5 operator keywords.
1359
+ */
1360
+ sanitizeWord(word) {
1361
+ let cleaned = word.replace(/["*()^{}[\]]/g, "");
1362
+ if (/^(NEAR|OR|AND|NOT)$/i.test(cleaned)) return "";
1363
+ cleaned = cleaned.replace(/[^\w\-]/g, "");
1364
+ return cleaned;
1365
+ }
1366
+ };
1367
+
1368
+ //#endregion
1369
+ //#region src/search/hybrid.ts
1370
+ /**
1371
+ * Hybrid search combining FTS5 keyword results and vec0 vector results
1372
+ * using reciprocal rank fusion (RRF).
1373
+ *
1374
+ * When both keyword and vector results are available, RRF merges the two
1375
+ * ranked lists into a single score-sorted list. When only keyword results
1376
+ * are available (worker not ready, no embeddings), falls back transparently.
1377
+ */
1378
+ /**
1379
+ * Merges multiple ranked lists into a single fused ranking using RRF.
1380
+ *
1381
+ * For each document across all lists, computes:
1382
+ * fusedScore = sum(1 / (k + rank + 1))
1383
+ * where rank is the 0-based position in each list.
1384
+ *
1385
+ * @param rankedLists - Arrays of ranked items, each with an `id` field
1386
+ * @param k - Smoothing constant (default 60, standard RRF value)
1387
+ * @returns Fused results sorted by fusedScore descending
1388
+ */
1389
+ function reciprocalRankFusion(rankedLists, k = 60) {
1390
+ const scores = /* @__PURE__ */ new Map();
1391
+ for (const list of rankedLists) for (let rank = 0; rank < list.length; rank++) {
1392
+ const item = list[rank];
1393
+ const current = scores.get(item.id) ?? 0;
1394
+ scores.set(item.id, current + 1 / (k + rank + 1));
1395
+ }
1396
+ const results = [];
1397
+ for (const [id, fusedScore] of scores) results.push({
1398
+ id,
1399
+ fusedScore
1400
+ });
1401
+ results.sort((a, b) => b.fusedScore - a.fusedScore);
1402
+ return results;
1403
+ }
1404
+ /**
1405
+ * Combines FTS5 keyword search and vec0 vector search using RRF.
1406
+ *
1407
+ * Falls back to keyword-only when:
1408
+ * - Worker is null or not ready
1409
+ * - Query embedding fails
1410
+ * - No vector results returned
1411
+ *
1412
+ * @returns SearchResult[] with matchType indicating source(s)
1413
+ */
1414
+ async function hybridSearch(params) {
1415
+ const { searchEngine, embeddingStore, worker, query, db, projectHash, options } = params;
1416
+ const limit = options?.limit ?? 20;
1417
+ return debugTimed("search", "Hybrid search", async () => {
1418
+ const keywordResults = searchEngine.searchKeyword(query, {
1419
+ limit,
1420
+ sessionId: options?.sessionId
1421
+ });
1422
+ debug("search", "Keyword results", { count: keywordResults.length });
1423
+ let vectorResults = [];
1424
+ if (worker && worker.isReady()) {
1425
+ const queryEmbedding = await worker.embed(query);
1426
+ if (queryEmbedding) {
1427
+ vectorResults = embeddingStore.search(queryEmbedding, limit * 2);
1428
+ debug("search", "Vector results", { count: vectorResults.length });
1429
+ } else debug("search", "Query embedding failed, keyword-only");
1430
+ } else debug("search", "Worker not ready, keyword-only");
1431
+ if (vectorResults.length === 0) {
1432
+ debug("search", "Returning keyword-only results", { count: keywordResults.length });
1433
+ return keywordResults;
1434
+ }
1435
+ const fused = reciprocalRankFusion([keywordResults.map((r) => ({ id: r.observation.id })), vectorResults.map((r) => ({ id: r.observationId }))]);
1436
+ const keywordMap = /* @__PURE__ */ new Map();
1437
+ for (const r of keywordResults) keywordMap.set(r.observation.id, r);
1438
+ const vectorIdSet = new Set(vectorResults.map((r) => r.observationId));
1439
+ const obsRepo = new ObservationRepository(db, projectHash);
1440
+ const merged = [];
1441
+ for (const item of fused) {
1442
+ if (merged.length >= limit) break;
1443
+ const fromKeyword = keywordMap.get(item.id);
1444
+ const fromVector = vectorIdSet.has(item.id);
1445
+ if (fromKeyword && fromVector) merged.push({
1446
+ observation: fromKeyword.observation,
1447
+ score: item.fusedScore,
1448
+ matchType: "hybrid",
1449
+ snippet: fromKeyword.snippet
1450
+ });
1451
+ else if (fromKeyword) merged.push({
1452
+ observation: fromKeyword.observation,
1453
+ score: item.fusedScore,
1454
+ matchType: "fts",
1455
+ snippet: fromKeyword.snippet
1456
+ });
1457
+ else if (fromVector) {
1458
+ const obs = obsRepo.getById(item.id);
1459
+ if (obs) {
1460
+ const snippet = (obs.content ?? "").replace(/\n/g, " ").slice(0, 100);
1461
+ merged.push({
1462
+ observation: obs,
1463
+ score: item.fusedScore,
1464
+ matchType: "vector",
1465
+ snippet
1466
+ });
1467
+ }
1468
+ }
1469
+ }
1470
+ debug("search", "Hybrid search complete", {
1471
+ keyword: keywordResults.length,
1472
+ vector: vectorResults.length,
1473
+ fused: merged.length,
1474
+ hybrid: merged.filter((r) => r.matchType === "hybrid").length
1475
+ });
1476
+ return merged;
1477
+ });
1478
+ }
1479
+
1480
+ //#endregion
1481
+ //#region src/shared/similarity.ts
1482
+ /**
1483
+ * Text similarity utilities shared across modules.
1484
+ */
1485
+ /**
1486
+ * Computes Jaccard similarity between two texts based on tokenized words.
1487
+ * Words are lowercased and split on whitespace/punctuation.
1488
+ */
1489
+ function jaccardSimilarity(textA, textB) {
1490
+ const tokenize = (t) => new Set(t.toLowerCase().split(/[\s,.!?;:'"()\[\]{}<>\/\\|@#$%^&*+=~`]+/).filter((w) => w.length > 0));
1491
+ const setA = tokenize(textA);
1492
+ const setB = tokenize(textB);
1493
+ if (setA.size === 0 && setB.size === 0) return 1;
1494
+ if (setA.size === 0 || setB.size === 0) return 0;
1495
+ let intersection = 0;
1496
+ for (const w of setA) if (setB.has(w)) intersection++;
1497
+ const union = setA.size + setB.size - intersection;
1498
+ return union === 0 ? 0 : intersection / union;
1499
+ }
1500
+
1501
+ //#endregion
1502
+ //#region src/hooks/save-guard.ts
1503
+ var SaveGuard = class {
1504
+ obsRepo;
1505
+ worker;
1506
+ embeddingStore;
1507
+ duplicateThreshold;
1508
+ vectorDistanceThreshold;
1509
+ recentWindow;
1510
+ /**
1511
+ * Construct from db + projectHash (creates internal ObservationRepository),
1512
+ * or from an existing ObservationRepository.
1513
+ */
1514
+ constructor(dbOrRepo, projectHashOrOpts, opts) {
1515
+ if (dbOrRepo instanceof ObservationRepository) {
1516
+ this.obsRepo = dbOrRepo;
1517
+ const resolvedOpts = projectHashOrOpts ?? {};
1518
+ this.worker = resolvedOpts.worker ?? null;
1519
+ this.embeddingStore = resolvedOpts.embeddingStore ?? null;
1520
+ this.duplicateThreshold = resolvedOpts.duplicateThreshold ?? .85;
1521
+ this.vectorDistanceThreshold = resolvedOpts.vectorDistanceThreshold ?? .08;
1522
+ this.recentWindow = resolvedOpts.recentWindow ?? 20;
1523
+ } else {
1524
+ this.obsRepo = new ObservationRepository(dbOrRepo, projectHashOrOpts);
1525
+ this.worker = opts?.worker ?? null;
1526
+ this.embeddingStore = opts?.embeddingStore ?? null;
1527
+ this.duplicateThreshold = opts?.duplicateThreshold ?? .85;
1528
+ this.vectorDistanceThreshold = opts?.vectorDistanceThreshold ?? .08;
1529
+ this.recentWindow = opts?.recentWindow ?? 20;
1530
+ }
1531
+ }
1532
+ /**
1533
+ * Synchronous evaluation for the hook path (text-only, no embeddings).
1534
+ * Only checks for duplicates — relevance is handled by the background classifier.
1535
+ */
1536
+ evaluateSync(content, _source) {
1537
+ const dupResult = this.checkTextDuplicates(content);
1538
+ if (dupResult) return dupResult;
1539
+ return {
1540
+ save: true,
1541
+ reason: "ok"
1542
+ };
1543
+ }
1544
+ /**
1545
+ * Async evaluation for the MCP path (embeddings + text fallback).
1546
+ * Only checks for duplicates — relevance is handled by the background classifier.
1547
+ */
1548
+ async evaluate(content, _source) {
1549
+ if (this.worker?.isReady() && this.embeddingStore) {
1550
+ const embedding = await this.worker.embed(content);
1551
+ if (embedding) {
1552
+ const results = this.embeddingStore.search(embedding, 5);
1553
+ for (const result of results) if (result.distance < this.vectorDistanceThreshold) {
1554
+ debug("save-guard", "Vector duplicate detected", {
1555
+ distance: result.distance,
1556
+ duplicateOf: result.observationId
1557
+ });
1558
+ return {
1559
+ save: false,
1560
+ reason: "duplicate",
1561
+ duplicateOf: result.observationId
1562
+ };
1563
+ }
1564
+ }
1565
+ }
1566
+ const dupResult = this.checkTextDuplicates(content);
1567
+ if (dupResult) return dupResult;
1568
+ return {
1569
+ save: true,
1570
+ reason: "ok"
1571
+ };
1572
+ }
1573
+ checkTextDuplicates(content) {
1574
+ const recent = this.obsRepo.list({
1575
+ limit: this.recentWindow,
1576
+ includeUnclassified: true
1577
+ });
1578
+ for (const obs of recent) {
1579
+ const sim = jaccardSimilarity(content, obs.content);
1580
+ if (sim >= this.duplicateThreshold) {
1581
+ debug("save-guard", "Text duplicate detected", {
1582
+ similarity: sim,
1583
+ duplicateOf: obs.id
1584
+ });
1585
+ return {
1586
+ save: false,
1587
+ reason: "duplicate",
1588
+ duplicateOf: obs.id
1589
+ };
1590
+ }
1591
+ }
1592
+ return null;
1593
+ }
1594
+ };
1595
+
1596
+ //#endregion
1597
+ //#region src/graph/migrations/001-graph-tables.ts
1598
+ /**
1599
+ * Migration 001: Create graph_nodes and graph_edges tables.
1600
+ *
1601
+ * Graph tables are managed separately from the main observation/session tables
1602
+ * because the knowledge graph is a distinct subsystem that operates
1603
+ * on extracted entities rather than raw observations.
1604
+ *
1605
+ * Tables:
1606
+ * - graph_nodes: entities with type-checked taxonomy (6 types)
1607
+ * - graph_edges: directed relationships with type-checked taxonomy (8 types),
1608
+ * weight confidence, and unique constraint on (source_id, target_id, type)
1609
+ *
1610
+ * Indexes:
1611
+ * - Nodes: type, name
1612
+ * - Edges: source_id, target_id, type, unique(source_id, target_id, type)
1613
+ */
1614
+ const up = `
1615
+ CREATE TABLE IF NOT EXISTS graph_nodes (
1616
+ id TEXT PRIMARY KEY,
1617
+ type TEXT NOT NULL CHECK(type IN ('Project','File','Decision','Problem','Solution','Reference')),
1618
+ name TEXT NOT NULL,
1619
+ metadata TEXT DEFAULT '{}',
1620
+ observation_ids TEXT DEFAULT '[]',
1621
+ project_hash TEXT,
1622
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1623
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1624
+ );
1625
+
1626
+ CREATE TABLE IF NOT EXISTS graph_edges (
1627
+ id TEXT PRIMARY KEY,
1628
+ source_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
1629
+ target_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
1630
+ type TEXT NOT NULL CHECK(type IN ('related_to','solved_by','caused_by','modifies','informed_by','references','verified_by','preceded_by')),
1631
+ weight REAL NOT NULL DEFAULT 1.0 CHECK(weight >= 0.0 AND weight <= 1.0),
1632
+ metadata TEXT DEFAULT '{}',
1633
+ project_hash TEXT,
1634
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1635
+ );
1636
+
1637
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_type ON graph_nodes(type);
1638
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_name ON graph_nodes(name);
1639
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_source ON graph_edges(source_id);
1640
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_target ON graph_edges(target_id);
1641
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_type ON graph_edges(type);
1642
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique ON graph_edges(source_id, target_id, type);
1643
+ `;
1644
+
1645
+ //#endregion
1646
+ //#region src/graph/schema.ts
1647
+ function rowToNode(row) {
1648
+ return {
1649
+ id: row.id,
1650
+ type: row.type,
1651
+ name: row.name,
1652
+ metadata: JSON.parse(row.metadata),
1653
+ observation_ids: JSON.parse(row.observation_ids),
1654
+ created_at: row.created_at,
1655
+ updated_at: row.updated_at
1656
+ };
1657
+ }
1658
+ function rowToEdge(row) {
1659
+ return {
1660
+ id: row.id,
1661
+ source_id: row.source_id,
1662
+ target_id: row.target_id,
1663
+ type: row.type,
1664
+ weight: row.weight,
1665
+ metadata: JSON.parse(row.metadata),
1666
+ created_at: row.created_at
1667
+ };
1668
+ }
1669
+ /**
1670
+ * Initializes graph tables if they do not exist.
1671
+ * Uses CREATE TABLE IF NOT EXISTS so it is safe to call multiple times.
1672
+ */
1673
+ function initGraphSchema(db) {
1674
+ db.exec(up);
1675
+ }
1676
+ /**
1677
+ * Traverses the graph from a starting node using a recursive CTE.
1678
+ *
1679
+ * Supports directional traversal:
1680
+ * - 'outgoing': follows edges where source_id matches (default)
1681
+ * - 'incoming': follows edges where target_id matches
1682
+ * - 'both': follows edges in either direction
1683
+ *
1684
+ * Returns nodes and the edges that connect them, up to the specified depth.
1685
+ * The starting node itself is NOT included in results (depth > 0 filter).
1686
+ *
1687
+ * @param db - better-sqlite3 Database handle
1688
+ * @param nodeId - starting node ID
1689
+ * @param opts - traversal options (depth, edgeTypes, direction)
1690
+ * @returns Array of { node, edge, depth } for each reachable node
1691
+ */
1692
+ function traverseFrom(db, nodeId, opts = {}) {
1693
+ const maxDepth = opts.depth ?? 2;
1694
+ const direction = opts.direction ?? "outgoing";
1695
+ let edgeTypeFilter = "";
1696
+ if (opts.edgeTypes && opts.edgeTypes.length > 0) edgeTypeFilter = `AND e.type IN (${opts.edgeTypes.map(() => "?").join(", ")})`;
1697
+ let recursiveStep;
1698
+ if (direction === "outgoing") recursiveStep = `
1699
+ SELECT e.target_id, t.depth + 1, e.id
1700
+ FROM graph_edges e
1701
+ JOIN traverse t ON e.source_id = t.node_id
1702
+ WHERE t.depth < ?
1703
+ ${edgeTypeFilter}
1704
+ `;
1705
+ else if (direction === "incoming") recursiveStep = `
1706
+ SELECT e.source_id, t.depth + 1, e.id
1707
+ FROM graph_edges e
1708
+ JOIN traverse t ON e.target_id = t.node_id
1709
+ WHERE t.depth < ?
1710
+ ${edgeTypeFilter}
1711
+ `;
1712
+ else recursiveStep = `
1713
+ SELECT e.target_id, t.depth + 1, e.id
1714
+ FROM graph_edges e
1715
+ JOIN traverse t ON e.source_id = t.node_id
1716
+ WHERE t.depth < ?
1717
+ ${edgeTypeFilter}
1718
+ UNION ALL
1719
+ SELECT e.source_id, t.depth + 1, e.id
1720
+ FROM graph_edges e
1721
+ JOIN traverse t ON e.target_id = t.node_id
1722
+ WHERE t.depth < ?
1723
+ ${edgeTypeFilter}
1724
+ `;
1725
+ const sql = `
1726
+ WITH RECURSIVE traverse(node_id, depth, edge_id) AS (
1727
+ SELECT ?, 0, NULL
1728
+ UNION ALL
1729
+ ${recursiveStep}
1730
+ )
1731
+ SELECT DISTINCT
1732
+ n.id AS n_id, n.type AS n_type, n.name AS n_name,
1733
+ n.metadata AS n_metadata, n.observation_ids AS n_observation_ids,
1734
+ n.created_at AS n_created_at, n.updated_at AS n_updated_at,
1735
+ e.id AS e_id, e.source_id AS e_source_id, e.target_id AS e_target_id,
1736
+ e.type AS e_type, e.weight AS e_weight, e.metadata AS e_metadata,
1737
+ e.created_at AS e_created_at,
1738
+ t.depth
1739
+ FROM traverse t
1740
+ JOIN graph_nodes n ON n.id = t.node_id
1741
+ LEFT JOIN graph_edges e ON e.id = t.edge_id
1742
+ WHERE t.depth > 0
1743
+ `;
1744
+ const queryParams = [nodeId];
1745
+ if (direction === "both") {
1746
+ queryParams.push(maxDepth);
1747
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1748
+ queryParams.push(maxDepth);
1749
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1750
+ } else {
1751
+ queryParams.push(maxDepth);
1752
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1753
+ }
1754
+ return db.prepare(sql).all(...queryParams).map((row) => ({
1755
+ node: {
1756
+ id: row.n_id,
1757
+ type: row.n_type,
1758
+ name: row.n_name,
1759
+ metadata: JSON.parse(row.n_metadata),
1760
+ observation_ids: JSON.parse(row.n_observation_ids),
1761
+ created_at: row.n_created_at,
1762
+ updated_at: row.n_updated_at
1763
+ },
1764
+ edge: row.e_id ? {
1765
+ id: row.e_id,
1766
+ source_id: row.e_source_id,
1767
+ target_id: row.e_target_id,
1768
+ type: row.e_type,
1769
+ weight: row.e_weight,
1770
+ metadata: JSON.parse(row.e_metadata),
1771
+ created_at: row.e_created_at
1772
+ } : null,
1773
+ depth: row.depth
1774
+ }));
1775
+ }
1776
+ /**
1777
+ * Returns all nodes of a given entity type.
1778
+ */
1779
+ function getNodesByType(db, type) {
1780
+ return db.prepare("SELECT * FROM graph_nodes WHERE type = ?").all(type).map(rowToNode);
1781
+ }
1782
+ /**
1783
+ * Looks up a node by name and type (composite natural key).
1784
+ * Returns null if no matching node exists.
1785
+ */
1786
+ function getNodeByNameAndType(db, name, type) {
1787
+ const row = db.prepare("SELECT * FROM graph_nodes WHERE name = ? AND type = ?").get(name, type);
1788
+ return row ? rowToNode(row) : null;
1789
+ }
1790
+ /**
1791
+ * Returns edges connected to a node, filtered by direction.
1792
+ *
1793
+ * @param direction - 'outgoing' (source), 'incoming' (target), or 'both' (default: 'both')
1794
+ */
1795
+ function getEdgesForNode(db, nodeId, opts) {
1796
+ const direction = opts?.direction ?? "both";
1797
+ let sql;
1798
+ let params;
1799
+ if (direction === "outgoing") {
1800
+ sql = "SELECT * FROM graph_edges WHERE source_id = ?";
1801
+ params = [nodeId];
1802
+ } else if (direction === "incoming") {
1803
+ sql = "SELECT * FROM graph_edges WHERE target_id = ?";
1804
+ params = [nodeId];
1805
+ } else {
1806
+ sql = "SELECT * FROM graph_edges WHERE source_id = ? OR target_id = ?";
1807
+ params = [nodeId, nodeId];
1808
+ }
1809
+ return db.prepare(sql).all(...params).map(rowToEdge);
1810
+ }
1811
+ /**
1812
+ * Returns the total number of edges connected to a node (both directions).
1813
+ * Used for degree enforcement (MAX_NODE_DEGREE constraint).
1814
+ */
1815
+ function countEdgesForNode(db, nodeId) {
1816
+ return db.prepare("SELECT COUNT(*) as cnt FROM graph_edges WHERE source_id = ? OR target_id = ?").get(nodeId, nodeId).cnt;
1817
+ }
1818
+ /**
1819
+ * Inserts or updates a node by name+type composite key.
1820
+ *
1821
+ * If a node with the same name and type already exists, updates its metadata
1822
+ * and merges observation_ids. Otherwise, inserts a new node with a generated UUID.
1823
+ *
1824
+ * @returns The upserted GraphNode
1825
+ */
1826
+ function upsertNode(db, node) {
1827
+ const existing = getNodeByNameAndType(db, node.name, node.type);
1828
+ if (existing) {
1829
+ const mergedObsIds = [...new Set([...existing.observation_ids, ...node.observation_ids])];
1830
+ const mergedMetadata = {
1831
+ ...existing.metadata,
1832
+ ...node.metadata
1833
+ };
1834
+ db.prepare(`UPDATE graph_nodes
1835
+ SET metadata = ?, observation_ids = ?, updated_at = datetime('now')
1836
+ WHERE id = ?`).run(JSON.stringify(mergedMetadata), JSON.stringify(mergedObsIds), existing.id);
1837
+ return rowToNode(db.prepare("SELECT * FROM graph_nodes WHERE id = ?").get(existing.id));
1838
+ }
1839
+ const id = node.id ?? randomBytes(16).toString("hex");
1840
+ db.prepare(`INSERT INTO graph_nodes (id, type, name, metadata, observation_ids, project_hash)
1841
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, node.type, node.name, JSON.stringify(node.metadata), JSON.stringify(node.observation_ids), node.project_hash ?? null);
1842
+ return rowToNode(db.prepare("SELECT * FROM graph_nodes WHERE id = ?").get(id));
1843
+ }
1844
+ /**
1845
+ * Inserts an edge. On conflict (same source_id, target_id, type),
1846
+ * updates the weight to the maximum of existing and new values.
1847
+ *
1848
+ * @returns The inserted or updated GraphEdge
1849
+ */
1850
+ function insertEdge(db, edge) {
1851
+ const id = edge.id ?? randomBytes(16).toString("hex");
1852
+ db.prepare(`INSERT INTO graph_edges (id, source_id, target_id, type, weight, metadata, project_hash)
1853
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1854
+ ON CONFLICT (source_id, target_id, type) DO UPDATE SET
1855
+ weight = MAX(graph_edges.weight, excluded.weight),
1856
+ metadata = excluded.metadata`).run(id, edge.source_id, edge.target_id, edge.type, edge.weight, JSON.stringify(edge.metadata), edge.project_hash ?? null);
1857
+ return rowToEdge(db.prepare("SELECT * FROM graph_edges WHERE source_id = ? AND target_id = ? AND type = ?").get(edge.source_id, edge.target_id, edge.type));
1858
+ }
1859
+
1860
+ //#endregion
1861
+ //#region src/graph/staleness.ts
1862
+ /**
1863
+ * Negation patterns: newer observation negates older one.
1864
+ * Matches when newer text contains negation keywords absent in older text
1865
+ * and both discuss similar subjects.
1866
+ */
1867
+ const NEGATION_KEYWORDS = [
1868
+ "not",
1869
+ "don't",
1870
+ "no longer",
1871
+ "stopped",
1872
+ "never",
1873
+ "doesn't",
1874
+ "won't",
1875
+ "isn't",
1876
+ "aren't",
1877
+ "discontinued"
1878
+ ];
1879
+ /**
1880
+ * Replacement patterns: newer observation explicitly replaces older approach.
1881
+ */
1882
+ const REPLACEMENT_PATTERNS = [
1883
+ /switched\s+(?:from\s+\S+\s+)?to\b/i,
1884
+ /migrated\s+(?:from\s+\S+\s+)?to\b/i,
1885
+ /replaced\s+(?:\S+\s+)?with\b/i,
1886
+ /changed\s+from\b/i,
1887
+ /moved\s+(?:from\s+\S+\s+)?to\b/i,
1888
+ /upgraded\s+(?:from\s+\S+\s+)?to\b/i,
1889
+ /swapped\s+(?:\S+\s+)?(?:for|with)\b/i
1890
+ ];
1891
+ /**
1892
+ * Status change patterns: newer observation marks something as inactive.
1893
+ */
1894
+ const STATUS_CHANGE_KEYWORDS = [
1895
+ "removed",
1896
+ "deleted",
1897
+ "deprecated",
1898
+ "archived",
1899
+ "dropped",
1900
+ "disabled",
1901
+ "decommissioned",
1902
+ "sunset",
1903
+ "abandoned"
1904
+ ];
1905
+ /**
1906
+ * Creates the staleness_flags table if it doesn't exist.
1907
+ * Uses a separate table rather than modifying the observations table,
1908
+ * keeping staleness metadata decoupled from core observation storage.
1909
+ */
1910
+ function initStalenessSchema(db) {
1911
+ db.exec(`
1912
+ CREATE TABLE IF NOT EXISTS staleness_flags (
1913
+ observation_id TEXT PRIMARY KEY,
1914
+ flagged_at TEXT NOT NULL DEFAULT (datetime('now')),
1915
+ reason TEXT NOT NULL,
1916
+ resolved INTEGER NOT NULL DEFAULT 0
1917
+ );
1918
+ CREATE INDEX IF NOT EXISTS idx_staleness_resolved ON staleness_flags(resolved);
1919
+ `);
1920
+ }
1921
+ /**
1922
+ * Detects potential staleness (contradictions) between observations
1923
+ * linked to a specific entity.
1924
+ *
1925
+ * Compares consecutive observation pairs chronologically and checks for:
1926
+ * 1. Negation patterns (newer negates older)
1927
+ * 2. Replacement patterns (newer replaces older approach)
1928
+ * 3. Status change patterns (newer marks something as inactive)
1929
+ *
1930
+ * This is DETECTION ONLY -- no data is modified.
1931
+ *
1932
+ * @param db - better-sqlite3 Database handle
1933
+ * @param entityId - Graph node ID to check observations for
1934
+ * @returns Array of StalenessReport for each detected contradiction
1935
+ */
1936
+ function detectStaleness(db, entityId) {
1937
+ const node = db.prepare("SELECT id, name, type, observation_ids FROM graph_nodes WHERE id = ?").get(entityId);
1938
+ if (!node) return [];
1939
+ const obsIds = JSON.parse(node.observation_ids);
1940
+ if (obsIds.length < 2) return [];
1941
+ const placeholders = obsIds.map(() => "?").join(", ");
1942
+ const observations = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) AND deleted_at IS NULL ORDER BY created_at ASC`).all(...obsIds).map(rowToObservation);
1943
+ if (observations.length < 2) return [];
1944
+ const reports = [];
1945
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1946
+ for (let i = 0; i < observations.length - 1; i++) {
1947
+ const older = observations[i];
1948
+ const newer = observations[i + 1];
1949
+ const reason = detectContradiction(older.content, newer.content);
1950
+ if (reason) reports.push({
1951
+ entityId: node.id,
1952
+ entityName: node.name,
1953
+ entityType: node.type,
1954
+ newerObservation: {
1955
+ id: newer.id,
1956
+ text: newer.content,
1957
+ created_at: newer.createdAt
1958
+ },
1959
+ olderObservation: {
1960
+ id: older.id,
1961
+ text: older.content,
1962
+ created_at: older.createdAt
1963
+ },
1964
+ reason,
1965
+ detectedAt: now
1966
+ });
1967
+ }
1968
+ return reports;
1969
+ }
1970
+ /**
1971
+ * Detects contradiction between two observation texts.
1972
+ * Returns a human-readable reason string, or null if no contradiction found.
1973
+ */
1974
+ function detectContradiction(olderText, newerText) {
1975
+ const olderLower = olderText.toLowerCase();
1976
+ const newerLower = newerText.toLowerCase();
1977
+ const negationResult = detectNegation(olderLower, newerLower);
1978
+ if (negationResult) return negationResult;
1979
+ const replacementResult = detectReplacement(newerLower);
1980
+ if (replacementResult) return replacementResult;
1981
+ const statusResult = detectStatusChange(olderLower, newerLower);
1982
+ if (statusResult) return statusResult;
1983
+ return null;
1984
+ }
1985
+ /**
1986
+ * Detects negation: newer text contains negation keywords that are absent
1987
+ * in the older text, suggesting the newer observation contradicts the older.
1988
+ */
1989
+ function detectNegation(olderLower, newerLower) {
1990
+ for (const keyword of NEGATION_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation contains negation ("${keyword}") not present in older observation`;
1991
+ return null;
1992
+ }
1993
+ /**
1994
+ * Detects replacement: newer text explicitly mentions switching/replacing.
1995
+ */
1996
+ function detectReplacement(newerLower) {
1997
+ for (const pattern of REPLACEMENT_PATTERNS) {
1998
+ const match = newerLower.match(pattern);
1999
+ if (match) return `Newer observation indicates replacement ("${match[0].trim()}")`;
2000
+ }
2001
+ return null;
2002
+ }
2003
+ /**
2004
+ * Detects status change: newer text marks something as removed/deprecated
2005
+ * when the older text described it as active/present.
2006
+ */
2007
+ function detectStatusChange(olderLower, newerLower) {
2008
+ for (const keyword of STATUS_CHANGE_KEYWORDS) if (newerLower.includes(keyword) && !olderLower.includes(keyword)) return `Newer observation indicates status change ("${keyword}")`;
2009
+ return null;
2010
+ }
2011
+ /**
2012
+ * Flags an observation as stale with an advisory reason.
2013
+ *
2014
+ * This flag is advisory -- search can use it to deprioritize but never hide
2015
+ * the observation. The observation remains fully queryable.
2016
+ *
2017
+ * Uses INSERT OR REPLACE to allow re-flagging with an updated reason.
2018
+ *
2019
+ * @param db - better-sqlite3 Database handle
2020
+ * @param observationId - ID of the observation to flag
2021
+ * @param reason - Human-readable explanation of why it's stale
2022
+ */
2023
+ function flagStaleObservation(db, observationId, reason) {
2024
+ initStalenessSchema(db);
2025
+ db.prepare(`INSERT OR REPLACE INTO staleness_flags (observation_id, reason, resolved)
2026
+ VALUES (?, ?, 0)`).run(observationId, reason);
2027
+ }
2028
+
2029
+ //#endregion
2030
+ //#region src/config/hygiene-config.ts
2031
+ /**
2032
+ * Database Hygiene Configuration
2033
+ *
2034
+ * Controls signal weights and tier thresholds used by the hygiene
2035
+ * analyzer to score observations for deletion candidacy.
2036
+ *
2037
+ * Configuration is loaded from .laminark/hygiene.json with
2038
+ * a 5-second cache to avoid repeated disk reads.
2039
+ */
2040
+ const DEFAULT_AUTO_CLEANUP = {
2041
+ enabled: true,
2042
+ tier: "high",
2043
+ maxOrphanNodes: 500
2044
+ };
2045
+ const DEFAULTS = {
2046
+ signalWeights: {
2047
+ orphaned: .3,
2048
+ islandNode: .25,
2049
+ noiseClassified: .25,
2050
+ shortContent: .1,
2051
+ autoCaptured: .1,
2052
+ stale: .1
2053
+ },
2054
+ tierThresholds: {
2055
+ high: .7,
2056
+ medium: .5
2057
+ },
2058
+ shortContentThreshold: 50,
2059
+ autoCleanup: { ...DEFAULT_AUTO_CLEANUP }
2060
+ };
2061
+ const CACHE_TTL_MS = 5e3;
2062
+ let cachedConfig = null;
2063
+ let cachedAt = 0;
2064
+ function clamp(value, min, max) {
2065
+ return Math.max(min, Math.min(max, value));
2066
+ }
2067
+ function validate(raw) {
2068
+ const config = { ...DEFAULTS };
2069
+ if (raw.signalWeights && typeof raw.signalWeights === "object" && !Array.isArray(raw.signalWeights)) {
2070
+ const sw = raw.signalWeights;
2071
+ const weights = { ...DEFAULTS.signalWeights };
2072
+ for (const key of Object.keys(DEFAULTS.signalWeights)) if (typeof sw[key] === "number") weights[key] = clamp(sw[key], 0, 1);
2073
+ config.signalWeights = weights;
2074
+ }
2075
+ if (raw.tierThresholds && typeof raw.tierThresholds === "object" && !Array.isArray(raw.tierThresholds)) {
2076
+ const tt = raw.tierThresholds;
2077
+ let high = typeof tt.high === "number" ? clamp(tt.high, 0, 1) : DEFAULTS.tierThresholds.high;
2078
+ let medium = typeof tt.medium === "number" ? clamp(tt.medium, 0, 1) : DEFAULTS.tierThresholds.medium;
2079
+ if (medium >= high) medium = Math.max(0, high - .1);
2080
+ config.tierThresholds = {
2081
+ high,
2082
+ medium
2083
+ };
2084
+ }
2085
+ if (typeof raw.shortContentThreshold === "number") config.shortContentThreshold = Math.max(0, Math.round(raw.shortContentThreshold));
2086
+ if (raw.autoCleanup && typeof raw.autoCleanup === "object" && !Array.isArray(raw.autoCleanup)) {
2087
+ const ac = raw.autoCleanup;
2088
+ const cleanup = { ...DEFAULT_AUTO_CLEANUP };
2089
+ if (typeof ac.enabled === "boolean") cleanup.enabled = ac.enabled;
2090
+ if (ac.tier === "high" || ac.tier === "medium" || ac.tier === "all") cleanup.tier = ac.tier;
2091
+ if (typeof ac.maxOrphanNodes === "number") cleanup.maxOrphanNodes = Math.max(0, Math.round(ac.maxOrphanNodes));
2092
+ config.autoCleanup = cleanup;
2093
+ }
2094
+ return config;
2095
+ }
2096
+ /**
2097
+ * Loads hygiene configuration from disk with a 5-second cache.
2098
+ */
2099
+ function loadHygieneConfig() {
2100
+ const now = Date.now();
2101
+ if (cachedConfig && now - cachedAt < CACHE_TTL_MS) return cachedConfig;
2102
+ const configPath = join(getConfigDir(), "hygiene.json");
2103
+ try {
2104
+ const content = readFileSync(configPath, "utf-8");
2105
+ cachedConfig = validate(JSON.parse(content));
2106
+ debug("config", "Loaded hygiene config", cachedConfig);
2107
+ } catch {
2108
+ cachedConfig = {
2109
+ ...DEFAULTS,
2110
+ signalWeights: { ...DEFAULTS.signalWeights },
2111
+ tierThresholds: { ...DEFAULTS.tierThresholds }
2112
+ };
2113
+ }
2114
+ cachedAt = now;
2115
+ return cachedConfig;
2116
+ }
2117
+ /**
2118
+ * Saves hygiene configuration to disk and invalidates cache.
2119
+ */
2120
+ function saveHygieneConfig(config) {
2121
+ writeFileSync(join(getConfigDir(), "hygiene.json"), JSON.stringify(config, null, 2), "utf-8");
2122
+ cachedConfig = config;
2123
+ cachedAt = Date.now();
2124
+ }
2125
+ /**
2126
+ * Resets hygiene config to defaults by invalidating cache.
2127
+ */
2128
+ function resetHygieneConfig() {
2129
+ cachedConfig = null;
2130
+ cachedAt = 0;
2131
+ return {
2132
+ ...DEFAULTS,
2133
+ signalWeights: { ...DEFAULTS.signalWeights },
2134
+ tierThresholds: { ...DEFAULTS.tierThresholds },
2135
+ autoCleanup: { ...DEFAULT_AUTO_CLEANUP }
2136
+ };
2137
+ }
2138
+
2139
+ //#endregion
2140
+ //#region src/graph/hygiene-analyzer.ts
2141
+ function buildSignalLookups(db) {
2142
+ const linkedObsIds = /* @__PURE__ */ new Set();
2143
+ const islandObsIds = /* @__PURE__ */ new Set();
2144
+ const allNodes = db.prepare("SELECT id, type, name, observation_ids FROM graph_nodes").all();
2145
+ const edgeCounts = /* @__PURE__ */ new Map();
2146
+ const edgeRows = db.prepare(`SELECT source_id AS nid, COUNT(*) AS cnt FROM graph_edges GROUP BY source_id
2147
+ UNION ALL
2148
+ SELECT target_id AS nid, COUNT(*) AS cnt FROM graph_edges GROUP BY target_id`).all();
2149
+ for (const row of edgeRows) edgeCounts.set(row.nid, (edgeCounts.get(row.nid) ?? 0) + row.cnt);
2150
+ for (const node of allNodes) {
2151
+ let obsIds;
2152
+ try {
2153
+ obsIds = JSON.parse(node.observation_ids);
2154
+ } catch {
2155
+ continue;
2156
+ }
2157
+ const degree = edgeCounts.get(node.id) ?? 0;
2158
+ for (const oid of obsIds) {
2159
+ linkedObsIds.add(oid);
2160
+ if (degree === 0) islandObsIds.add(oid);
2161
+ }
2162
+ }
2163
+ const staleIds = /* @__PURE__ */ new Set();
2164
+ try {
2165
+ initStalenessSchema(db);
2166
+ const staleRows = db.prepare("SELECT observation_id FROM staleness_flags WHERE resolved = 0").all();
2167
+ for (const row of staleRows) staleIds.add(row.observation_id);
2168
+ } catch {}
2169
+ return {
2170
+ linkedObsIds,
2171
+ islandObsIds,
2172
+ staleIds,
2173
+ allNodes,
2174
+ edgeCounts
2175
+ };
2176
+ }
2177
+ function scoreObservation(obs, lookups, config) {
2178
+ const weights = config.signalWeights;
2179
+ const thresholds = config.tierThresholds;
2180
+ const signals = {
2181
+ orphaned: !lookups.linkedObsIds.has(obs.id),
2182
+ islandNode: lookups.islandObsIds.has(obs.id),
2183
+ noiseClassified: obs.classification === "noise",
2184
+ shortContent: obs.content.length < config.shortContentThreshold,
2185
+ autoCaptured: obs.source.startsWith("hook:"),
2186
+ stale: lookups.staleIds.has(obs.id)
2187
+ };
2188
+ const confidence = (signals.orphaned ? weights.orphaned : 0) + (signals.islandNode ? weights.islandNode : 0) + (signals.noiseClassified ? weights.noiseClassified : 0) + (signals.shortContent ? weights.shortContent : 0) + (signals.autoCaptured ? weights.autoCaptured : 0) + (signals.stale ? weights.stale : 0);
2189
+ const tier = confidence >= thresholds.high ? "high" : confidence >= thresholds.medium ? "medium" : "low";
2190
+ return {
2191
+ signals,
2192
+ confidence: Math.round(confidence * 100) / 100,
2193
+ tier
2194
+ };
2195
+ }
2196
+ /**
2197
+ * Analyzes all active observations and scores each on deletion signals.
2198
+ * Pure read-only — no data is modified.
2199
+ */
2200
+ function analyzeObservations(db, projectHash, opts) {
2201
+ const limit = opts?.limit ?? 50;
2202
+ const minTier = opts?.minTier ?? "medium";
2203
+ const config = opts?.config ?? loadHygieneConfig();
2204
+ debug("hygiene", "Starting analysis", {
2205
+ projectHash,
2206
+ sessionId: opts?.sessionId
2207
+ });
2208
+ let obsSql = `
2209
+ SELECT id, content, title, source, kind, session_id, classification, created_at
2210
+ FROM observations
2211
+ WHERE project_hash = ? AND deleted_at IS NULL
2212
+ `;
2213
+ const obsParams = [projectHash];
2214
+ if (opts?.sessionId) {
2215
+ obsSql += " AND session_id = ?";
2216
+ obsParams.push(opts.sessionId);
2217
+ }
2218
+ obsSql += " ORDER BY created_at DESC";
2219
+ const observations = db.prepare(obsSql).all(...obsParams);
2220
+ const lookups = buildSignalLookups(db);
2221
+ const allCandidates = [];
2222
+ for (const obs of observations) {
2223
+ const { signals, confidence, tier } = scoreObservation(obs, lookups, config);
2224
+ if (minTier === "high" && tier !== "high") continue;
2225
+ if (minTier === "medium" && tier === "low") continue;
2226
+ const preview = obs.content.length > 80 ? obs.content.substring(0, 80) + "..." : obs.content;
2227
+ allCandidates.push({
2228
+ id: obs.id,
2229
+ shortId: obs.id.substring(0, 8),
2230
+ sessionId: obs.session_id,
2231
+ kind: obs.kind,
2232
+ source: obs.source,
2233
+ contentPreview: preview,
2234
+ createdAt: obs.created_at,
2235
+ signals,
2236
+ confidence,
2237
+ tier
2238
+ });
2239
+ }
2240
+ allCandidates.sort((a, b) => b.confidence - a.confidence);
2241
+ const activeObsIds = new Set(observations.map((o) => o.id));
2242
+ const orphanNodes = [];
2243
+ for (const node of lookups.allNodes) {
2244
+ if ((lookups.edgeCounts.get(node.id) ?? 0) > 0) continue;
2245
+ let obsIds;
2246
+ try {
2247
+ obsIds = JSON.parse(node.observation_ids);
2248
+ } catch {
2249
+ continue;
2250
+ }
2251
+ const allDead = obsIds.length === 0 || obsIds.every((oid) => !activeObsIds.has(oid));
2252
+ orphanNodes.push({
2253
+ id: node.id,
2254
+ type: node.type,
2255
+ name: node.name,
2256
+ reason: allDead ? "zero edges, dead observation refs" : "zero edges (island node)"
2257
+ });
2258
+ }
2259
+ const limited = allCandidates.slice(0, limit);
2260
+ const highCount = allCandidates.filter((c) => c.tier === "high").length;
2261
+ const mediumCount = allCandidates.filter((c) => c.tier === "medium").length;
2262
+ const lowCount = allCandidates.filter((c) => c.tier === "low").length;
2263
+ debug("hygiene", "Analysis complete", {
2264
+ total: observations.length,
2265
+ high: highCount,
2266
+ medium: mediumCount,
2267
+ orphanNodes: orphanNodes.length
2268
+ });
2269
+ return {
2270
+ analyzedAt: (/* @__PURE__ */ new Date()).toISOString(),
2271
+ totalObservations: observations.length,
2272
+ candidates: limited,
2273
+ orphanNodes: orphanNodes.slice(0, limit),
2274
+ summary: {
2275
+ high: highCount,
2276
+ medium: mediumCount,
2277
+ low: lowCount,
2278
+ orphanNodeCount: orphanNodes.length
2279
+ }
2280
+ };
2281
+ }
2282
+ /**
2283
+ * Produces a score distribution report across all observations.
2284
+ * Shows signal counts, confidence histogram, and island node summary
2285
+ * so users can tune thresholds to catch the right candidates.
2286
+ */
2287
+ function findAnalysis(db, projectHash, config) {
2288
+ const cfg = config ?? loadHygieneConfig();
2289
+ const observations = db.prepare(`
2290
+ SELECT id, content, title, source, kind, session_id, classification, created_at
2291
+ FROM observations
2292
+ WHERE project_hash = ? AND deleted_at IS NULL
2293
+ ORDER BY created_at DESC
2294
+ `).all(projectHash);
2295
+ const lookups = buildSignalLookups(db);
2296
+ const bySignal = {
2297
+ orphaned: 0,
2298
+ islandNode: 0,
2299
+ noiseClassified: 0,
2300
+ shortContent: 0,
2301
+ autoCaptured: 0,
2302
+ stale: 0
2303
+ };
2304
+ const buckets = new Array(10).fill(0);
2305
+ const islandConfidences = [];
2306
+ for (const obs of observations) {
2307
+ const { signals, confidence } = scoreObservation(obs, lookups, cfg);
2308
+ if (signals.orphaned) bySignal.orphaned++;
2309
+ if (signals.islandNode) bySignal.islandNode++;
2310
+ if (signals.noiseClassified) bySignal.noiseClassified++;
2311
+ if (signals.shortContent) bySignal.shortContent++;
2312
+ if (signals.autoCaptured) bySignal.autoCaptured++;
2313
+ if (signals.stale) bySignal.stale++;
2314
+ const bucketIdx = Math.min(Math.floor(confidence * 10), 9);
2315
+ buckets[bucketIdx]++;
2316
+ if (signals.islandNode) islandConfidences.push(confidence);
2317
+ }
2318
+ const distribution = buckets.map((count, i) => ({
2319
+ range: `${(i / 10).toFixed(1)}-${((i + 1) / 10).toFixed(1)}`,
2320
+ count
2321
+ }));
2322
+ islandConfidences.sort((a, b) => a - b);
2323
+ const islandTotal = islandConfidences.length;
2324
+ const minConf = islandTotal > 0 ? islandConfidences[0] : 0;
2325
+ const maxConf = islandTotal > 0 ? islandConfidences[islandTotal - 1] : 0;
2326
+ const medianConf = islandTotal > 0 ? islandConfidences[Math.floor(islandTotal / 2)] : 0;
2327
+ const capturedHigh = islandConfidences.filter((c) => c >= cfg.tierThresholds.high).length;
2328
+ const capturedMedium = islandConfidences.filter((c) => c >= cfg.tierThresholds.medium).length;
2329
+ return {
2330
+ total: observations.length,
2331
+ bySignal,
2332
+ distribution,
2333
+ islandNodes: {
2334
+ total: islandTotal,
2335
+ minConfidence: Math.round(minConf * 100) / 100,
2336
+ maxConfidence: Math.round(maxConf * 100) / 100,
2337
+ medianConfidence: Math.round(medianConf * 100) / 100,
2338
+ capturedAtCurrentThresholds: {
2339
+ high: capturedHigh,
2340
+ medium: capturedMedium,
2341
+ all: islandTotal
2342
+ }
2343
+ }
2344
+ };
2345
+ }
2346
+ /**
2347
+ * Runs automatic hygiene cleanup at session end.
2348
+ *
2349
+ * Analyzes observations and purges candidates matching the configured tier.
2350
+ * Orphan graph node removal is capped by autoCleanup.maxOrphanNodes.
2351
+ * Safe to call on every session end — skips quickly if disabled.
2352
+ */
2353
+ function runAutoCleanup(db, projectHash, config) {
2354
+ const cfg = config ?? loadHygieneConfig();
2355
+ const auto = cfg.autoCleanup;
2356
+ if (!auto.enabled) return {
2357
+ skipped: true,
2358
+ reason: "disabled",
2359
+ observationsPurged: 0,
2360
+ orphanNodesRemoved: 0
2361
+ };
2362
+ debug("hygiene", "Auto-cleanup starting", {
2363
+ tier: auto.tier,
2364
+ maxOrphanNodes: auto.maxOrphanNodes
2365
+ });
2366
+ const report = analyzeObservations(db, projectHash, {
2367
+ limit: 200,
2368
+ minTier: auto.tier === "all" ? "low" : auto.tier,
2369
+ config: cfg
2370
+ });
2371
+ if (report.orphanNodes.length > auto.maxOrphanNodes) report.orphanNodes = report.orphanNodes.slice(0, auto.maxOrphanNodes);
2372
+ if (report.candidates.length + report.orphanNodes.length === 0) {
2373
+ debug("hygiene", "Auto-cleanup: nothing to clean");
2374
+ return {
2375
+ skipped: false,
2376
+ observationsPurged: 0,
2377
+ orphanNodesRemoved: 0
2378
+ };
2379
+ }
2380
+ const result = executePurge(db, projectHash, report, auto.tier);
2381
+ debug("hygiene", "Auto-cleanup complete", {
2382
+ observationsPurged: result.observationsPurged,
2383
+ orphanNodesRemoved: result.orphanNodesRemoved
2384
+ });
2385
+ return {
2386
+ skipped: false,
2387
+ ...result
2388
+ };
2389
+ }
2390
+ function executePurge(db, projectHash, report, tier) {
2391
+ const candidateIds = report.candidates.filter((c) => {
2392
+ if (tier === "high") return c.tier === "high";
2393
+ if (tier === "medium") return c.tier === "high" || c.tier === "medium";
2394
+ return true;
2395
+ }).map((c) => c.id);
2396
+ debug("hygiene", "Executing purge", {
2397
+ tier,
2398
+ candidates: candidateIds.length
2399
+ });
2400
+ let observationsPurged = 0;
2401
+ const softDeleteStmt = db.prepare(`
2402
+ UPDATE observations
2403
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
2404
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
2405
+ `);
2406
+ return db.transaction(() => {
2407
+ for (const id of candidateIds) {
2408
+ const result = softDeleteStmt.run(id, projectHash);
2409
+ observationsPurged += result.changes;
2410
+ }
2411
+ let orphanNodesRemoved = 0;
2412
+ const deleteNodeStmt = db.prepare("DELETE FROM graph_nodes WHERE id = ?");
2413
+ for (const node of report.orphanNodes) {
2414
+ const result = deleteNodeStmt.run(node.id);
2415
+ orphanNodesRemoved += result.changes;
2416
+ }
2417
+ return {
2418
+ observationsPurged,
2419
+ orphanNodesRemoved
2420
+ };
2421
+ })();
2422
+ }
2423
+
2424
+ //#endregion
2425
+ //#region src/hooks/tool-name-parser.ts
2426
+ /**
2427
+ * Infers the tool type from a tool name seen in PostToolUse.
2428
+ *
2429
+ * - MCP tools have the `mcp__` prefix
2430
+ * - Built-in tools are PascalCase single words (Write, Edit, Bash, Read, etc.)
2431
+ * - Anything else is unknown
2432
+ */
2433
+ function inferToolType(toolName) {
2434
+ if (toolName.startsWith("mcp__")) return "mcp_tool";
2435
+ if (/^[A-Z][a-zA-Z]+$/.test(toolName)) return "builtin";
2436
+ return "unknown";
2437
+ }
2438
+ /**
2439
+ * Infers the scope of a tool from its name.
2440
+ *
2441
+ * - Plugin MCP tools (mcp__plugin_*) are plugin-scoped
2442
+ * - Other MCP tools default to project-scoped (conservative; may be global but unknown from name alone)
2443
+ * - Non-MCP tools (builtins) are always global
2444
+ */
2445
+ function inferScope(toolName) {
2446
+ if (toolName.startsWith("mcp__plugin_")) return "plugin";
2447
+ if (toolName.startsWith("mcp__")) return "project";
2448
+ return "global";
2449
+ }
2450
+ /**
2451
+ * Extracts the MCP server name from a tool name.
2452
+ *
2453
+ * Plugin MCP tools: `mcp__plugin_<pluginName>_<serverName>__<tool>`
2454
+ * Example: `mcp__plugin_laminark_laminark__recall` -> server is `laminark`
2455
+ *
2456
+ * Project MCP tools: `mcp__<serverName>__<tool>`
2457
+ * Example: `mcp__playwright__browser_screenshot` -> server is `playwright`
2458
+ *
2459
+ * Returns null for non-MCP tools.
2460
+ */
2461
+ function extractServerName(toolName) {
2462
+ const pluginMatch = toolName.match(/^mcp__plugin_([^_]+(?:_[^_]+)*)_([^_]+(?:_[^_]+)*)__/);
2463
+ if (pluginMatch) return pluginMatch[2];
2464
+ const projectMatch = toolName.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
2465
+ if (projectMatch) return projectMatch[1];
2466
+ return null;
2467
+ }
2468
+
2469
+ //#endregion
2470
+ //#region src/branches/branch-repository.ts
2471
+ var BranchRepository = class {
2472
+ db;
2473
+ projectHash;
2474
+ stmtCreate;
2475
+ stmtComplete;
2476
+ stmtAbandon;
2477
+ stmtGetActive;
2478
+ stmtGetById;
2479
+ stmtList;
2480
+ stmtListByStatus;
2481
+ stmtListByType;
2482
+ stmtUpdateArcStage;
2483
+ stmtUpdateToolPattern;
2484
+ stmtUpdateClassification;
2485
+ stmtUpdateSummary;
2486
+ stmtIncrementObsCount;
2487
+ stmtLinkDebugPath;
2488
+ stmtAddObservation;
2489
+ stmtGetObservations;
2490
+ stmtMaxSequence;
2491
+ stmtFindStale;
2492
+ stmtFindUnclassified;
2493
+ stmtFindRecentCompleted;
2494
+ stmtFindRecentActive;
2495
+ stmtListRecent;
2496
+ constructor(db, projectHash) {
2497
+ this.db = db;
2498
+ this.projectHash = projectHash;
2499
+ this.stmtCreate = db.prepare(`
2500
+ INSERT INTO thought_branches
2501
+ (id, project_hash, session_id, status, trigger_source, trigger_observation_id, started_at)
2502
+ VALUES (?, ?, ?, 'active', ?, ?, datetime('now'))
2503
+ `);
2504
+ this.stmtComplete = db.prepare(`
2505
+ UPDATE thought_branches
2506
+ SET status = 'completed', arc_stage = 'completed', ended_at = datetime('now')
2507
+ WHERE id = ? AND project_hash = ?
2508
+ `);
2509
+ this.stmtAbandon = db.prepare(`
2510
+ UPDATE thought_branches
2511
+ SET status = 'abandoned', ended_at = datetime('now')
2512
+ WHERE id = ? AND project_hash = ?
2513
+ `);
2514
+ this.stmtGetActive = db.prepare(`
2515
+ SELECT * FROM thought_branches
2516
+ WHERE project_hash = ? AND status = 'active'
2517
+ ORDER BY started_at DESC
2518
+ LIMIT 1
2519
+ `);
2520
+ this.stmtGetById = db.prepare(`
2521
+ SELECT * FROM thought_branches
2522
+ WHERE id = ? AND project_hash = ?
2523
+ `);
2524
+ this.stmtList = db.prepare(`
2525
+ SELECT * FROM thought_branches
2526
+ WHERE project_hash = ?
2527
+ ORDER BY started_at DESC
2528
+ LIMIT ?
2529
+ `);
2530
+ this.stmtListByStatus = db.prepare(`
2531
+ SELECT * FROM thought_branches
2532
+ WHERE project_hash = ? AND status = ?
2533
+ ORDER BY started_at DESC
2534
+ LIMIT ?
2535
+ `);
2536
+ this.stmtListByType = db.prepare(`
2537
+ SELECT * FROM thought_branches
2538
+ WHERE project_hash = ? AND branch_type = ?
2539
+ ORDER BY started_at DESC
2540
+ LIMIT ?
2541
+ `);
2542
+ this.stmtUpdateArcStage = db.prepare(`
2543
+ UPDATE thought_branches SET arc_stage = ? WHERE id = ? AND project_hash = ?
2544
+ `);
2545
+ this.stmtUpdateToolPattern = db.prepare(`
2546
+ UPDATE thought_branches SET tool_pattern = ? WHERE id = ? AND project_hash = ?
2547
+ `);
2548
+ this.stmtUpdateClassification = db.prepare(`
2549
+ UPDATE thought_branches SET branch_type = ?, title = ? WHERE id = ? AND project_hash = ?
2550
+ `);
2551
+ this.stmtUpdateSummary = db.prepare(`
2552
+ UPDATE thought_branches SET summary = ? WHERE id = ? AND project_hash = ?
2553
+ `);
2554
+ this.stmtIncrementObsCount = db.prepare(`
2555
+ UPDATE thought_branches SET observation_count = observation_count + 1 WHERE id = ? AND project_hash = ?
2556
+ `);
2557
+ this.stmtLinkDebugPath = db.prepare(`
2558
+ UPDATE thought_branches SET linked_debug_path_id = ? WHERE id = ? AND project_hash = ?
2559
+ `);
2560
+ this.stmtAddObservation = db.prepare(`
2561
+ INSERT OR IGNORE INTO branch_observations
2562
+ (branch_id, observation_id, sequence_order, tool_name, arc_stage_at_add)
2563
+ VALUES (?, ?, ?, ?, ?)
2564
+ `);
2565
+ this.stmtGetObservations = db.prepare(`
2566
+ SELECT * FROM branch_observations
2567
+ WHERE branch_id = ?
2568
+ ORDER BY sequence_order ASC
2569
+ `);
2570
+ this.stmtMaxSequence = db.prepare(`
2571
+ SELECT COALESCE(MAX(sequence_order), 0) AS max_seq FROM branch_observations
2572
+ WHERE branch_id = ?
2573
+ `);
2574
+ this.stmtFindStale = db.prepare(`
2575
+ SELECT * FROM thought_branches
2576
+ WHERE project_hash = ? AND status = 'active'
2577
+ AND started_at < datetime('now', '-24 hours')
2578
+ `);
2579
+ this.stmtFindUnclassified = db.prepare(`
2580
+ SELECT * FROM thought_branches
2581
+ WHERE project_hash = ? AND branch_type = 'unknown'
2582
+ AND observation_count >= 3
2583
+ ORDER BY started_at DESC
2584
+ LIMIT ?
2585
+ `);
2586
+ this.stmtFindRecentCompleted = db.prepare(`
2587
+ SELECT * FROM thought_branches
2588
+ WHERE project_hash = ? AND status = 'completed' AND summary IS NULL
2589
+ AND ended_at > datetime('now', '-1 hour')
2590
+ ORDER BY ended_at DESC
2591
+ LIMIT ?
2592
+ `);
2593
+ this.stmtFindRecentActive = db.prepare(`
2594
+ SELECT * FROM thought_branches
2595
+ WHERE project_hash = ? AND status = 'active'
2596
+ AND started_at > datetime('now', '-24 hours')
2597
+ ORDER BY started_at DESC
2598
+ LIMIT 1
2599
+ `);
2600
+ this.stmtListRecent = db.prepare(`
2601
+ SELECT * FROM thought_branches
2602
+ WHERE project_hash = ?
2603
+ AND started_at > datetime('now', ? || ' hours')
2604
+ ORDER BY started_at DESC
2605
+ `);
2606
+ }
2607
+ createBranch(sessionId, triggerSource, triggerObservationId) {
2608
+ const id = randomBytes(16).toString("hex");
2609
+ this.stmtCreate.run(id, this.projectHash, sessionId, triggerSource, triggerObservationId ?? null);
2610
+ return this.getBranch(id);
2611
+ }
2612
+ completeBranch(branchId) {
2613
+ this.stmtComplete.run(branchId, this.projectHash);
2614
+ }
2615
+ abandonBranch(branchId) {
2616
+ this.stmtAbandon.run(branchId, this.projectHash);
2617
+ }
2618
+ getActiveBranch() {
2619
+ const row = this.stmtGetActive.get(this.projectHash);
2620
+ return row ? rowToBranch(row) : null;
2621
+ }
2622
+ getBranch(branchId) {
2623
+ const row = this.stmtGetById.get(branchId, this.projectHash);
2624
+ return row ? rowToBranch(row) : null;
2625
+ }
2626
+ listBranches(limit = 20) {
2627
+ return this.stmtList.all(this.projectHash, limit).map(rowToBranch);
2628
+ }
2629
+ listByStatus(status, limit = 20) {
2630
+ return this.stmtListByStatus.all(this.projectHash, status, limit).map(rowToBranch);
2631
+ }
2632
+ listByType(branchType, limit = 20) {
2633
+ return this.stmtListByType.all(this.projectHash, branchType, limit).map(rowToBranch);
2634
+ }
2635
+ updateArcStage(branchId, stage) {
2636
+ this.stmtUpdateArcStage.run(stage, branchId, this.projectHash);
2637
+ }
2638
+ updateToolPattern(branchId, pattern) {
2639
+ this.stmtUpdateToolPattern.run(JSON.stringify(pattern), branchId, this.projectHash);
2640
+ }
2641
+ updateClassification(branchId, branchType, title) {
2642
+ this.stmtUpdateClassification.run(branchType, title, branchId, this.projectHash);
2643
+ }
2644
+ updateSummary(branchId, summary) {
2645
+ this.stmtUpdateSummary.run(summary, branchId, this.projectHash);
2646
+ }
2647
+ linkDebugPath(branchId, debugPathId) {
2648
+ this.stmtLinkDebugPath.run(debugPathId, branchId, this.projectHash);
2649
+ }
2650
+ addObservation(branchId, observationId, toolName, arcStage) {
2651
+ const { max_seq } = this.stmtMaxSequence.get(branchId);
2652
+ this.stmtAddObservation.run(branchId, observationId, max_seq + 1, toolName, arcStage);
2653
+ this.stmtIncrementObsCount.run(branchId, this.projectHash);
2654
+ }
2655
+ getObservations(branchId) {
2656
+ return this.stmtGetObservations.all(branchId).map(rowToBranchObservation);
2657
+ }
2658
+ findStaleBranches() {
2659
+ return this.stmtFindStale.all(this.projectHash).map(rowToBranch);
2660
+ }
2661
+ findUnclassifiedBranches(limit = 5) {
2662
+ return this.stmtFindUnclassified.all(this.projectHash, limit).map(rowToBranch);
2663
+ }
2664
+ findRecentCompletedUnsummarized(limit = 3) {
2665
+ return this.stmtFindRecentCompleted.all(this.projectHash, limit).map(rowToBranch);
2666
+ }
2667
+ findRecentActiveBranch() {
2668
+ const row = this.stmtFindRecentActive.get(this.projectHash);
2669
+ return row ? rowToBranch(row) : null;
2670
+ }
2671
+ listRecentBranches(hours) {
2672
+ return this.stmtListRecent.all(this.projectHash, `-${hours}`).map(rowToBranch);
2673
+ }
2674
+ };
2675
+ function rowToBranch(row) {
2676
+ let toolPattern = {};
2677
+ try {
2678
+ toolPattern = JSON.parse(row.tool_pattern);
2679
+ } catch {}
2680
+ return {
2681
+ id: row.id,
2682
+ project_hash: row.project_hash,
2683
+ session_id: row.session_id,
2684
+ status: row.status,
2685
+ branch_type: row.branch_type,
2686
+ arc_stage: row.arc_stage,
2687
+ title: row.title,
2688
+ summary: row.summary,
2689
+ parent_branch_id: row.parent_branch_id,
2690
+ linked_debug_path_id: row.linked_debug_path_id,
2691
+ trigger_source: row.trigger_source,
2692
+ trigger_observation_id: row.trigger_observation_id,
2693
+ observation_count: row.observation_count,
2694
+ tool_pattern: toolPattern,
2695
+ started_at: row.started_at,
2696
+ ended_at: row.ended_at,
2697
+ created_at: row.created_at
2698
+ };
2699
+ }
2700
+ function rowToBranchObservation(row) {
2701
+ return {
2702
+ branch_id: row.branch_id,
2703
+ observation_id: row.observation_id,
2704
+ sequence_order: row.sequence_order,
2705
+ tool_name: row.tool_name,
2706
+ arc_stage_at_add: row.arc_stage_at_add,
2707
+ created_at: row.created_at
2708
+ };
2709
+ }
2710
+
2711
+ //#endregion
2712
+ //#region src/storage/research-buffer.ts
2713
+ /**
2714
+ * Lightweight buffer for exploration tool events (Read, Glob, Grep).
2715
+ *
2716
+ * Instead of creating full observations for these low-signal tools,
2717
+ * they are stored in a temporary buffer. When a Write/Edit observation
2718
+ * is created, the recent buffer entries are attached as research context,
2719
+ * creating provenance links between exploration and changes.
2720
+ *
2721
+ * Buffer entries are flushed after 30 minutes.
2722
+ */
2723
+ var ResearchBufferRepository = class {
2724
+ db;
2725
+ projectHash;
2726
+ stmtInsert;
2727
+ stmtGetRecent;
2728
+ stmtFlush;
2729
+ constructor(db, projectHash) {
2730
+ this.db = db;
2731
+ this.projectHash = projectHash;
2732
+ this.stmtInsert = db.prepare(`
2733
+ INSERT INTO research_buffer (project_hash, session_id, tool_name, target)
2734
+ VALUES (?, ?, ?, ?)
2735
+ `);
2736
+ this.stmtGetRecent = db.prepare(`
2737
+ SELECT tool_name, target, created_at FROM research_buffer
2738
+ WHERE session_id = ? AND project_hash = ?
2739
+ AND created_at >= datetime('now', '-' || ? || ' minutes')
2740
+ ORDER BY created_at DESC
2741
+ `);
2742
+ this.stmtFlush = db.prepare(`
2743
+ DELETE FROM research_buffer
2744
+ WHERE created_at < datetime('now', '-' || ? || ' minutes')
2745
+ `);
2746
+ debug("research-buffer", "ResearchBufferRepository initialized", { projectHash });
2747
+ }
2748
+ /**
2749
+ * Records a research tool event in the buffer.
2750
+ */
2751
+ add(entry) {
2752
+ this.stmtInsert.run(this.projectHash, entry.sessionId, entry.toolName, entry.target);
2753
+ debug("research-buffer", "Buffered research event", {
2754
+ tool: entry.toolName,
2755
+ target: entry.target
2756
+ });
2757
+ }
2758
+ /**
2759
+ * Returns recent buffer entries for a session within a time window.
2760
+ */
2761
+ getRecent(sessionId, windowMinutes = 5) {
2762
+ return this.stmtGetRecent.all(sessionId, this.projectHash, windowMinutes).map((r) => ({
2763
+ toolName: r.tool_name,
2764
+ target: r.target,
2765
+ createdAt: r.created_at
2766
+ }));
2767
+ }
2768
+ /**
2769
+ * Deletes buffer entries older than the specified number of minutes.
2770
+ */
2771
+ flush(olderThanMinutes = 30) {
2772
+ const result = this.stmtFlush.run(olderThanMinutes);
2773
+ if (result.changes > 0) debug("research-buffer", "Flushed old entries", { deleted: result.changes });
2774
+ return result.changes;
2775
+ }
2776
+ };
2777
+
2778
+ //#endregion
2779
+ //#region src/storage/notifications.ts
2780
+ var NotificationStore = class {
2781
+ stmtInsert;
2782
+ stmtConsume;
2783
+ stmtSelect;
2784
+ constructor(db) {
2785
+ db.exec(`
2786
+ CREATE TABLE IF NOT EXISTS pending_notifications (
2787
+ id TEXT PRIMARY KEY,
2788
+ project_id TEXT NOT NULL,
2789
+ message TEXT NOT NULL,
2790
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2791
+ )
2792
+ `);
2793
+ this.stmtInsert = db.prepare("INSERT INTO pending_notifications (id, project_id, message) VALUES (?, ?, ?)");
2794
+ this.stmtSelect = db.prepare("SELECT * FROM pending_notifications WHERE project_id = ? ORDER BY created_at ASC LIMIT 10");
2795
+ this.stmtConsume = db.prepare("DELETE FROM pending_notifications WHERE project_id = ?");
2796
+ debug("db", "NotificationStore initialized");
2797
+ }
2798
+ add(projectId, message) {
2799
+ const id = randomBytes(16).toString("hex");
2800
+ this.stmtInsert.run(id, projectId, message);
2801
+ debug("db", "Notification added", { projectId });
2802
+ }
2803
+ /** Fetch and delete all pending notifications for a project (consume pattern). */
2804
+ consumePending(projectId) {
2805
+ const rows = this.stmtSelect.all(projectId);
2806
+ if (rows.length > 0) this.stmtConsume.run(projectId);
2807
+ return rows.map((r) => ({
2808
+ id: r.id,
2809
+ projectId: r.project_id,
2810
+ message: r.message,
2811
+ createdAt: r.created_at
2812
+ }));
2813
+ }
2814
+ };
2815
+
2816
+ //#endregion
2817
+ //#region src/paths/schema.ts
2818
+ const PATH_SCHEMA_DDL = `
2819
+ CREATE TABLE IF NOT EXISTS debug_paths (
2820
+ id TEXT PRIMARY KEY,
2821
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved', 'abandoned')),
2822
+ trigger_summary TEXT NOT NULL,
2823
+ resolution_summary TEXT,
2824
+ kiss_summary TEXT,
2825
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
2826
+ resolved_at TEXT,
2827
+ project_hash TEXT NOT NULL
2828
+ );
2829
+
2830
+ CREATE TABLE IF NOT EXISTS path_waypoints (
2831
+ id TEXT PRIMARY KEY,
2832
+ path_id TEXT NOT NULL REFERENCES debug_paths(id) ON DELETE CASCADE,
2833
+ observation_id TEXT,
2834
+ waypoint_type TEXT NOT NULL CHECK(waypoint_type IN ('error', 'attempt', 'failure', 'success', 'pivot', 'revert', 'discovery', 'resolution')),
2835
+ sequence_order INTEGER NOT NULL,
2836
+ summary TEXT NOT NULL,
2837
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2838
+ );
2839
+
2840
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_project_status
2841
+ ON debug_paths(project_hash, status);
2842
+
2843
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_started
2844
+ ON debug_paths(started_at DESC);
2845
+
2846
+ CREATE INDEX IF NOT EXISTS idx_path_waypoints_path_order
2847
+ ON path_waypoints(path_id, sequence_order);
2848
+ `;
2849
+ /**
2850
+ * Initializes debug path tables if they do not exist.
2851
+ * Safe to call multiple times (all statements use IF NOT EXISTS).
2852
+ */
2853
+ function initPathSchema(db) {
2854
+ db.exec(PATH_SCHEMA_DDL);
2855
+ }
2856
+
2857
+ //#endregion
2858
+ //#region src/paths/path-repository.ts
2859
+ var PathRepository = class {
2860
+ db;
2861
+ projectHash;
2862
+ stmtCreatePath;
2863
+ stmtResolvePath;
2864
+ stmtAbandonPath;
2865
+ stmtGetActivePath;
2866
+ stmtGetPath;
2867
+ stmtListPaths;
2868
+ stmtUpdateKiss;
2869
+ stmtFindRecentActive;
2870
+ stmtListByStatus;
2871
+ stmtAddWaypoint;
2872
+ stmtGetWaypoints;
2873
+ stmtCountWaypoints;
2874
+ stmtMaxSequence;
2875
+ constructor(db, projectHash) {
2876
+ this.db = db;
2877
+ this.projectHash = projectHash;
2878
+ this.stmtCreatePath = db.prepare(`
2879
+ INSERT INTO debug_paths (id, status, trigger_summary, started_at, project_hash)
2880
+ VALUES (?, 'active', ?, datetime('now'), ?)
2881
+ `);
2882
+ this.stmtResolvePath = db.prepare(`
2883
+ UPDATE debug_paths
2884
+ SET status = 'resolved', resolution_summary = ?, resolved_at = datetime('now')
2885
+ WHERE id = ? AND project_hash = ?
2886
+ `);
2887
+ this.stmtAbandonPath = db.prepare(`
2888
+ UPDATE debug_paths
2889
+ SET status = 'abandoned', resolved_at = datetime('now')
2890
+ WHERE id = ? AND project_hash = ?
2891
+ `);
2892
+ this.stmtGetActivePath = db.prepare(`
2893
+ SELECT * FROM debug_paths
2894
+ WHERE project_hash = ? AND status = 'active'
2895
+ ORDER BY started_at DESC
2896
+ LIMIT 1
2897
+ `);
2898
+ this.stmtGetPath = db.prepare(`
2899
+ SELECT * FROM debug_paths
2900
+ WHERE id = ? AND project_hash = ?
2901
+ `);
2902
+ this.stmtListPaths = db.prepare(`
2903
+ SELECT * FROM debug_paths
2904
+ WHERE project_hash = ?
2905
+ ORDER BY started_at DESC
2906
+ LIMIT ?
2907
+ `);
2908
+ this.stmtFindRecentActive = db.prepare(`
2909
+ SELECT * FROM debug_paths
2910
+ WHERE project_hash = ? AND status = 'active'
2911
+ AND started_at > datetime('now', '-24 hours')
2912
+ ORDER BY started_at DESC
2913
+ LIMIT 1
2914
+ `);
2915
+ this.stmtListByStatus = db.prepare(`
2916
+ SELECT * FROM debug_paths
2917
+ WHERE project_hash = ? AND status = ?
2918
+ ORDER BY started_at DESC
2919
+ LIMIT ?
2920
+ `);
2921
+ this.stmtUpdateKiss = db.prepare(`
2922
+ UPDATE debug_paths SET kiss_summary = ? WHERE id = ? AND project_hash = ?
2923
+ `);
2924
+ this.stmtAddWaypoint = db.prepare(`
2925
+ INSERT INTO path_waypoints (id, path_id, observation_id, waypoint_type, sequence_order, summary, created_at)
2926
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
2927
+ `);
2928
+ this.stmtGetWaypoints = db.prepare(`
2929
+ SELECT * FROM path_waypoints
2930
+ WHERE path_id = ?
2931
+ ORDER BY sequence_order ASC
2932
+ `);
2933
+ this.stmtCountWaypoints = db.prepare(`
2934
+ SELECT COUNT(*) AS count FROM path_waypoints
2935
+ WHERE path_id = ?
2936
+ `);
2937
+ this.stmtMaxSequence = db.prepare(`
2938
+ SELECT COALESCE(MAX(sequence_order), 0) AS max_seq FROM path_waypoints
2939
+ WHERE path_id = ?
2940
+ `);
2941
+ }
2942
+ /**
2943
+ * Creates a new active debug path.
2944
+ * Generates a UUID id, sets status='active' and started_at=now.
2945
+ */
2946
+ createPath(triggerSummary) {
2947
+ const id = randomBytes(16).toString("hex");
2948
+ this.stmtCreatePath.run(id, triggerSummary, this.projectHash);
2949
+ return this.getPath(id);
2950
+ }
2951
+ /**
2952
+ * Resolves a debug path with a resolution summary.
2953
+ * Sets status='resolved', resolved_at=now.
2954
+ */
2955
+ resolvePath(pathId, resolutionSummary) {
2956
+ this.stmtResolvePath.run(resolutionSummary, pathId, this.projectHash);
2957
+ }
2958
+ /**
2959
+ * Abandons a debug path.
2960
+ * Sets status='abandoned', resolved_at=now.
2961
+ */
2962
+ abandonPath(pathId) {
2963
+ this.stmtAbandonPath.run(pathId, this.projectHash);
2964
+ }
2965
+ /**
2966
+ * Returns the active path for this project (at most one active at a time).
2967
+ * Returns null if no active path exists.
2968
+ */
2969
+ getActivePath() {
2970
+ const row = this.stmtGetActivePath.get(this.projectHash);
2971
+ return row ? rowToDebugPath(row) : null;
2972
+ }
2973
+ /**
2974
+ * Gets a debug path by ID, scoped to this project.
2975
+ * Returns null if not found.
2976
+ */
2977
+ getPath(pathId) {
2978
+ const row = this.stmtGetPath.get(pathId, this.projectHash);
2979
+ return row ? rowToDebugPath(row) : null;
2980
+ }
2981
+ /**
2982
+ * Lists recent paths for this project, ordered by started_at DESC.
2983
+ * Default limit is 20.
2984
+ */
2985
+ listPaths(limit = 20) {
2986
+ return this.stmtListPaths.all(this.projectHash, limit).map(rowToDebugPath);
2987
+ }
2988
+ /**
2989
+ * Finds a recently active path (started within the last 24 hours).
2990
+ * Used for cross-session path linking — detects paths that may need
2991
+ * continuation from a prior session.
2992
+ */
2993
+ findRecentActivePath() {
2994
+ const row = this.stmtFindRecentActive.get(this.projectHash);
2995
+ return row ? rowToDebugPath(row) : null;
2996
+ }
2997
+ /**
2998
+ * Lists paths filtered by status, ordered by started_at DESC.
2999
+ * Useful for filtering to resolved/active/abandoned paths specifically.
3000
+ */
3001
+ listPathsByStatus(status, limit = 20) {
3002
+ return this.stmtListByStatus.all(this.projectHash, status, limit).map(rowToDebugPath);
3003
+ }
3004
+ /**
3005
+ * Updates the kiss_summary column for a resolved debug path.
3006
+ * Stores the full KissSummary JSON string.
3007
+ */
3008
+ updateKissSummary(pathId, kissSummary) {
3009
+ this.stmtUpdateKiss.run(kissSummary, pathId, this.projectHash);
3010
+ }
3011
+ /**
3012
+ * Adds a waypoint to a debug path.
3013
+ * Auto-increments sequence_order based on existing waypoints.
3014
+ */
3015
+ addWaypoint(pathId, type, summary, observationId) {
3016
+ const id = randomBytes(16).toString("hex");
3017
+ const { max_seq } = this.stmtMaxSequence.get(pathId);
3018
+ const sequenceOrder = max_seq + 1;
3019
+ this.stmtAddWaypoint.run(id, pathId, observationId ?? null, type, sequenceOrder, summary);
3020
+ return this.getWaypoints(pathId).find((w) => w.id === id);
3021
+ }
3022
+ /**
3023
+ * Returns all waypoints for a path, ordered by sequence_order ASC.
3024
+ */
3025
+ getWaypoints(pathId) {
3026
+ return this.stmtGetWaypoints.all(pathId).map(rowToPathWaypoint);
3027
+ }
3028
+ /**
3029
+ * Counts waypoints for a path. Used for cap enforcement (max 30 per path).
3030
+ */
3031
+ countWaypoints(pathId) {
3032
+ return this.stmtCountWaypoints.get(pathId).count;
3033
+ }
3034
+ };
3035
+ function rowToDebugPath(row) {
3036
+ return {
3037
+ id: row.id,
3038
+ status: row.status,
3039
+ trigger_summary: row.trigger_summary,
3040
+ resolution_summary: row.resolution_summary,
3041
+ kiss_summary: row.kiss_summary,
3042
+ started_at: row.started_at,
3043
+ resolved_at: row.resolved_at,
3044
+ project_hash: row.project_hash
3045
+ };
3046
+ }
3047
+ function rowToPathWaypoint(row) {
3048
+ return {
3049
+ id: row.id,
3050
+ path_id: row.path_id,
3051
+ observation_id: row.observation_id,
3052
+ waypoint_type: row.waypoint_type,
3053
+ sequence_order: row.sequence_order,
3054
+ summary: row.summary,
3055
+ created_at: row.created_at
3056
+ };
3057
+ }
3058
+
3059
+ //#endregion
3060
+ //#region src/storage/tool-registry.ts
3061
+ /**
3062
+ * Repository for tool registry CRUD operations.
3063
+ *
3064
+ * Unlike ObservationRepository, this is NOT scoped to a single project --
3065
+ * the tool registry spans all scopes (global, project, plugin) and is
3066
+ * queried cross-project for tool discovery and routing.
3067
+ *
3068
+ * All SQL statements are prepared once in the constructor and reused for
3069
+ * every call (better-sqlite3 performance best practice).
3070
+ */
3071
+ var ToolRegistryRepository = class {
3072
+ db;
3073
+ stmtUpsert;
3074
+ stmtRecordUsage;
3075
+ stmtGetByScope;
3076
+ stmtGetByName;
3077
+ stmtGetAll;
3078
+ stmtCount;
3079
+ stmtGetAvailableForSession;
3080
+ stmtInsertEvent;
3081
+ stmtGetUsageForTool;
3082
+ stmtGetUsageForSession;
3083
+ stmtGetUsageSince;
3084
+ stmtGetRecentUsage;
3085
+ stmtMarkStale;
3086
+ stmtMarkDemoted;
3087
+ stmtMarkActive;
3088
+ stmtGetConfigSourced;
3089
+ stmtGetRecentEventsForTool;
3090
+ constructor(db) {
3091
+ this.db = db;
3092
+ try {
3093
+ this.stmtUpsert = db.prepare(`
3094
+ INSERT INTO tool_registry (name, tool_type, scope, source, project_hash, description, server_name, trigger_hints, discovered_at)
3095
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3096
+ ON CONFLICT (name, COALESCE(project_hash, ''))
3097
+ DO UPDATE SET
3098
+ description = COALESCE(excluded.description, tool_registry.description),
3099
+ trigger_hints = COALESCE(excluded.trigger_hints, tool_registry.trigger_hints),
3100
+ source = excluded.source,
3101
+ status = 'active',
3102
+ updated_at = datetime('now')
3103
+ `);
3104
+ this.stmtRecordUsage = db.prepare(`
3105
+ UPDATE tool_registry
3106
+ SET usage_count = usage_count + 1,
3107
+ last_used_at = datetime('now'),
3108
+ updated_at = datetime('now')
3109
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
3110
+ `);
3111
+ this.stmtGetByScope = db.prepare(`
3112
+ SELECT * FROM tool_registry
3113
+ WHERE scope = 'global' OR project_hash = ?
3114
+ ORDER BY usage_count DESC, discovered_at DESC
3115
+ `);
3116
+ this.stmtGetByName = db.prepare(`
3117
+ SELECT * FROM tool_registry
3118
+ WHERE name = ?
3119
+ ORDER BY usage_count DESC
3120
+ LIMIT 1
3121
+ `);
3122
+ this.stmtGetAll = db.prepare(`
3123
+ SELECT * FROM tool_registry
3124
+ ORDER BY usage_count DESC, discovered_at DESC
3125
+ `);
3126
+ this.stmtCount = db.prepare(`
3127
+ SELECT COUNT(*) AS count FROM tool_registry
3128
+ `);
3129
+ this.stmtGetAvailableForSession = db.prepare(`
3130
+ SELECT * FROM tool_registry
3131
+ WHERE
3132
+ scope = 'global'
3133
+ OR (scope = 'project' AND project_hash = ?)
3134
+ OR (scope = 'plugin' AND (project_hash IS NULL OR project_hash = ?))
3135
+ ORDER BY
3136
+ CASE status
3137
+ WHEN 'active' THEN 0
3138
+ WHEN 'stale' THEN 1
3139
+ WHEN 'demoted' THEN 2
3140
+ ELSE 3
3141
+ END,
3142
+ CASE tool_type
3143
+ WHEN 'mcp_server' THEN 0
3144
+ WHEN 'slash_command' THEN 1
3145
+ WHEN 'skill' THEN 2
3146
+ WHEN 'plugin' THEN 3
3147
+ ELSE 4
3148
+ END,
3149
+ usage_count DESC,
3150
+ discovered_at DESC
3151
+ `);
3152
+ this.stmtInsertEvent = db.prepare(`
3153
+ INSERT INTO tool_usage_events (tool_name, session_id, project_hash, success)
3154
+ VALUES (?, ?, ?, ?)
3155
+ `);
3156
+ this.stmtGetUsageForTool = db.prepare(`
3157
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
3158
+ FROM tool_usage_events
3159
+ WHERE tool_name = ? AND project_hash = ?
3160
+ AND created_at >= datetime('now', ?)
3161
+ GROUP BY tool_name
3162
+ `);
3163
+ this.stmtGetUsageForSession = db.prepare(`
3164
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
3165
+ FROM tool_usage_events
3166
+ WHERE session_id = ?
3167
+ GROUP BY tool_name
3168
+ ORDER BY usage_count DESC
3169
+ `);
3170
+ this.stmtGetUsageSince = db.prepare(`
3171
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
3172
+ FROM tool_usage_events
3173
+ WHERE project_hash = ?
3174
+ AND created_at >= datetime('now', ?)
3175
+ GROUP BY tool_name
3176
+ ORDER BY usage_count DESC
3177
+ `);
3178
+ this.stmtGetRecentUsage = db.prepare(`
3179
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
3180
+ FROM (
3181
+ SELECT tool_name, created_at
3182
+ FROM tool_usage_events
3183
+ WHERE project_hash = ?
3184
+ ORDER BY created_at DESC
3185
+ LIMIT ?
3186
+ )
3187
+ GROUP BY tool_name
3188
+ ORDER BY usage_count DESC
3189
+ `);
3190
+ this.stmtMarkStale = db.prepare(`
3191
+ UPDATE tool_registry
3192
+ SET status = 'stale', updated_at = datetime('now')
3193
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
3194
+ AND status != 'stale'
3195
+ `);
3196
+ this.stmtMarkDemoted = db.prepare(`
3197
+ UPDATE tool_registry
3198
+ SET status = 'demoted', updated_at = datetime('now')
3199
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
3200
+ `);
3201
+ this.stmtMarkActive = db.prepare(`
3202
+ UPDATE tool_registry
3203
+ SET status = 'active', updated_at = datetime('now')
3204
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
3205
+ AND status != 'active'
3206
+ `);
3207
+ this.stmtGetConfigSourced = db.prepare(`
3208
+ SELECT * FROM tool_registry
3209
+ WHERE source LIKE 'config:%'
3210
+ AND status = 'active'
3211
+ AND (project_hash = ? OR project_hash IS NULL)
3212
+ `);
3213
+ this.stmtGetRecentEventsForTool = db.prepare(`
3214
+ SELECT success FROM tool_usage_events
3215
+ WHERE tool_name = ? AND project_hash = ?
3216
+ ORDER BY created_at DESC
3217
+ LIMIT ?
3218
+ `);
3219
+ debug("tool-registry", "ToolRegistryRepository initialized");
3220
+ } catch (err) {
3221
+ throw err;
3222
+ }
3223
+ }
3224
+ /**
3225
+ * Inserts or updates a discovered tool in the registry.
3226
+ * On conflict (same name + project_hash), updates description and source.
3227
+ */
3228
+ upsert(tool) {
3229
+ try {
3230
+ this.stmtUpsert.run(tool.name, tool.toolType, tool.scope, tool.source, tool.projectHash, tool.description, tool.serverName, tool.triggerHints);
3231
+ debug("tool-registry", "Upserted tool", {
3232
+ name: tool.name,
3233
+ scope: tool.scope
3234
+ });
3235
+ } catch (err) {
3236
+ debug("tool-registry", "Failed to upsert tool", {
3237
+ name: tool.name,
3238
+ error: String(err)
3239
+ });
3240
+ }
3241
+ }
3242
+ /**
3243
+ * Increments usage_count and updates last_used_at for a tool.
3244
+ * Called from organic PostToolUse discovery to track usage.
3245
+ */
3246
+ recordUsage(name, projectHash) {
3247
+ try {
3248
+ this.stmtRecordUsage.run(name, projectHash);
3249
+ debug("tool-registry", "Recorded usage", { name });
3250
+ } catch (err) {
3251
+ debug("tool-registry", "Failed to record usage", {
3252
+ name,
3253
+ error: String(err)
3254
+ });
3255
+ }
3256
+ }
3257
+ /**
3258
+ * Records usage for an existing tool, or creates it if not yet in the registry.
3259
+ * This is the entry point for organic discovery -- an upsert-and-increment-if-exists pattern.
3260
+ *
3261
+ * First tries recordUsage. If the tool is not in the registry (changes === 0),
3262
+ * calls upsert with the full tool info, which initializes it with usage_count = 0.
3263
+ */
3264
+ recordOrCreate(name, defaults, sessionId, success) {
3265
+ try {
3266
+ const result = this.stmtRecordUsage.run(name, defaults.projectHash);
3267
+ if (result.changes === 0) this.upsert({
3268
+ name,
3269
+ ...defaults
3270
+ });
3271
+ if (sessionId !== void 0) this.stmtInsertEvent.run(name, sessionId, defaults.projectHash, success === false ? 0 : 1);
3272
+ debug("tool-registry", "recordOrCreate completed", {
3273
+ name,
3274
+ created: result.changes === 0
3275
+ });
3276
+ } catch (err) {
3277
+ debug("tool-registry", "Failed recordOrCreate", {
3278
+ name,
3279
+ error: String(err)
3280
+ });
3281
+ }
3282
+ }
3283
+ /**
3284
+ * Returns global tools plus project-specific tools for the given project.
3285
+ */
3286
+ getForProject(projectHash) {
3287
+ return this.stmtGetByScope.all(projectHash);
3288
+ }
3289
+ /**
3290
+ * Returns tools available in the resolved scope for a given project.
3291
+ * Implements SCOP-01/SCOP-02/SCOP-03 scope resolution rules.
3292
+ */
3293
+ getAvailableForSession(projectHash) {
3294
+ return this.stmtGetAvailableForSession.all(projectHash, projectHash);
3295
+ }
3296
+ /**
3297
+ * Returns the top-usage entry for a given tool name.
3298
+ */
3299
+ getByName(name) {
3300
+ return this.stmtGetByName.get(name) ?? null;
3301
+ }
3302
+ /**
3303
+ * Returns all tools in the registry (for debugging/admin).
3304
+ */
3305
+ getAll() {
3306
+ return this.stmtGetAll.all();
3307
+ }
3308
+ /**
3309
+ * Returns total number of tools in the registry.
3310
+ */
3311
+ count() {
3312
+ return this.stmtCount.get().count;
3313
+ }
3314
+ /**
3315
+ * Returns usage stats for a specific tool within a time window.
3316
+ * @param timeModifier - SQLite datetime modifier, e.g., '-7 days', '-30 days'
3317
+ */
3318
+ getUsageForTool(toolName, projectHash, timeModifier = "-7 days") {
3319
+ return this.stmtGetUsageForTool.get(toolName, projectHash, timeModifier) ?? null;
3320
+ }
3321
+ /**
3322
+ * Returns per-tool usage stats for a specific session.
3323
+ */
3324
+ getUsageForSession(sessionId) {
3325
+ return this.stmtGetUsageForSession.all(sessionId);
3326
+ }
3327
+ /**
3328
+ * Returns per-tool usage stats since a time offset for a project.
3329
+ * @param timeModifier - SQLite datetime modifier, e.g., '-7 days', '-30 days'
3330
+ */
3331
+ getUsageSince(projectHash, timeModifier = "-7 days") {
3332
+ return this.stmtGetUsageSince.all(projectHash, timeModifier);
3333
+ }
3334
+ /**
3335
+ * Returns per-tool usage stats from the last N events for a project.
3336
+ * Event-count-based window instead of time-based — immune to usage gaps.
3337
+ * @param limit - Number of recent events to consider (default 200)
3338
+ */
3339
+ getRecentUsage(projectHash, limit = 200) {
3340
+ return this.stmtGetRecentUsage.all(projectHash, limit);
3341
+ }
3342
+ /**
3343
+ * Marks a tool as stale (no longer in config but still in registry).
3344
+ * Idempotent -- no-op if already stale.
3345
+ */
3346
+ markStale(name, projectHash) {
3347
+ try {
3348
+ this.stmtMarkStale.run(name, projectHash);
3349
+ debug("tool-registry", "Marked tool stale", { name });
3350
+ } catch (err) {
3351
+ debug("tool-registry", "Failed to mark tool stale", {
3352
+ name,
3353
+ error: String(err)
3354
+ });
3355
+ }
3356
+ }
3357
+ /**
3358
+ * Marks a tool as demoted (high failure rate detected).
3359
+ */
3360
+ markDemoted(name, projectHash) {
3361
+ try {
3362
+ this.stmtMarkDemoted.run(name, projectHash);
3363
+ debug("tool-registry", "Marked tool demoted", { name });
3364
+ } catch (err) {
3365
+ debug("tool-registry", "Failed to mark tool demoted", {
3366
+ name,
3367
+ error: String(err)
3368
+ });
3369
+ }
3370
+ }
3371
+ /**
3372
+ * Marks a tool as active (restored from stale/demoted).
3373
+ * Idempotent -- no-op if already active.
3374
+ */
3375
+ markActive(name, projectHash) {
3376
+ try {
3377
+ this.stmtMarkActive.run(name, projectHash);
3378
+ debug("tool-registry", "Marked tool active", { name });
3379
+ } catch (err) {
3380
+ debug("tool-registry", "Failed to mark tool active", {
3381
+ name,
3382
+ error: String(err)
3383
+ });
3384
+ }
3385
+ }
3386
+ /**
3387
+ * Returns all config-sourced active tools for a given project (or global).
3388
+ * Used by staleness detection to compare against current config state.
3389
+ */
3390
+ getConfigSourcedTools(projectHash) {
3391
+ try {
3392
+ return this.stmtGetConfigSourced.all(projectHash);
3393
+ } catch (err) {
3394
+ debug("tool-registry", "Failed to get config-sourced tools", { error: String(err) });
3395
+ return [];
3396
+ }
3397
+ }
3398
+ /**
3399
+ * Returns recent success/failure events for a specific tool.
3400
+ * Used by failure-driven demotion to check failure rate.
3401
+ * @param limit - Number of recent events to check (default 5)
3402
+ */
3403
+ getRecentEventsForTool(toolName, projectHash, limit = 5) {
3404
+ try {
3405
+ return this.stmtGetRecentEventsForTool.all(toolName, projectHash, limit);
3406
+ } catch (err) {
3407
+ debug("tool-registry", "Failed to get recent events for tool", {
3408
+ toolName,
3409
+ error: String(err)
3410
+ });
3411
+ return [];
3412
+ }
3413
+ }
3414
+ /**
3415
+ * Sanitizes a user query for safe FTS5 MATCH usage.
3416
+ * Removes FTS5 operators and special characters to prevent syntax errors.
3417
+ * Returns null if the query is empty after sanitization.
3418
+ */
3419
+ sanitizeQuery(query) {
3420
+ const words = query.trim().split(/\s+/).filter(Boolean);
3421
+ if (words.length === 0) return null;
3422
+ const sanitized = words.map((w) => {
3423
+ let cleaned = w.replace(/["*()^{}[\]]/g, "");
3424
+ if (/^(NEAR|OR|AND|NOT)$/i.test(cleaned)) return "";
3425
+ cleaned = cleaned.replace(/[^\w\-]/g, "");
3426
+ return cleaned;
3427
+ }).filter(Boolean);
3428
+ if (sanitized.length === 0) return null;
3429
+ return sanitized.join(" ");
3430
+ }
3431
+ /**
3432
+ * FTS5 keyword search on tool_registry_fts (name + description).
3433
+ * Returns ranked results using BM25 with name weighted 2x over description.
3434
+ */
3435
+ searchByKeyword(query, options) {
3436
+ const sanitized = this.sanitizeQuery(query);
3437
+ if (!sanitized) return [];
3438
+ const limit = options?.limit ?? 20;
3439
+ let sql = `
3440
+ SELECT tr.*, bm25(tool_registry_fts, 2.0, 1.0) AS rank
3441
+ FROM tool_registry_fts
3442
+ JOIN tool_registry tr ON tr.id = tool_registry_fts.rowid
3443
+ WHERE tool_registry_fts MATCH ?
3444
+ `;
3445
+ const params = [sanitized];
3446
+ if (options?.scope) {
3447
+ sql += " AND tr.scope = ?";
3448
+ params.push(options.scope);
3449
+ }
3450
+ sql += " ORDER BY rank LIMIT ?";
3451
+ params.push(limit);
3452
+ try {
3453
+ return this.db.prepare(sql).all(...params).map(({ rank, ...toolFields }) => ({
3454
+ tool: toolFields,
3455
+ score: Math.abs(rank),
3456
+ matchType: "fts"
3457
+ }));
3458
+ } catch (err) {
3459
+ debug("tool-registry", "FTS5 search failed", { error: String(err) });
3460
+ return [];
3461
+ }
3462
+ }
3463
+ /**
3464
+ * Vector similarity search on tool_registry_embeddings using vec0 KNN.
3465
+ * Returns tool IDs and distances sorted by cosine similarity.
3466
+ */
3467
+ searchByVector(queryEmbedding, options) {
3468
+ const limit = options?.limit ?? 40;
3469
+ try {
3470
+ let sql;
3471
+ const params = [queryEmbedding];
3472
+ if (options?.scope) {
3473
+ sql = `
3474
+ SELECT tre.tool_id, tre.distance
3475
+ FROM tool_registry_embeddings tre
3476
+ JOIN tool_registry tr ON tr.id = tre.tool_id
3477
+ WHERE tre.embedding MATCH ? AND tr.scope = ?
3478
+ ORDER BY tre.distance LIMIT ?
3479
+ `;
3480
+ params.push(options.scope);
3481
+ } else sql = `
3482
+ SELECT tre.tool_id, tre.distance
3483
+ FROM tool_registry_embeddings tre
3484
+ WHERE tre.embedding MATCH ?
3485
+ ORDER BY tre.distance LIMIT ?
3486
+ `;
3487
+ params.push(limit);
3488
+ return this.db.prepare(sql).all(...params);
3489
+ } catch (err) {
3490
+ debug("tool-registry", "Vector search failed", { error: String(err) });
3491
+ return [];
3492
+ }
3493
+ }
3494
+ /**
3495
+ * Hybrid search combining FTS5 keyword and vec0 vector results via
3496
+ * reciprocal rank fusion (RRF). Falls back to FTS5-only when vector
3497
+ * search is unavailable (no worker, no sqlite-vec, no embeddings).
3498
+ */
3499
+ async searchTools(query, options) {
3500
+ const limit = options?.limit ?? 20;
3501
+ const ftsResults = this.searchByKeyword(query, {
3502
+ scope: options?.scope,
3503
+ limit
3504
+ });
3505
+ let vectorResults = [];
3506
+ if (options?.worker?.isReady() && options?.hasVectorSupport) {
3507
+ const queryEmbedding = await options.worker.embed(query);
3508
+ if (queryEmbedding) vectorResults = this.searchByVector(queryEmbedding, {
3509
+ scope: options?.scope,
3510
+ limit: limit * 2
3511
+ });
3512
+ }
3513
+ if (vectorResults.length === 0) return ftsResults.slice(0, limit);
3514
+ const fused = reciprocalRankFusion([ftsResults.map((r) => ({ id: String(r.tool.id) })), vectorResults.map((r) => ({ id: String(r.tool_id) }))]);
3515
+ const ftsMap = /* @__PURE__ */ new Map();
3516
+ for (const r of ftsResults) ftsMap.set(String(r.tool.id), r);
3517
+ const vecIds = new Set(vectorResults.map((r) => String(r.tool_id)));
3518
+ const results = [];
3519
+ for (const item of fused) {
3520
+ if (results.length >= limit) break;
3521
+ const fromFts = ftsMap.get(item.id);
3522
+ const fromVec = vecIds.has(item.id);
3523
+ if (fromFts) results.push({
3524
+ tool: fromFts.tool,
3525
+ score: item.fusedScore,
3526
+ matchType: fromFts && fromVec ? "hybrid" : "fts"
3527
+ });
3528
+ else if (fromVec) {
3529
+ const toolRow = this.db.prepare("SELECT * FROM tool_registry WHERE id = ?").get(Number(item.id));
3530
+ if (toolRow) results.push({
3531
+ tool: toolRow,
3532
+ score: item.fusedScore,
3533
+ matchType: "vector"
3534
+ });
3535
+ }
3536
+ }
3537
+ return results;
3538
+ }
3539
+ /**
3540
+ * Stores an embedding vector for a tool in tool_registry_embeddings.
3541
+ * Used by the background embedding loop to index tool descriptions.
3542
+ */
3543
+ storeEmbedding(toolId, embedding) {
3544
+ try {
3545
+ this.db.prepare("INSERT OR REPLACE INTO tool_registry_embeddings(tool_id, embedding) VALUES (?, ?)").run(toolId, embedding);
3546
+ } catch (err) {
3547
+ debug("tool-registry", "Failed to store tool embedding", {
3548
+ toolId,
3549
+ error: String(err)
3550
+ });
3551
+ }
3552
+ }
3553
+ /**
3554
+ * Returns tools that have descriptions but no embedding yet.
3555
+ * Used by the background embedding loop to find work.
3556
+ */
3557
+ findUnembeddedTools(limit = 5) {
3558
+ try {
3559
+ return this.db.prepare(`
3560
+ SELECT id, name, description FROM tool_registry
3561
+ WHERE description IS NOT NULL
3562
+ AND id NOT IN (SELECT tool_id FROM tool_registry_embeddings)
3563
+ LIMIT ?
3564
+ `).all(limit);
3565
+ } catch (err) {
3566
+ debug("tool-registry", "Failed to find unembedded tools", { error: String(err) });
3567
+ return [];
3568
+ }
3569
+ }
3570
+ };
3571
+
3572
+ //#endregion
3573
+ export { hybridSearch as A, getNodesByType as C, upsertNode as D, traverseFrom as E, openDatabase as F, MIGRATIONS as I, runMigrations as L, SessionRepository as M, ObservationRepository as N, SaveGuard as O, rowToObservation as P, debug as R, getNodeByNameAndType as S, insertEdge as T, detectStaleness as _, ResearchBufferRepository as a, countEdgesForNode as b, inferScope as c, executePurge as d, findAnalysis as f, saveHygieneConfig as g, resetHygieneConfig as h, NotificationStore as i, SearchEngine as j, jaccardSimilarity as k, inferToolType as l, loadHygieneConfig as m, PathRepository as n, BranchRepository as o, runAutoCleanup as p, initPathSchema as r, extractServerName as s, ToolRegistryRepository as t, analyzeObservations as u, flagStaleObservation as v, initGraphSchema as w, getEdgesForNode as x, initStalenessSchema as y, debugTimed as z };
3574
+ //# sourceMappingURL=tool-registry-e710BvXq.mjs.map