laminark 2.21.6

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/README.md +182 -0
  3. package/package.json +63 -0
  4. package/plugin/.claude-plugin/plugin.json +13 -0
  5. package/plugin/.mcp.json +12 -0
  6. package/plugin/dist/analysis/worker.d.ts +1 -0
  7. package/plugin/dist/analysis/worker.js +233 -0
  8. package/plugin/dist/analysis/worker.js.map +1 -0
  9. package/plugin/dist/config-t8LZeB-u.mjs +90 -0
  10. package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
  11. package/plugin/dist/hooks/handler.d.ts +284 -0
  12. package/plugin/dist/hooks/handler.d.ts.map +1 -0
  13. package/plugin/dist/hooks/handler.js +2125 -0
  14. package/plugin/dist/hooks/handler.js.map +1 -0
  15. package/plugin/dist/index.d.ts +445 -0
  16. package/plugin/dist/index.d.ts.map +1 -0
  17. package/plugin/dist/index.js +5831 -0
  18. package/plugin/dist/index.js.map +1 -0
  19. package/plugin/dist/observations-Ch0nc47i.d.mts +170 -0
  20. package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
  21. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs +2655 -0
  22. package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
  23. package/plugin/hooks/hooks.json +78 -0
  24. package/plugin/scripts/README.md +47 -0
  25. package/plugin/scripts/bump-version.sh +44 -0
  26. package/plugin/scripts/ensure-deps.sh +12 -0
  27. package/plugin/scripts/install.sh +63 -0
  28. package/plugin/scripts/local-install.sh +103 -0
  29. package/plugin/scripts/setup-tmpdir.sh +65 -0
  30. package/plugin/scripts/uninstall.sh +95 -0
  31. package/plugin/scripts/update.sh +88 -0
  32. package/plugin/scripts/verify-install.sh +43 -0
  33. package/plugin/ui/activity.js +185 -0
  34. package/plugin/ui/app.js +1642 -0
  35. package/plugin/ui/graph.js +2333 -0
  36. package/plugin/ui/help.js +228 -0
  37. package/plugin/ui/index.html +492 -0
  38. package/plugin/ui/settings.js +650 -0
  39. package/plugin/ui/styles.css +2910 -0
  40. package/plugin/ui/timeline.js +652 -0
@@ -0,0 +1,2655 @@
1
+ import { a as isDebugEnabled } from "./config-t8LZeB-u.mjs";
2
+ import Database from "better-sqlite3";
3
+ import * as sqliteVec from "sqlite-vec";
4
+ import { mkdirSync } from "node:fs";
5
+ import { dirname } 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
+ */
85
+ const MIGRATIONS = [
86
+ {
87
+ version: 1,
88
+ name: "create_observations",
89
+ up: `
90
+ CREATE TABLE observations (
91
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ id TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
93
+ project_hash TEXT NOT NULL,
94
+ content TEXT NOT NULL,
95
+ source TEXT NOT NULL DEFAULT 'unknown',
96
+ session_id TEXT,
97
+ embedding BLOB,
98
+ embedding_model TEXT,
99
+ embedding_version TEXT,
100
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
101
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
102
+ deleted_at TEXT
103
+ );
104
+
105
+ CREATE INDEX idx_observations_project ON observations(project_hash);
106
+ CREATE INDEX idx_observations_session ON observations(session_id);
107
+ CREATE INDEX idx_observations_created ON observations(created_at);
108
+ CREATE INDEX idx_observations_deleted ON observations(deleted_at) WHERE deleted_at IS NOT NULL;
109
+ `
110
+ },
111
+ {
112
+ version: 2,
113
+ name: "create_sessions",
114
+ up: `
115
+ CREATE TABLE sessions (
116
+ id TEXT PRIMARY KEY,
117
+ project_hash TEXT NOT NULL,
118
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
119
+ ended_at TEXT,
120
+ summary TEXT
121
+ );
122
+
123
+ CREATE INDEX idx_sessions_project ON sessions(project_hash);
124
+ CREATE INDEX idx_sessions_started ON sessions(started_at);
125
+ `
126
+ },
127
+ {
128
+ version: 3,
129
+ name: "create_fts5_observations",
130
+ up: `
131
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
132
+ content,
133
+ content='observations',
134
+ content_rowid='rowid',
135
+ tokenize='porter unicode61'
136
+ );
137
+
138
+ -- Sync trigger: INSERT
139
+ CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
140
+ INSERT INTO observations_fts(rowid, content)
141
+ VALUES (new.rowid, new.content);
142
+ END;
143
+
144
+ -- Sync trigger: UPDATE (delete old entry, insert new)
145
+ CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
146
+ INSERT INTO observations_fts(observations_fts, rowid, content)
147
+ VALUES('delete', old.rowid, old.content);
148
+ INSERT INTO observations_fts(rowid, content)
149
+ VALUES (new.rowid, new.content);
150
+ END;
151
+
152
+ -- Sync trigger: DELETE
153
+ CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
154
+ INSERT INTO observations_fts(observations_fts, rowid, content)
155
+ VALUES('delete', old.rowid, old.content);
156
+ END;
157
+ `
158
+ },
159
+ {
160
+ version: 4,
161
+ name: "create_vec0_embeddings",
162
+ up: `
163
+ CREATE VIRTUAL TABLE IF NOT EXISTS observation_embeddings USING vec0(
164
+ observation_id TEXT PRIMARY KEY,
165
+ embedding float[384]
166
+ );
167
+ `
168
+ },
169
+ {
170
+ version: 5,
171
+ name: "add_observation_title",
172
+ up: `
173
+ ALTER TABLE observations ADD COLUMN title TEXT;
174
+
175
+ DROP TRIGGER observations_ai;
176
+ DROP TRIGGER observations_au;
177
+ DROP TRIGGER observations_ad;
178
+ DROP TABLE observations_fts;
179
+
180
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
181
+ title,
182
+ content,
183
+ content='observations',
184
+ content_rowid='rowid',
185
+ tokenize='porter unicode61'
186
+ );
187
+
188
+ CREATE TRIGGER observations_ai AFTER INSERT ON observations BEGIN
189
+ INSERT INTO observations_fts(rowid, title, content)
190
+ VALUES (new.rowid, new.title, new.content);
191
+ END;
192
+
193
+ CREATE TRIGGER observations_au AFTER UPDATE ON observations BEGIN
194
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
195
+ VALUES('delete', old.rowid, old.title, old.content);
196
+ INSERT INTO observations_fts(rowid, title, content)
197
+ VALUES (new.rowid, new.title, new.content);
198
+ END;
199
+
200
+ CREATE TRIGGER observations_ad AFTER DELETE ON observations BEGIN
201
+ INSERT INTO observations_fts(observations_fts, rowid, title, content)
202
+ VALUES('delete', old.rowid, old.title, old.content);
203
+ END;
204
+
205
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
206
+ `
207
+ },
208
+ {
209
+ version: 6,
210
+ name: "recreate_vec0_cosine_distance",
211
+ up: `
212
+ DROP TABLE IF EXISTS observation_embeddings;
213
+ CREATE VIRTUAL TABLE IF NOT EXISTS observation_embeddings USING vec0(
214
+ observation_id TEXT PRIMARY KEY,
215
+ embedding float[384] distance_metric=cosine
216
+ );
217
+ `
218
+ },
219
+ {
220
+ version: 7,
221
+ name: "create_context_stashes",
222
+ up: `
223
+ CREATE TABLE context_stashes (
224
+ id TEXT PRIMARY KEY,
225
+ project_id TEXT NOT NULL,
226
+ session_id TEXT NOT NULL,
227
+ topic_label TEXT NOT NULL,
228
+ summary TEXT NOT NULL,
229
+ observation_snapshots TEXT NOT NULL,
230
+ observation_ids TEXT NOT NULL,
231
+ status TEXT NOT NULL DEFAULT 'stashed',
232
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
233
+ resumed_at TEXT
234
+ );
235
+
236
+ CREATE INDEX idx_stashes_project_status_created
237
+ ON context_stashes(project_id, status, created_at DESC);
238
+
239
+ CREATE INDEX idx_stashes_session
240
+ ON context_stashes(session_id);
241
+ `
242
+ },
243
+ {
244
+ version: 8,
245
+ name: "create_threshold_history",
246
+ up: `
247
+ CREATE TABLE threshold_history (
248
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
249
+ project_id TEXT NOT NULL,
250
+ session_id TEXT NOT NULL,
251
+ final_ewma_distance REAL NOT NULL,
252
+ final_ewma_variance REAL NOT NULL,
253
+ observation_count INTEGER NOT NULL,
254
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
255
+ );
256
+
257
+ CREATE INDEX idx_threshold_history_project
258
+ ON threshold_history(project_id, created_at DESC);
259
+ `
260
+ },
261
+ {
262
+ version: 9,
263
+ name: "create_shift_decisions",
264
+ up: `
265
+ CREATE TABLE shift_decisions (
266
+ id TEXT PRIMARY KEY,
267
+ project_id TEXT NOT NULL,
268
+ session_id TEXT NOT NULL,
269
+ observation_id TEXT,
270
+ distance REAL NOT NULL,
271
+ threshold REAL NOT NULL,
272
+ ewma_distance REAL,
273
+ ewma_variance REAL,
274
+ sensitivity_multiplier REAL,
275
+ shifted INTEGER NOT NULL,
276
+ confidence REAL,
277
+ stash_id TEXT,
278
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
279
+ );
280
+
281
+ CREATE INDEX idx_shift_decisions_session
282
+ ON shift_decisions(project_id, session_id, created_at DESC);
283
+
284
+ CREATE INDEX idx_shift_decisions_shifted
285
+ ON shift_decisions(shifted, created_at DESC);
286
+ `
287
+ },
288
+ {
289
+ version: 10,
290
+ name: "create_project_metadata",
291
+ up: `
292
+ CREATE TABLE IF NOT EXISTS project_metadata (
293
+ project_hash TEXT PRIMARY KEY,
294
+ project_path TEXT NOT NULL,
295
+ display_name TEXT,
296
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
297
+ );
298
+ `
299
+ },
300
+ {
301
+ version: 11,
302
+ name: "add_project_hash_to_graph_tables",
303
+ up: (db) => {
304
+ const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
305
+ const columnExists = (table, column) => {
306
+ return db.prepare(`PRAGMA table_info('${table}')`).all().some((c) => c.name === column);
307
+ };
308
+ if (tableExists("graph_nodes") && !columnExists("graph_nodes", "project_hash")) {
309
+ db.exec("ALTER TABLE graph_nodes ADD COLUMN project_hash TEXT");
310
+ db.exec(`
311
+ UPDATE graph_nodes SET project_hash = (
312
+ SELECT o.project_hash FROM observations o
313
+ WHERE o.id IN (
314
+ SELECT value FROM json_each(graph_nodes.observation_ids)
315
+ )
316
+ LIMIT 1
317
+ ) WHERE project_hash IS NULL
318
+ `);
319
+ }
320
+ if (tableExists("graph_edges") && !columnExists("graph_edges", "project_hash")) {
321
+ db.exec("ALTER TABLE graph_edges ADD COLUMN project_hash TEXT");
322
+ db.exec(`
323
+ UPDATE graph_edges SET project_hash = (
324
+ SELECT gn.project_hash FROM graph_nodes gn
325
+ WHERE gn.id = graph_edges.source_id
326
+ ) WHERE project_hash IS NULL
327
+ `);
328
+ }
329
+ if (tableExists("graph_nodes")) db.exec("CREATE INDEX IF NOT EXISTS idx_graph_nodes_project ON graph_nodes(project_hash)");
330
+ if (tableExists("graph_edges")) db.exec("CREATE INDEX IF NOT EXISTS idx_graph_edges_project ON graph_edges(project_hash)");
331
+ }
332
+ },
333
+ {
334
+ version: 12,
335
+ name: "add_observation_classification",
336
+ up: `
337
+ ALTER TABLE observations ADD COLUMN classification TEXT;
338
+ ALTER TABLE observations ADD COLUMN classified_at TEXT;
339
+ CREATE INDEX idx_observations_classification
340
+ ON observations(classification) WHERE classification IS NOT NULL;
341
+ `
342
+ },
343
+ {
344
+ version: 13,
345
+ name: "create_research_buffer",
346
+ up: `
347
+ CREATE TABLE research_buffer (
348
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
349
+ project_hash TEXT NOT NULL,
350
+ session_id TEXT,
351
+ tool_name TEXT NOT NULL,
352
+ target TEXT NOT NULL,
353
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
354
+ );
355
+ CREATE INDEX idx_research_buffer_session ON research_buffer(session_id, created_at DESC);
356
+ `
357
+ },
358
+ {
359
+ version: 14,
360
+ name: "add_observation_kind",
361
+ up: (db) => {
362
+ db.exec("ALTER TABLE observations ADD COLUMN kind TEXT DEFAULT 'finding'");
363
+ db.exec(`
364
+ UPDATE observations SET kind = 'change'
365
+ WHERE source LIKE 'hook:Write' OR source LIKE 'hook:Edit'
366
+ `);
367
+ db.exec(`
368
+ UPDATE observations SET kind = 'verification'
369
+ WHERE source LIKE 'hook:Bash'
370
+ `);
371
+ db.exec(`
372
+ UPDATE observations SET kind = 'reference'
373
+ WHERE source LIKE 'hook:WebFetch' OR source LIKE 'hook:WebSearch'
374
+ `);
375
+ db.exec(`
376
+ UPDATE observations SET kind = 'finding'
377
+ WHERE source IN ('mcp:save_memory', 'manual', 'slash:remember')
378
+ AND kind = 'finding'
379
+ `);
380
+ db.exec(`
381
+ UPDATE observations
382
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
383
+ WHERE (source LIKE 'hook:Read' OR source LIKE 'hook:Glob' OR source LIKE 'hook:Grep')
384
+ AND deleted_at IS NULL
385
+ `);
386
+ db.exec("CREATE INDEX idx_observations_kind ON observations(kind)");
387
+ }
388
+ },
389
+ {
390
+ version: 15,
391
+ name: "update_graph_taxonomy",
392
+ up: (db) => {
393
+ const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
394
+ if (!tableExists("graph_nodes")) return;
395
+ db.exec("DELETE FROM graph_nodes WHERE type IN ('Tool', 'Person')");
396
+ db.exec("DELETE FROM graph_edges WHERE type IN ('uses', 'depends_on', 'decided_by', 'part_of')");
397
+ db.exec(`
398
+ CREATE TABLE graph_nodes_new (
399
+ id TEXT PRIMARY KEY,
400
+ type TEXT NOT NULL CHECK(type IN ('Project','File','Decision','Problem','Solution','Reference')),
401
+ name TEXT NOT NULL,
402
+ metadata TEXT DEFAULT '{}',
403
+ observation_ids TEXT DEFAULT '[]',
404
+ project_hash TEXT,
405
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
406
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
407
+ );
408
+
409
+ INSERT INTO graph_nodes_new SELECT * FROM graph_nodes;
410
+
411
+ DROP TABLE graph_nodes;
412
+ ALTER TABLE graph_nodes_new RENAME TO graph_nodes;
413
+
414
+ CREATE INDEX idx_graph_nodes_type ON graph_nodes(type);
415
+ CREATE INDEX idx_graph_nodes_name ON graph_nodes(name);
416
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_project ON graph_nodes(project_hash);
417
+ `);
418
+ db.exec(`
419
+ CREATE TABLE graph_edges_new (
420
+ id TEXT PRIMARY KEY,
421
+ source_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
422
+ target_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
423
+ type TEXT NOT NULL CHECK(type IN ('related_to','solved_by','caused_by','modifies','informed_by','references','verified_by','preceded_by')),
424
+ weight REAL NOT NULL DEFAULT 1.0 CHECK(weight >= 0.0 AND weight <= 1.0),
425
+ metadata TEXT DEFAULT '{}',
426
+ project_hash TEXT,
427
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
428
+ );
429
+
430
+ INSERT INTO graph_edges_new SELECT * FROM graph_edges;
431
+
432
+ DROP TABLE graph_edges;
433
+ ALTER TABLE graph_edges_new RENAME TO graph_edges;
434
+
435
+ CREATE INDEX idx_graph_edges_source ON graph_edges(source_id);
436
+ CREATE INDEX idx_graph_edges_target ON graph_edges(target_id);
437
+ CREATE INDEX idx_graph_edges_type ON graph_edges(type);
438
+ CREATE UNIQUE INDEX idx_graph_edges_unique ON graph_edges(source_id, target_id, type);
439
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_project ON graph_edges(project_hash);
440
+ `);
441
+ }
442
+ },
443
+ {
444
+ version: 16,
445
+ name: "create_tool_registry",
446
+ up: `
447
+ CREATE TABLE tool_registry (
448
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
449
+ name TEXT NOT NULL,
450
+ tool_type TEXT NOT NULL,
451
+ scope TEXT NOT NULL,
452
+ source TEXT NOT NULL,
453
+ project_hash TEXT,
454
+ description TEXT,
455
+ server_name TEXT,
456
+ usage_count INTEGER NOT NULL DEFAULT 0,
457
+ last_used_at TEXT,
458
+ discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
459
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
460
+ );
461
+
462
+ CREATE UNIQUE INDEX idx_tool_registry_name_project
463
+ ON tool_registry(name, COALESCE(project_hash, ''));
464
+ CREATE INDEX idx_tool_registry_scope
465
+ ON tool_registry(scope);
466
+ CREATE INDEX idx_tool_registry_project
467
+ ON tool_registry(project_hash) WHERE project_hash IS NOT NULL;
468
+ CREATE INDEX idx_tool_registry_usage
469
+ ON tool_registry(usage_count DESC, last_used_at DESC);
470
+ `
471
+ },
472
+ {
473
+ version: 17,
474
+ name: "create_tool_usage_events",
475
+ up: `
476
+ CREATE TABLE tool_usage_events (
477
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
478
+ tool_name TEXT NOT NULL,
479
+ session_id TEXT,
480
+ project_hash TEXT,
481
+ success INTEGER NOT NULL DEFAULT 1,
482
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
483
+ );
484
+
485
+ CREATE INDEX idx_tool_usage_events_tool
486
+ ON tool_usage_events(tool_name, created_at DESC);
487
+ CREATE INDEX idx_tool_usage_events_session
488
+ ON tool_usage_events(session_id) WHERE session_id IS NOT NULL;
489
+ CREATE INDEX idx_tool_usage_events_project_time
490
+ ON tool_usage_events(project_hash, created_at DESC);
491
+ `
492
+ },
493
+ {
494
+ version: 18,
495
+ name: "create_tool_registry_search",
496
+ up: (db) => {
497
+ db.exec(`
498
+ CREATE VIRTUAL TABLE tool_registry_fts USING fts5(
499
+ name,
500
+ description,
501
+ content='tool_registry',
502
+ content_rowid='id',
503
+ tokenize='porter unicode61'
504
+ );
505
+
506
+ -- Sync trigger: INSERT
507
+ CREATE TRIGGER tool_registry_ai AFTER INSERT ON tool_registry BEGIN
508
+ INSERT INTO tool_registry_fts(rowid, name, description)
509
+ VALUES (new.id, new.name, new.description);
510
+ END;
511
+
512
+ -- Sync trigger: UPDATE (delete old entry, insert new)
513
+ CREATE TRIGGER tool_registry_au AFTER UPDATE ON tool_registry BEGIN
514
+ INSERT INTO tool_registry_fts(tool_registry_fts, rowid, name, description)
515
+ VALUES('delete', old.id, old.name, old.description);
516
+ INSERT INTO tool_registry_fts(rowid, name, description)
517
+ VALUES (new.id, new.name, new.description);
518
+ END;
519
+
520
+ -- Sync trigger: DELETE
521
+ CREATE TRIGGER tool_registry_ad AFTER DELETE ON tool_registry BEGIN
522
+ INSERT INTO tool_registry_fts(tool_registry_fts, rowid, name, description)
523
+ VALUES('delete', old.id, old.name, old.description);
524
+ END;
525
+
526
+ -- Rebuild to index existing tool_registry rows
527
+ INSERT INTO tool_registry_fts(tool_registry_fts) VALUES('rebuild');
528
+ `);
529
+ try {
530
+ db.exec(`
531
+ CREATE VIRTUAL TABLE IF NOT EXISTS tool_registry_embeddings USING vec0(
532
+ tool_id INTEGER PRIMARY KEY,
533
+ embedding float[384] distance_metric=cosine
534
+ );
535
+ `);
536
+ } catch {}
537
+ }
538
+ },
539
+ {
540
+ version: 19,
541
+ name: "add_tool_registry_status",
542
+ up: `
543
+ ALTER TABLE tool_registry ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
544
+ CREATE INDEX idx_tool_registry_status ON tool_registry(status);
545
+ `
546
+ },
547
+ {
548
+ version: 20,
549
+ name: "create_debug_path_tables",
550
+ up: `
551
+ CREATE TABLE IF NOT EXISTS debug_paths (
552
+ id TEXT PRIMARY KEY,
553
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved', 'abandoned')),
554
+ trigger_summary TEXT NOT NULL,
555
+ resolution_summary TEXT,
556
+ kiss_summary TEXT,
557
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
558
+ resolved_at TEXT,
559
+ project_hash TEXT NOT NULL
560
+ );
561
+
562
+ CREATE TABLE IF NOT EXISTS path_waypoints (
563
+ id TEXT PRIMARY KEY,
564
+ path_id TEXT NOT NULL REFERENCES debug_paths(id) ON DELETE CASCADE,
565
+ observation_id TEXT,
566
+ waypoint_type TEXT NOT NULL CHECK(waypoint_type IN ('error', 'attempt', 'failure', 'success', 'pivot', 'revert', 'discovery', 'resolution')),
567
+ sequence_order INTEGER NOT NULL,
568
+ summary TEXT NOT NULL,
569
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
570
+ );
571
+
572
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_project_status
573
+ ON debug_paths(project_hash, status);
574
+
575
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_started
576
+ ON debug_paths(started_at DESC);
577
+
578
+ CREATE INDEX IF NOT EXISTS idx_path_waypoints_path_order
579
+ ON path_waypoints(path_id, sequence_order);
580
+ `
581
+ }
582
+ ];
583
+ /**
584
+ * Applies unapplied schema migrations in order.
585
+ *
586
+ * Creates a _migrations tracking table if it does not exist, then applies
587
+ * each migration whose version exceeds the current max applied version.
588
+ * Each migration runs inside a transaction for atomicity.
589
+ *
590
+ * Migrations 004 and 006 (vec0 tables) are only applied when hasVectorSupport
591
+ * is true. If sqlite-vec is not available, they are silently skipped and will
592
+ * be applied on a future run when the extension becomes available.
593
+ *
594
+ * @param db - An open better-sqlite3 database connection
595
+ * @param hasVectorSupport - Whether sqlite-vec loaded successfully
596
+ */
597
+ function runMigrations(db, hasVectorSupport) {
598
+ db.exec(`
599
+ CREATE TABLE IF NOT EXISTS _migrations (
600
+ version INTEGER PRIMARY KEY,
601
+ name TEXT NOT NULL,
602
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
603
+ )
604
+ `);
605
+ const maxVersion = db.prepare("SELECT COALESCE(MAX(version), 0) FROM _migrations").pluck().get();
606
+ const insertMigration = db.prepare("INSERT INTO _migrations (version, name) VALUES (?, ?)");
607
+ const applyMigration = db.transaction((m) => {
608
+ if (typeof m.up === "function") m.up(db);
609
+ else db.exec(m.up);
610
+ insertMigration.run(m.version, m.name);
611
+ });
612
+ for (const migration of MIGRATIONS) {
613
+ if (migration.version <= maxVersion) continue;
614
+ if ((migration.version === 4 || migration.version === 6) && !hasVectorSupport) continue;
615
+ applyMigration(migration);
616
+ }
617
+ }
618
+
619
+ //#endregion
620
+ //#region src/storage/database.ts
621
+ /**
622
+ * Opens a SQLite database with WAL mode, correct PRAGMA order,
623
+ * optional sqlite-vec extension loading, and schema migrations.
624
+ *
625
+ * Single connection per process by design -- better-sqlite3 is synchronous,
626
+ * so connection pooling adds zero benefit.
627
+ *
628
+ * @param config - Database path and busy timeout configuration
629
+ * @returns A configured LaminarkDatabase instance
630
+ */
631
+ function openDatabase(config) {
632
+ mkdirSync(dirname(config.dbPath), { recursive: true });
633
+ const db = new Database(config.dbPath);
634
+ const journalMode = db.pragma("journal_mode = WAL", { simple: true });
635
+ if (journalMode !== "wal") console.warn(`WARNING: WAL mode not active (got '${journalMode}'). Database may be on a read-only filesystem or otherwise restricted.`);
636
+ db.pragma(`busy_timeout = ${config.busyTimeout}`);
637
+ db.pragma("synchronous = NORMAL");
638
+ db.pragma("cache_size = -64000");
639
+ db.pragma("foreign_keys = ON");
640
+ db.pragma("temp_store = MEMORY");
641
+ db.pragma("wal_autocheckpoint = 1000");
642
+ debug("db", "PRAGMAs configured", {
643
+ journalMode,
644
+ busyTimeout: config.busyTimeout
645
+ });
646
+ let hasVectorSupport = false;
647
+ try {
648
+ sqliteVec.load(db);
649
+ hasVectorSupport = true;
650
+ } catch {}
651
+ debug("db", hasVectorSupport ? "sqlite-vec loaded" : "sqlite-vec unavailable, keyword-only mode");
652
+ runMigrations(db, hasVectorSupport);
653
+ debug("db", "Database opened", {
654
+ path: config.dbPath,
655
+ hasVectorSupport
656
+ });
657
+ return {
658
+ db,
659
+ hasVectorSupport,
660
+ close() {
661
+ try {
662
+ db.pragma("wal_checkpoint(PASSIVE)");
663
+ } catch {}
664
+ debug("db", "Database closed");
665
+ db.close();
666
+ },
667
+ checkpoint() {
668
+ db.pragma("wal_checkpoint(PASSIVE)");
669
+ }
670
+ };
671
+ }
672
+
673
+ //#endregion
674
+ //#region src/shared/types.ts
675
+ /**
676
+ * ObservationRow -- the raw database row shape.
677
+ * Uses snake_case to match SQL column names directly.
678
+ * rowid is INTEGER PRIMARY KEY AUTOINCREMENT for FTS5 content_rowid compatibility.
679
+ */
680
+ const ObservationRowSchema = z.object({
681
+ rowid: z.number(),
682
+ id: z.string(),
683
+ project_hash: z.string(),
684
+ content: z.string(),
685
+ title: z.string().nullable(),
686
+ source: z.string(),
687
+ session_id: z.string().nullable(),
688
+ embedding: z.instanceof(Buffer).nullable(),
689
+ embedding_model: z.string().nullable(),
690
+ embedding_version: z.string().nullable(),
691
+ kind: z.string().default("finding"),
692
+ classification: z.string().nullable(),
693
+ classified_at: z.string().nullable(),
694
+ created_at: z.string(),
695
+ updated_at: z.string(),
696
+ deleted_at: z.string().nullable()
697
+ });
698
+ /**
699
+ * ObservationInsert -- input for creating observations.
700
+ * Validated at runtime via Zod schema.
701
+ */
702
+ const ObservationInsertSchema = z.object({
703
+ content: z.string().min(1).max(1e5),
704
+ title: z.string().max(200).nullable().default(null),
705
+ source: z.string().default("unknown"),
706
+ kind: z.string().default("finding"),
707
+ sessionId: z.string().nullable().default(null),
708
+ embedding: z.instanceof(Float32Array).nullable().default(null),
709
+ embeddingModel: z.string().nullable().default(null),
710
+ embeddingVersion: z.string().nullable().default(null)
711
+ });
712
+ /**
713
+ * Maps a snake_case ObservationRow (from SQLite) to a camelCase Observation.
714
+ * Converts embedding Buffer to Float32Array for application use.
715
+ */
716
+ function rowToObservation(row) {
717
+ return {
718
+ rowid: row.rowid,
719
+ id: row.id,
720
+ projectHash: row.project_hash,
721
+ content: row.content,
722
+ title: row.title,
723
+ source: row.source,
724
+ sessionId: row.session_id,
725
+ kind: row.kind ?? "finding",
726
+ embedding: row.embedding ? new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4) : null,
727
+ embeddingModel: row.embedding_model,
728
+ embeddingVersion: row.embedding_version,
729
+ classification: row.classification,
730
+ classifiedAt: row.classified_at,
731
+ createdAt: row.created_at,
732
+ updatedAt: row.updated_at,
733
+ deletedAt: row.deleted_at
734
+ };
735
+ }
736
+
737
+ //#endregion
738
+ //#region src/storage/observations.ts
739
+ /**
740
+ * Repository for observation CRUD operations.
741
+ *
742
+ * Every query is scoped to the projectHash provided at construction time.
743
+ * Callers cannot accidentally query the wrong project -- project isolation
744
+ * is baked into every prepared statement.
745
+ *
746
+ * All SQL statements are prepared once in the constructor and reused for
747
+ * every call (better-sqlite3 performance best practice).
748
+ */
749
+ var ObservationRepository = class {
750
+ db;
751
+ projectHash;
752
+ stmtInsert;
753
+ stmtGetById;
754
+ stmtGetByIdIncludingDeleted;
755
+ stmtSoftDelete;
756
+ stmtRestore;
757
+ stmtCount;
758
+ constructor(db, projectHash) {
759
+ this.db = db;
760
+ this.projectHash = projectHash;
761
+ this.stmtInsert = db.prepare(`
762
+ INSERT INTO observations (id, project_hash, content, title, source, kind, session_id, embedding, embedding_model, embedding_version)
763
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
764
+ `);
765
+ this.stmtGetById = db.prepare(`
766
+ SELECT * FROM observations
767
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
768
+ `);
769
+ this.stmtGetByIdIncludingDeleted = db.prepare(`
770
+ SELECT * FROM observations
771
+ WHERE id = ? AND project_hash = ?
772
+ `);
773
+ this.stmtSoftDelete = db.prepare(`
774
+ UPDATE observations
775
+ SET deleted_at = datetime('now'), updated_at = datetime('now')
776
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
777
+ `);
778
+ this.stmtRestore = db.prepare(`
779
+ UPDATE observations
780
+ SET deleted_at = NULL, updated_at = datetime('now')
781
+ WHERE id = ? AND project_hash = ?
782
+ `);
783
+ this.stmtCount = db.prepare(`
784
+ SELECT COUNT(*) AS count FROM observations
785
+ WHERE project_hash = ? AND deleted_at IS NULL
786
+ `);
787
+ debug("obs", "ObservationRepository initialized", { projectHash });
788
+ }
789
+ /**
790
+ * Creates a new observation scoped to this repository's project.
791
+ * Validates input with Zod at runtime.
792
+ */
793
+ create(input) {
794
+ const validated = ObservationInsertSchema.parse(input);
795
+ const id = randomBytes(16).toString("hex");
796
+ const embeddingBuffer = validated.embedding ? Buffer.from(validated.embedding.buffer, validated.embedding.byteOffset, validated.embedding.byteLength) : null;
797
+ debug("obs", "Creating observation", {
798
+ source: validated.source,
799
+ contentLength: validated.content.length
800
+ });
801
+ this.stmtInsert.run(id, this.projectHash, validated.content, validated.title, validated.source, validated.kind, validated.sessionId, embeddingBuffer, validated.embeddingModel, validated.embeddingVersion);
802
+ const row = this.stmtGetById.get(id, this.projectHash);
803
+ if (!row) throw new Error("Failed to retrieve newly created observation");
804
+ debug("obs", "Observation created", { id });
805
+ return rowToObservation(row);
806
+ }
807
+ /**
808
+ * Gets an observation by ID, scoped to this project.
809
+ * Returns null if not found or soft-deleted.
810
+ */
811
+ getById(id) {
812
+ const row = this.stmtGetById.get(id, this.projectHash);
813
+ return row ? rowToObservation(row) : null;
814
+ }
815
+ /**
816
+ * Lists observations for this project, ordered by created_at DESC.
817
+ * Excludes soft-deleted observations.
818
+ */
819
+ list(options) {
820
+ debug("obs", "Listing observations", { ...options });
821
+ const limit = options?.limit ?? 50;
822
+ const offset = options?.offset ?? 0;
823
+ const includeUnclassified = options?.includeUnclassified ?? false;
824
+ let sql = "SELECT * FROM observations WHERE project_hash = ? AND deleted_at IS NULL";
825
+ const params = [this.projectHash];
826
+ if (!includeUnclassified) sql += " AND ((classification IS NOT NULL AND classification != 'noise') OR created_at >= datetime('now', '-60 seconds'))";
827
+ if (options?.kind) {
828
+ sql += " AND kind = ?";
829
+ params.push(options.kind);
830
+ }
831
+ if (options?.sessionId) {
832
+ sql += " AND session_id = ?";
833
+ params.push(options.sessionId);
834
+ }
835
+ if (options?.since) {
836
+ sql += " AND created_at >= ?";
837
+ params.push(options.since);
838
+ }
839
+ sql += " ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?";
840
+ params.push(limit, offset);
841
+ const rows = this.db.prepare(sql).all(...params);
842
+ debug("obs", "Listed observations", { count: rows.length });
843
+ return rows.map(rowToObservation);
844
+ }
845
+ /**
846
+ * Updates an observation's content, embedding fields, or both.
847
+ * Always sets updated_at to current time.
848
+ * Scoped to this project; returns null if not found or soft-deleted.
849
+ */
850
+ update(id, updates) {
851
+ debug("obs", "Updating observation", { id });
852
+ const setClauses = ["updated_at = datetime('now')"];
853
+ const params = [];
854
+ if (updates.content !== void 0) {
855
+ setClauses.push("content = ?");
856
+ params.push(updates.content);
857
+ }
858
+ if (updates.embedding !== void 0) {
859
+ setClauses.push("embedding = ?");
860
+ params.push(updates.embedding ? Buffer.from(updates.embedding.buffer, updates.embedding.byteOffset, updates.embedding.byteLength) : null);
861
+ }
862
+ if (updates.embeddingModel !== void 0) {
863
+ setClauses.push("embedding_model = ?");
864
+ params.push(updates.embeddingModel);
865
+ }
866
+ if (updates.embeddingVersion !== void 0) {
867
+ setClauses.push("embedding_version = ?");
868
+ params.push(updates.embeddingVersion);
869
+ }
870
+ params.push(id, this.projectHash);
871
+ const sql = `UPDATE observations SET ${setClauses.join(", ")} WHERE id = ? AND project_hash = ? AND deleted_at IS NULL`;
872
+ if (this.db.prepare(sql).run(...params).changes === 0) {
873
+ debug("obs", "Observation not found for update", { id });
874
+ return null;
875
+ }
876
+ debug("obs", "Observation updated", { id });
877
+ return this.getById(id);
878
+ }
879
+ /**
880
+ * Soft-deletes an observation by setting deleted_at.
881
+ * Returns true if the observation was found and deleted.
882
+ */
883
+ softDelete(id) {
884
+ debug("obs", "Soft-deleting observation", { id });
885
+ const result = this.stmtSoftDelete.run(id, this.projectHash);
886
+ debug("obs", result.changes > 0 ? "Observation soft-deleted" : "Observation not found for delete", { id });
887
+ return result.changes > 0;
888
+ }
889
+ /**
890
+ * Restores a soft-deleted observation by clearing deleted_at.
891
+ * Returns true if the observation was found and restored.
892
+ */
893
+ restore(id) {
894
+ return this.stmtRestore.run(id, this.projectHash).changes > 0;
895
+ }
896
+ /**
897
+ * Updates the classification of an observation.
898
+ * Sets classified_at to current time. Returns true if found and updated.
899
+ */
900
+ updateClassification(id, classification) {
901
+ debug("obs", "Updating classification", {
902
+ id,
903
+ classification
904
+ });
905
+ return this.db.prepare(`
906
+ UPDATE observations
907
+ SET classification = ?, classified_at = datetime('now'), updated_at = datetime('now')
908
+ WHERE id = ? AND project_hash = ? AND deleted_at IS NULL
909
+ `).run(classification, id, this.projectHash).changes > 0;
910
+ }
911
+ /**
912
+ * Creates an observation with an initial classification (bypasses classifier).
913
+ * Used for explicit user saves that should be immediately visible.
914
+ */
915
+ createClassified(input, classification) {
916
+ const obs = this.create(input);
917
+ this.updateClassification(obs.id, classification);
918
+ return this.getById(obs.id);
919
+ }
920
+ /**
921
+ * Fetches unclassified observations for the background classifier.
922
+ * Returns observations ordered by created_at ASC (oldest first).
923
+ */
924
+ listUnclassified(limit = 20) {
925
+ return this.db.prepare(`
926
+ SELECT * FROM observations
927
+ WHERE project_hash = ? AND classification IS NULL AND deleted_at IS NULL
928
+ ORDER BY created_at ASC
929
+ LIMIT ?
930
+ `).all(this.projectHash, limit).map(rowToObservation);
931
+ }
932
+ /**
933
+ * Fetches observations surrounding a given timestamp for classification context.
934
+ * Returns observations regardless of classification status.
935
+ */
936
+ listContext(aroundTime, windowSize = 5) {
937
+ const beforeRows = this.db.prepare(`
938
+ SELECT * FROM observations
939
+ WHERE project_hash = ? AND deleted_at IS NULL AND created_at <= ?
940
+ ORDER BY created_at DESC, rowid DESC
941
+ LIMIT ?
942
+ `).all(this.projectHash, aroundTime, windowSize + 1);
943
+ const afterRows = this.db.prepare(`
944
+ SELECT * FROM observations
945
+ WHERE project_hash = ? AND deleted_at IS NULL AND created_at > ?
946
+ ORDER BY created_at ASC, rowid ASC
947
+ LIMIT ?
948
+ `).all(this.projectHash, aroundTime, windowSize);
949
+ const allRows = [...beforeRows.reverse(), ...afterRows];
950
+ const seen = /* @__PURE__ */ new Set();
951
+ return allRows.filter((r) => {
952
+ if (seen.has(r.id)) return false;
953
+ seen.add(r.id);
954
+ return true;
955
+ }).map(rowToObservation);
956
+ }
957
+ /**
958
+ * Counts non-deleted observations for this project.
959
+ */
960
+ count() {
961
+ return this.stmtCount.get(this.projectHash).count;
962
+ }
963
+ /**
964
+ * Gets an observation by ID, including soft-deleted observations.
965
+ * Used by the recall tool for restore operations (must find purged items).
966
+ */
967
+ getByIdIncludingDeleted(id) {
968
+ debug("obs", "Getting observation including deleted", { id });
969
+ const row = this.stmtGetByIdIncludingDeleted.get(id, this.projectHash);
970
+ return row ? rowToObservation(row) : null;
971
+ }
972
+ /**
973
+ * Lists observations for this project, including soft-deleted ones.
974
+ * Used by recall with include_purged: true to show all items.
975
+ */
976
+ listIncludingDeleted(options) {
977
+ const limit = options?.limit ?? 50;
978
+ const offset = options?.offset ?? 0;
979
+ debug("obs", "Listing observations including deleted", {
980
+ limit,
981
+ offset
982
+ });
983
+ 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);
984
+ debug("obs", "Listed observations including deleted", { count: rows.length });
985
+ return rows.map(rowToObservation);
986
+ }
987
+ /**
988
+ * Searches observations by title substring (partial match via LIKE).
989
+ * Optionally includes soft-deleted items.
990
+ */
991
+ getByTitle(title, options) {
992
+ const limit = options?.limit ?? 20;
993
+ const includePurged = options?.includePurged ?? false;
994
+ debug("obs", "Searching by title", {
995
+ title,
996
+ limit,
997
+ includePurged
998
+ });
999
+ let sql = "SELECT * FROM observations WHERE project_hash = ? AND title LIKE ?";
1000
+ if (!includePurged) sql += " AND deleted_at IS NULL";
1001
+ sql += " AND classification IS NOT NULL AND classification != 'noise'";
1002
+ sql += " ORDER BY created_at DESC, rowid DESC LIMIT ?";
1003
+ const rows = this.db.prepare(sql).all(this.projectHash, `%${title}%`, limit);
1004
+ debug("obs", "Title search completed", { count: rows.length });
1005
+ return rows.map(rowToObservation);
1006
+ }
1007
+ };
1008
+
1009
+ //#endregion
1010
+ //#region src/storage/sessions.ts
1011
+ /**
1012
+ * Maps a snake_case SessionRow to a camelCase Session interface.
1013
+ */
1014
+ function rowToSession(row) {
1015
+ return {
1016
+ id: row.id,
1017
+ projectHash: row.project_hash,
1018
+ startedAt: row.started_at,
1019
+ endedAt: row.ended_at,
1020
+ summary: row.summary
1021
+ };
1022
+ }
1023
+ /**
1024
+ * Repository for session lifecycle management.
1025
+ *
1026
+ * Every query is scoped to the projectHash provided at construction time.
1027
+ * All SQL statements are prepared once in the constructor.
1028
+ */
1029
+ var SessionRepository = class {
1030
+ db;
1031
+ projectHash;
1032
+ stmtCreate;
1033
+ stmtGetById;
1034
+ stmtGetActive;
1035
+ constructor(db, projectHash) {
1036
+ this.db = db;
1037
+ this.projectHash = projectHash;
1038
+ this.stmtCreate = db.prepare(`
1039
+ INSERT INTO sessions (id, project_hash)
1040
+ VALUES (?, ?)
1041
+ `);
1042
+ this.stmtGetById = db.prepare(`
1043
+ SELECT * FROM sessions
1044
+ WHERE id = ? AND project_hash = ?
1045
+ `);
1046
+ this.stmtGetActive = db.prepare(`
1047
+ SELECT * FROM sessions
1048
+ WHERE ended_at IS NULL AND project_hash = ?
1049
+ ORDER BY started_at DESC
1050
+ LIMIT 1
1051
+ `);
1052
+ debug("session", "SessionRepository initialized", { projectHash });
1053
+ }
1054
+ /**
1055
+ * Creates a new session with the given ID, scoped to this project.
1056
+ */
1057
+ create(id) {
1058
+ this.stmtCreate.run(id, this.projectHash);
1059
+ const row = this.stmtGetById.get(id, this.projectHash);
1060
+ if (!row) throw new Error("Failed to retrieve newly created session");
1061
+ debug("session", "Session created", { id });
1062
+ return rowToSession(row);
1063
+ }
1064
+ /**
1065
+ * Ends a session by setting ended_at and optionally a summary.
1066
+ * Returns the updated session or null if not found.
1067
+ */
1068
+ end(id, summary) {
1069
+ const setClauses = ["ended_at = datetime('now')"];
1070
+ const params = [];
1071
+ if (summary !== void 0) {
1072
+ setClauses.push("summary = ?");
1073
+ params.push(summary);
1074
+ }
1075
+ params.push(id, this.projectHash);
1076
+ const sql = `UPDATE sessions SET ${setClauses.join(", ")} WHERE id = ? AND project_hash = ?`;
1077
+ if (this.db.prepare(sql).run(...params).changes === 0) return null;
1078
+ debug("session", "Session ended", {
1079
+ id,
1080
+ hasSummary: !!summary
1081
+ });
1082
+ return this.getById(id);
1083
+ }
1084
+ /**
1085
+ * Gets a session by ID, scoped to this project.
1086
+ */
1087
+ getById(id) {
1088
+ const row = this.stmtGetById.get(id, this.projectHash);
1089
+ return row ? rowToSession(row) : null;
1090
+ }
1091
+ /**
1092
+ * Gets the most recent sessions for this project, ordered by started_at DESC.
1093
+ */
1094
+ getLatest(limit) {
1095
+ const effectiveLimit = limit ?? 10;
1096
+ return this.db.prepare(`SELECT * FROM sessions WHERE project_hash = ? ORDER BY started_at DESC, rowid DESC LIMIT ?`).all(this.projectHash, effectiveLimit).map(rowToSession);
1097
+ }
1098
+ /**
1099
+ * Gets the currently active (not ended) session for this project.
1100
+ * Returns the most recently started active session, or null if none.
1101
+ */
1102
+ getActive() {
1103
+ const row = this.stmtGetActive.get(this.projectHash);
1104
+ return row ? rowToSession(row) : null;
1105
+ }
1106
+ /**
1107
+ * Updates the summary column on an existing session row.
1108
+ * Sets updated_at (via ended_at preservation) to track when the summary was written.
1109
+ *
1110
+ * Used by the curation module after compressing session observations.
1111
+ */
1112
+ updateSessionSummary(sessionId, summary) {
1113
+ if (this.db.prepare(`UPDATE sessions SET summary = ? WHERE id = ? AND project_hash = ?`).run(summary, sessionId, this.projectHash).changes === 0) {
1114
+ debug("session", "Session not found for summary update", { sessionId });
1115
+ return;
1116
+ }
1117
+ debug("session", "Session summary updated", {
1118
+ sessionId,
1119
+ summaryLength: summary.length
1120
+ });
1121
+ }
1122
+ };
1123
+
1124
+ //#endregion
1125
+ //#region src/storage/search.ts
1126
+ /**
1127
+ * FTS5 search engine with BM25 ranking, snippet extraction, and strict project scoping.
1128
+ *
1129
+ * All queries are scoped to the projectHash provided at construction time.
1130
+ * Queries are sanitized to prevent FTS5 syntax errors and injection.
1131
+ */
1132
+ var SearchEngine = class {
1133
+ db;
1134
+ projectHash;
1135
+ constructor(db, projectHash) {
1136
+ this.db = db;
1137
+ this.projectHash = projectHash;
1138
+ }
1139
+ /**
1140
+ * Full-text search with BM25 ranking and snippet extraction.
1141
+ *
1142
+ * bm25() returns NEGATIVE values where more negative = more relevant.
1143
+ * ORDER BY rank (ascending) puts best matches first.
1144
+ *
1145
+ * @param query - User's search query (sanitized for FTS5 safety)
1146
+ * @param options - Optional limit and sessionId filter
1147
+ * @returns SearchResult[] ordered by relevance (best match first)
1148
+ */
1149
+ searchKeyword(query, options) {
1150
+ const sanitized = this.sanitizeQuery(query);
1151
+ if (!sanitized) return [];
1152
+ const limit = options?.limit ?? 20;
1153
+ let sql = `
1154
+ SELECT
1155
+ o.*,
1156
+ bm25(observations_fts, 2.0, 1.0) AS rank,
1157
+ snippet(observations_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
1158
+ FROM observations_fts
1159
+ JOIN observations o ON o.rowid = observations_fts.rowid
1160
+ WHERE observations_fts MATCH ?
1161
+ AND o.project_hash = ?
1162
+ AND o.deleted_at IS NULL
1163
+ AND (o.classification IS NULL OR o.classification != 'noise')
1164
+ `;
1165
+ const params = [sanitized, this.projectHash];
1166
+ if (options?.sessionId) {
1167
+ sql += " AND o.session_id = ?";
1168
+ params.push(options.sessionId);
1169
+ }
1170
+ sql += " ORDER BY rank LIMIT ?";
1171
+ params.push(limit);
1172
+ const results = debugTimed("search", "FTS5 keyword search", () => {
1173
+ return this.db.prepare(sql).all(...params).map((row) => ({
1174
+ observation: rowToObservation(row),
1175
+ score: Math.abs(row.rank),
1176
+ matchType: "fts",
1177
+ snippet: row.snippet
1178
+ }));
1179
+ });
1180
+ debug("search", "Keyword search completed", {
1181
+ query: sanitized,
1182
+ resultCount: results.length
1183
+ });
1184
+ return results;
1185
+ }
1186
+ /**
1187
+ * Prefix search for autocomplete-style matching.
1188
+ * Appends `*` to each word for prefix matching.
1189
+ */
1190
+ searchByPrefix(prefix, limit) {
1191
+ const words = prefix.trim().split(/\s+/).filter(Boolean);
1192
+ if (words.length === 0) return [];
1193
+ const sanitizedWords = words.map((w) => this.sanitizeWord(w)).filter(Boolean);
1194
+ if (sanitizedWords.length === 0) return [];
1195
+ const ftsQuery = sanitizedWords.map((w) => `${w}*`).join(" ");
1196
+ const effectiveLimit = limit ?? 20;
1197
+ const sql = `
1198
+ SELECT
1199
+ o.*,
1200
+ bm25(observations_fts, 2.0, 1.0) AS rank,
1201
+ snippet(observations_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
1202
+ FROM observations_fts
1203
+ JOIN observations o ON o.rowid = observations_fts.rowid
1204
+ WHERE observations_fts MATCH ?
1205
+ AND o.project_hash = ?
1206
+ AND o.deleted_at IS NULL
1207
+ AND (o.classification IS NULL OR o.classification != 'noise')
1208
+ ORDER BY rank
1209
+ LIMIT ?
1210
+ `;
1211
+ const results = debugTimed("search", "FTS5 prefix search", () => {
1212
+ return this.db.prepare(sql).all(ftsQuery, this.projectHash, effectiveLimit).map((row) => ({
1213
+ observation: rowToObservation(row),
1214
+ score: Math.abs(row.rank),
1215
+ matchType: "fts",
1216
+ snippet: row.snippet
1217
+ }));
1218
+ });
1219
+ debug("search", "Prefix search completed", {
1220
+ prefix,
1221
+ resultCount: results.length
1222
+ });
1223
+ return results;
1224
+ }
1225
+ /**
1226
+ * Rebuild the FTS5 index if it gets out of sync.
1227
+ */
1228
+ rebuildIndex() {
1229
+ debug("search", "Rebuilding FTS5 index");
1230
+ this.db.exec("INSERT INTO observations_fts(observations_fts) VALUES('rebuild')");
1231
+ }
1232
+ /**
1233
+ * Sanitizes a user query for safe FTS5 MATCH usage.
1234
+ * Removes FTS5 operators and special characters.
1235
+ * Returns null if the query is empty after sanitization.
1236
+ */
1237
+ sanitizeQuery(query) {
1238
+ const words = query.trim().split(/\s+/).filter(Boolean);
1239
+ if (words.length === 0) return null;
1240
+ const sanitizedWords = words.map((w) => this.sanitizeWord(w)).filter(Boolean);
1241
+ if (sanitizedWords.length === 0) return null;
1242
+ return sanitizedWords.join(" ");
1243
+ }
1244
+ /**
1245
+ * Sanitizes a single word for FTS5 safety.
1246
+ * Removes quotes, parentheses, asterisks, and FTS5 operator keywords.
1247
+ */
1248
+ sanitizeWord(word) {
1249
+ let cleaned = word.replace(/["*()^{}[\]]/g, "");
1250
+ if (/^(NEAR|OR|AND|NOT)$/i.test(cleaned)) return "";
1251
+ cleaned = cleaned.replace(/[^\w\-]/g, "");
1252
+ return cleaned;
1253
+ }
1254
+ };
1255
+
1256
+ //#endregion
1257
+ //#region src/search/hybrid.ts
1258
+ /**
1259
+ * Hybrid search combining FTS5 keyword results and vec0 vector results
1260
+ * using reciprocal rank fusion (RRF).
1261
+ *
1262
+ * When both keyword and vector results are available, RRF merges the two
1263
+ * ranked lists into a single score-sorted list. When only keyword results
1264
+ * are available (worker not ready, no embeddings), falls back transparently.
1265
+ */
1266
+ /**
1267
+ * Merges multiple ranked lists into a single fused ranking using RRF.
1268
+ *
1269
+ * For each document across all lists, computes:
1270
+ * fusedScore = sum(1 / (k + rank + 1))
1271
+ * where rank is the 0-based position in each list.
1272
+ *
1273
+ * @param rankedLists - Arrays of ranked items, each with an `id` field
1274
+ * @param k - Smoothing constant (default 60, standard RRF value)
1275
+ * @returns Fused results sorted by fusedScore descending
1276
+ */
1277
+ function reciprocalRankFusion(rankedLists, k = 60) {
1278
+ const scores = /* @__PURE__ */ new Map();
1279
+ for (const list of rankedLists) for (let rank = 0; rank < list.length; rank++) {
1280
+ const item = list[rank];
1281
+ const current = scores.get(item.id) ?? 0;
1282
+ scores.set(item.id, current + 1 / (k + rank + 1));
1283
+ }
1284
+ const results = [];
1285
+ for (const [id, fusedScore] of scores) results.push({
1286
+ id,
1287
+ fusedScore
1288
+ });
1289
+ results.sort((a, b) => b.fusedScore - a.fusedScore);
1290
+ return results;
1291
+ }
1292
+ /**
1293
+ * Combines FTS5 keyword search and vec0 vector search using RRF.
1294
+ *
1295
+ * Falls back to keyword-only when:
1296
+ * - Worker is null or not ready
1297
+ * - Query embedding fails
1298
+ * - No vector results returned
1299
+ *
1300
+ * @returns SearchResult[] with matchType indicating source(s)
1301
+ */
1302
+ async function hybridSearch(params) {
1303
+ const { searchEngine, embeddingStore, worker, query, db, projectHash, options } = params;
1304
+ const limit = options?.limit ?? 20;
1305
+ return debugTimed("search", "Hybrid search", async () => {
1306
+ const keywordResults = searchEngine.searchKeyword(query, {
1307
+ limit,
1308
+ sessionId: options?.sessionId
1309
+ });
1310
+ debug("search", "Keyword results", { count: keywordResults.length });
1311
+ let vectorResults = [];
1312
+ if (worker && worker.isReady()) {
1313
+ const queryEmbedding = await worker.embed(query);
1314
+ if (queryEmbedding) {
1315
+ vectorResults = embeddingStore.search(queryEmbedding, limit * 2);
1316
+ debug("search", "Vector results", { count: vectorResults.length });
1317
+ } else debug("search", "Query embedding failed, keyword-only");
1318
+ } else debug("search", "Worker not ready, keyword-only");
1319
+ if (vectorResults.length === 0) {
1320
+ debug("search", "Returning keyword-only results", { count: keywordResults.length });
1321
+ return keywordResults;
1322
+ }
1323
+ const fused = reciprocalRankFusion([keywordResults.map((r) => ({ id: r.observation.id })), vectorResults.map((r) => ({ id: r.observationId }))]);
1324
+ const keywordMap = /* @__PURE__ */ new Map();
1325
+ for (const r of keywordResults) keywordMap.set(r.observation.id, r);
1326
+ const vectorIdSet = new Set(vectorResults.map((r) => r.observationId));
1327
+ const obsRepo = new ObservationRepository(db, projectHash);
1328
+ const merged = [];
1329
+ for (const item of fused) {
1330
+ if (merged.length >= limit) break;
1331
+ const fromKeyword = keywordMap.get(item.id);
1332
+ const fromVector = vectorIdSet.has(item.id);
1333
+ if (fromKeyword && fromVector) merged.push({
1334
+ observation: fromKeyword.observation,
1335
+ score: item.fusedScore,
1336
+ matchType: "hybrid",
1337
+ snippet: fromKeyword.snippet
1338
+ });
1339
+ else if (fromKeyword) merged.push({
1340
+ observation: fromKeyword.observation,
1341
+ score: item.fusedScore,
1342
+ matchType: "fts",
1343
+ snippet: fromKeyword.snippet
1344
+ });
1345
+ else if (fromVector) {
1346
+ const obs = obsRepo.getById(item.id);
1347
+ if (obs) {
1348
+ const snippet = (obs.content ?? "").replace(/\n/g, " ").slice(0, 100);
1349
+ merged.push({
1350
+ observation: obs,
1351
+ score: item.fusedScore,
1352
+ matchType: "vector",
1353
+ snippet
1354
+ });
1355
+ }
1356
+ }
1357
+ }
1358
+ debug("search", "Hybrid search complete", {
1359
+ keyword: keywordResults.length,
1360
+ vector: vectorResults.length,
1361
+ fused: merged.length,
1362
+ hybrid: merged.filter((r) => r.matchType === "hybrid").length
1363
+ });
1364
+ return merged;
1365
+ });
1366
+ }
1367
+
1368
+ //#endregion
1369
+ //#region src/shared/similarity.ts
1370
+ /**
1371
+ * Text similarity utilities shared across modules.
1372
+ */
1373
+ /**
1374
+ * Computes Jaccard similarity between two texts based on tokenized words.
1375
+ * Words are lowercased and split on whitespace/punctuation.
1376
+ */
1377
+ function jaccardSimilarity(textA, textB) {
1378
+ const tokenize = (t) => new Set(t.toLowerCase().split(/[\s,.!?;:'"()\[\]{}<>\/\\|@#$%^&*+=~`]+/).filter((w) => w.length > 0));
1379
+ const setA = tokenize(textA);
1380
+ const setB = tokenize(textB);
1381
+ if (setA.size === 0 && setB.size === 0) return 1;
1382
+ if (setA.size === 0 || setB.size === 0) return 0;
1383
+ let intersection = 0;
1384
+ for (const w of setA) if (setB.has(w)) intersection++;
1385
+ const union = setA.size + setB.size - intersection;
1386
+ return union === 0 ? 0 : intersection / union;
1387
+ }
1388
+
1389
+ //#endregion
1390
+ //#region src/hooks/save-guard.ts
1391
+ var SaveGuard = class {
1392
+ obsRepo;
1393
+ worker;
1394
+ embeddingStore;
1395
+ duplicateThreshold;
1396
+ vectorDistanceThreshold;
1397
+ recentWindow;
1398
+ /**
1399
+ * Construct from db + projectHash (creates internal ObservationRepository),
1400
+ * or from an existing ObservationRepository.
1401
+ */
1402
+ constructor(dbOrRepo, projectHashOrOpts, opts) {
1403
+ if (dbOrRepo instanceof ObservationRepository) {
1404
+ this.obsRepo = dbOrRepo;
1405
+ const resolvedOpts = projectHashOrOpts ?? {};
1406
+ this.worker = resolvedOpts.worker ?? null;
1407
+ this.embeddingStore = resolvedOpts.embeddingStore ?? null;
1408
+ this.duplicateThreshold = resolvedOpts.duplicateThreshold ?? .85;
1409
+ this.vectorDistanceThreshold = resolvedOpts.vectorDistanceThreshold ?? .08;
1410
+ this.recentWindow = resolvedOpts.recentWindow ?? 20;
1411
+ } else {
1412
+ this.obsRepo = new ObservationRepository(dbOrRepo, projectHashOrOpts);
1413
+ this.worker = opts?.worker ?? null;
1414
+ this.embeddingStore = opts?.embeddingStore ?? null;
1415
+ this.duplicateThreshold = opts?.duplicateThreshold ?? .85;
1416
+ this.vectorDistanceThreshold = opts?.vectorDistanceThreshold ?? .08;
1417
+ this.recentWindow = opts?.recentWindow ?? 20;
1418
+ }
1419
+ }
1420
+ /**
1421
+ * Synchronous evaluation for the hook path (text-only, no embeddings).
1422
+ * Only checks for duplicates — relevance is handled by the background classifier.
1423
+ */
1424
+ evaluateSync(content, _source) {
1425
+ const dupResult = this.checkTextDuplicates(content);
1426
+ if (dupResult) return dupResult;
1427
+ return {
1428
+ save: true,
1429
+ reason: "ok"
1430
+ };
1431
+ }
1432
+ /**
1433
+ * Async evaluation for the MCP path (embeddings + text fallback).
1434
+ * Only checks for duplicates — relevance is handled by the background classifier.
1435
+ */
1436
+ async evaluate(content, _source) {
1437
+ if (this.worker?.isReady() && this.embeddingStore) {
1438
+ const embedding = await this.worker.embed(content);
1439
+ if (embedding) {
1440
+ const results = this.embeddingStore.search(embedding, 5);
1441
+ for (const result of results) if (result.distance < this.vectorDistanceThreshold) {
1442
+ debug("save-guard", "Vector duplicate detected", {
1443
+ distance: result.distance,
1444
+ duplicateOf: result.observationId
1445
+ });
1446
+ return {
1447
+ save: false,
1448
+ reason: "duplicate",
1449
+ duplicateOf: result.observationId
1450
+ };
1451
+ }
1452
+ }
1453
+ }
1454
+ const dupResult = this.checkTextDuplicates(content);
1455
+ if (dupResult) return dupResult;
1456
+ return {
1457
+ save: true,
1458
+ reason: "ok"
1459
+ };
1460
+ }
1461
+ checkTextDuplicates(content) {
1462
+ const recent = this.obsRepo.list({
1463
+ limit: this.recentWindow,
1464
+ includeUnclassified: true
1465
+ });
1466
+ for (const obs of recent) {
1467
+ const sim = jaccardSimilarity(content, obs.content);
1468
+ if (sim >= this.duplicateThreshold) {
1469
+ debug("save-guard", "Text duplicate detected", {
1470
+ similarity: sim,
1471
+ duplicateOf: obs.id
1472
+ });
1473
+ return {
1474
+ save: false,
1475
+ reason: "duplicate",
1476
+ duplicateOf: obs.id
1477
+ };
1478
+ }
1479
+ }
1480
+ return null;
1481
+ }
1482
+ };
1483
+
1484
+ //#endregion
1485
+ //#region src/graph/migrations/001-graph-tables.ts
1486
+ /**
1487
+ * Migration 001: Create graph_nodes and graph_edges tables.
1488
+ *
1489
+ * Graph tables are managed separately from the main observation/session tables
1490
+ * because the knowledge graph is a distinct subsystem that operates
1491
+ * on extracted entities rather than raw observations.
1492
+ *
1493
+ * Tables:
1494
+ * - graph_nodes: entities with type-checked taxonomy (6 types)
1495
+ * - graph_edges: directed relationships with type-checked taxonomy (8 types),
1496
+ * weight confidence, and unique constraint on (source_id, target_id, type)
1497
+ *
1498
+ * Indexes:
1499
+ * - Nodes: type, name
1500
+ * - Edges: source_id, target_id, type, unique(source_id, target_id, type)
1501
+ */
1502
+ const up = `
1503
+ CREATE TABLE IF NOT EXISTS graph_nodes (
1504
+ id TEXT PRIMARY KEY,
1505
+ type TEXT NOT NULL CHECK(type IN ('Project','File','Decision','Problem','Solution','Reference')),
1506
+ name TEXT NOT NULL,
1507
+ metadata TEXT DEFAULT '{}',
1508
+ observation_ids TEXT DEFAULT '[]',
1509
+ project_hash TEXT,
1510
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1511
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1512
+ );
1513
+
1514
+ CREATE TABLE IF NOT EXISTS graph_edges (
1515
+ id TEXT PRIMARY KEY,
1516
+ source_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
1517
+ target_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE,
1518
+ type TEXT NOT NULL CHECK(type IN ('related_to','solved_by','caused_by','modifies','informed_by','references','verified_by','preceded_by')),
1519
+ weight REAL NOT NULL DEFAULT 1.0 CHECK(weight >= 0.0 AND weight <= 1.0),
1520
+ metadata TEXT DEFAULT '{}',
1521
+ project_hash TEXT,
1522
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1523
+ );
1524
+
1525
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_type ON graph_nodes(type);
1526
+ CREATE INDEX IF NOT EXISTS idx_graph_nodes_name ON graph_nodes(name);
1527
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_source ON graph_edges(source_id);
1528
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_target ON graph_edges(target_id);
1529
+ CREATE INDEX IF NOT EXISTS idx_graph_edges_type ON graph_edges(type);
1530
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique ON graph_edges(source_id, target_id, type);
1531
+ `;
1532
+
1533
+ //#endregion
1534
+ //#region src/graph/schema.ts
1535
+ function rowToNode(row) {
1536
+ return {
1537
+ id: row.id,
1538
+ type: row.type,
1539
+ name: row.name,
1540
+ metadata: JSON.parse(row.metadata),
1541
+ observation_ids: JSON.parse(row.observation_ids),
1542
+ created_at: row.created_at,
1543
+ updated_at: row.updated_at
1544
+ };
1545
+ }
1546
+ function rowToEdge(row) {
1547
+ return {
1548
+ id: row.id,
1549
+ source_id: row.source_id,
1550
+ target_id: row.target_id,
1551
+ type: row.type,
1552
+ weight: row.weight,
1553
+ metadata: JSON.parse(row.metadata),
1554
+ created_at: row.created_at
1555
+ };
1556
+ }
1557
+ /**
1558
+ * Initializes graph tables if they do not exist.
1559
+ * Uses CREATE TABLE IF NOT EXISTS so it is safe to call multiple times.
1560
+ */
1561
+ function initGraphSchema(db) {
1562
+ db.exec(up);
1563
+ }
1564
+ /**
1565
+ * Traverses the graph from a starting node using a recursive CTE.
1566
+ *
1567
+ * Supports directional traversal:
1568
+ * - 'outgoing': follows edges where source_id matches (default)
1569
+ * - 'incoming': follows edges where target_id matches
1570
+ * - 'both': follows edges in either direction
1571
+ *
1572
+ * Returns nodes and the edges that connect them, up to the specified depth.
1573
+ * The starting node itself is NOT included in results (depth > 0 filter).
1574
+ *
1575
+ * @param db - better-sqlite3 Database handle
1576
+ * @param nodeId - starting node ID
1577
+ * @param opts - traversal options (depth, edgeTypes, direction)
1578
+ * @returns Array of { node, edge, depth } for each reachable node
1579
+ */
1580
+ function traverseFrom(db, nodeId, opts = {}) {
1581
+ const maxDepth = opts.depth ?? 2;
1582
+ const direction = opts.direction ?? "outgoing";
1583
+ let edgeTypeFilter = "";
1584
+ if (opts.edgeTypes && opts.edgeTypes.length > 0) edgeTypeFilter = `AND e.type IN (${opts.edgeTypes.map(() => "?").join(", ")})`;
1585
+ let recursiveStep;
1586
+ if (direction === "outgoing") recursiveStep = `
1587
+ SELECT e.target_id, t.depth + 1, e.id
1588
+ FROM graph_edges e
1589
+ JOIN traverse t ON e.source_id = t.node_id
1590
+ WHERE t.depth < ?
1591
+ ${edgeTypeFilter}
1592
+ `;
1593
+ else if (direction === "incoming") recursiveStep = `
1594
+ SELECT e.source_id, t.depth + 1, e.id
1595
+ FROM graph_edges e
1596
+ JOIN traverse t ON e.target_id = t.node_id
1597
+ WHERE t.depth < ?
1598
+ ${edgeTypeFilter}
1599
+ `;
1600
+ else recursiveStep = `
1601
+ SELECT e.target_id, t.depth + 1, e.id
1602
+ FROM graph_edges e
1603
+ JOIN traverse t ON e.source_id = t.node_id
1604
+ WHERE t.depth < ?
1605
+ ${edgeTypeFilter}
1606
+ UNION ALL
1607
+ SELECT e.source_id, t.depth + 1, e.id
1608
+ FROM graph_edges e
1609
+ JOIN traverse t ON e.target_id = t.node_id
1610
+ WHERE t.depth < ?
1611
+ ${edgeTypeFilter}
1612
+ `;
1613
+ const sql = `
1614
+ WITH RECURSIVE traverse(node_id, depth, edge_id) AS (
1615
+ SELECT ?, 0, NULL
1616
+ UNION ALL
1617
+ ${recursiveStep}
1618
+ )
1619
+ SELECT DISTINCT
1620
+ n.id AS n_id, n.type AS n_type, n.name AS n_name,
1621
+ n.metadata AS n_metadata, n.observation_ids AS n_observation_ids,
1622
+ n.created_at AS n_created_at, n.updated_at AS n_updated_at,
1623
+ e.id AS e_id, e.source_id AS e_source_id, e.target_id AS e_target_id,
1624
+ e.type AS e_type, e.weight AS e_weight, e.metadata AS e_metadata,
1625
+ e.created_at AS e_created_at,
1626
+ t.depth
1627
+ FROM traverse t
1628
+ JOIN graph_nodes n ON n.id = t.node_id
1629
+ LEFT JOIN graph_edges e ON e.id = t.edge_id
1630
+ WHERE t.depth > 0
1631
+ `;
1632
+ const queryParams = [nodeId];
1633
+ if (direction === "both") {
1634
+ queryParams.push(maxDepth);
1635
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1636
+ queryParams.push(maxDepth);
1637
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1638
+ } else {
1639
+ queryParams.push(maxDepth);
1640
+ if (opts.edgeTypes) queryParams.push(...opts.edgeTypes);
1641
+ }
1642
+ return db.prepare(sql).all(...queryParams).map((row) => ({
1643
+ node: {
1644
+ id: row.n_id,
1645
+ type: row.n_type,
1646
+ name: row.n_name,
1647
+ metadata: JSON.parse(row.n_metadata),
1648
+ observation_ids: JSON.parse(row.n_observation_ids),
1649
+ created_at: row.n_created_at,
1650
+ updated_at: row.n_updated_at
1651
+ },
1652
+ edge: row.e_id ? {
1653
+ id: row.e_id,
1654
+ source_id: row.e_source_id,
1655
+ target_id: row.e_target_id,
1656
+ type: row.e_type,
1657
+ weight: row.e_weight,
1658
+ metadata: JSON.parse(row.e_metadata),
1659
+ created_at: row.e_created_at
1660
+ } : null,
1661
+ depth: row.depth
1662
+ }));
1663
+ }
1664
+ /**
1665
+ * Returns all nodes of a given entity type.
1666
+ */
1667
+ function getNodesByType(db, type) {
1668
+ return db.prepare("SELECT * FROM graph_nodes WHERE type = ?").all(type).map(rowToNode);
1669
+ }
1670
+ /**
1671
+ * Looks up a node by name and type (composite natural key).
1672
+ * Returns null if no matching node exists.
1673
+ */
1674
+ function getNodeByNameAndType(db, name, type) {
1675
+ const row = db.prepare("SELECT * FROM graph_nodes WHERE name = ? AND type = ?").get(name, type);
1676
+ return row ? rowToNode(row) : null;
1677
+ }
1678
+ /**
1679
+ * Returns edges connected to a node, filtered by direction.
1680
+ *
1681
+ * @param direction - 'outgoing' (source), 'incoming' (target), or 'both' (default: 'both')
1682
+ */
1683
+ function getEdgesForNode(db, nodeId, opts) {
1684
+ const direction = opts?.direction ?? "both";
1685
+ let sql;
1686
+ let params;
1687
+ if (direction === "outgoing") {
1688
+ sql = "SELECT * FROM graph_edges WHERE source_id = ?";
1689
+ params = [nodeId];
1690
+ } else if (direction === "incoming") {
1691
+ sql = "SELECT * FROM graph_edges WHERE target_id = ?";
1692
+ params = [nodeId];
1693
+ } else {
1694
+ sql = "SELECT * FROM graph_edges WHERE source_id = ? OR target_id = ?";
1695
+ params = [nodeId, nodeId];
1696
+ }
1697
+ return db.prepare(sql).all(...params).map(rowToEdge);
1698
+ }
1699
+ /**
1700
+ * Returns the total number of edges connected to a node (both directions).
1701
+ * Used for degree enforcement (MAX_NODE_DEGREE constraint).
1702
+ */
1703
+ function countEdgesForNode(db, nodeId) {
1704
+ return db.prepare("SELECT COUNT(*) as cnt FROM graph_edges WHERE source_id = ? OR target_id = ?").get(nodeId, nodeId).cnt;
1705
+ }
1706
+ /**
1707
+ * Inserts or updates a node by name+type composite key.
1708
+ *
1709
+ * If a node with the same name and type already exists, updates its metadata
1710
+ * and merges observation_ids. Otherwise, inserts a new node with a generated UUID.
1711
+ *
1712
+ * @returns The upserted GraphNode
1713
+ */
1714
+ function upsertNode(db, node) {
1715
+ const existing = getNodeByNameAndType(db, node.name, node.type);
1716
+ if (existing) {
1717
+ const mergedObsIds = [...new Set([...existing.observation_ids, ...node.observation_ids])];
1718
+ const mergedMetadata = {
1719
+ ...existing.metadata,
1720
+ ...node.metadata
1721
+ };
1722
+ db.prepare(`UPDATE graph_nodes
1723
+ SET metadata = ?, observation_ids = ?, updated_at = datetime('now')
1724
+ WHERE id = ?`).run(JSON.stringify(mergedMetadata), JSON.stringify(mergedObsIds), existing.id);
1725
+ return rowToNode(db.prepare("SELECT * FROM graph_nodes WHERE id = ?").get(existing.id));
1726
+ }
1727
+ const id = node.id ?? randomBytes(16).toString("hex");
1728
+ db.prepare(`INSERT INTO graph_nodes (id, type, name, metadata, observation_ids, project_hash)
1729
+ VALUES (?, ?, ?, ?, ?, ?)`).run(id, node.type, node.name, JSON.stringify(node.metadata), JSON.stringify(node.observation_ids), node.project_hash ?? null);
1730
+ return rowToNode(db.prepare("SELECT * FROM graph_nodes WHERE id = ?").get(id));
1731
+ }
1732
+ /**
1733
+ * Inserts an edge. On conflict (same source_id, target_id, type),
1734
+ * updates the weight to the maximum of existing and new values.
1735
+ *
1736
+ * @returns The inserted or updated GraphEdge
1737
+ */
1738
+ function insertEdge(db, edge) {
1739
+ const id = edge.id ?? randomBytes(16).toString("hex");
1740
+ db.prepare(`INSERT INTO graph_edges (id, source_id, target_id, type, weight, metadata, project_hash)
1741
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1742
+ ON CONFLICT (source_id, target_id, type) DO UPDATE SET
1743
+ weight = MAX(graph_edges.weight, excluded.weight),
1744
+ metadata = excluded.metadata`).run(id, edge.source_id, edge.target_id, edge.type, edge.weight, JSON.stringify(edge.metadata), edge.project_hash ?? null);
1745
+ 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));
1746
+ }
1747
+
1748
+ //#endregion
1749
+ //#region src/hooks/tool-name-parser.ts
1750
+ /**
1751
+ * Infers the tool type from a tool name seen in PostToolUse.
1752
+ *
1753
+ * - MCP tools have the `mcp__` prefix
1754
+ * - Built-in tools are PascalCase single words (Write, Edit, Bash, Read, etc.)
1755
+ * - Anything else is unknown
1756
+ */
1757
+ function inferToolType(toolName) {
1758
+ if (toolName.startsWith("mcp__")) return "mcp_tool";
1759
+ if (/^[A-Z][a-zA-Z]+$/.test(toolName)) return "builtin";
1760
+ return "unknown";
1761
+ }
1762
+ /**
1763
+ * Infers the scope of a tool from its name.
1764
+ *
1765
+ * - Plugin MCP tools (mcp__plugin_*) are plugin-scoped
1766
+ * - Other MCP tools default to project-scoped (conservative; may be global but unknown from name alone)
1767
+ * - Non-MCP tools (builtins) are always global
1768
+ */
1769
+ function inferScope(toolName) {
1770
+ if (toolName.startsWith("mcp__plugin_")) return "plugin";
1771
+ if (toolName.startsWith("mcp__")) return "project";
1772
+ return "global";
1773
+ }
1774
+ /**
1775
+ * Extracts the MCP server name from a tool name.
1776
+ *
1777
+ * Plugin MCP tools: `mcp__plugin_<pluginName>_<serverName>__<tool>`
1778
+ * Example: `mcp__plugin_laminark_laminark__recall` -> server is `laminark`
1779
+ *
1780
+ * Project MCP tools: `mcp__<serverName>__<tool>`
1781
+ * Example: `mcp__playwright__browser_screenshot` -> server is `playwright`
1782
+ *
1783
+ * Returns null for non-MCP tools.
1784
+ */
1785
+ function extractServerName(toolName) {
1786
+ const pluginMatch = toolName.match(/^mcp__plugin_([^_]+(?:_[^_]+)*)_([^_]+(?:_[^_]+)*)__/);
1787
+ if (pluginMatch) return pluginMatch[2];
1788
+ const projectMatch = toolName.match(/^mcp__([^_]+(?:_[^_]+)*)__/);
1789
+ if (projectMatch) return projectMatch[1];
1790
+ return null;
1791
+ }
1792
+
1793
+ //#endregion
1794
+ //#region src/storage/research-buffer.ts
1795
+ /**
1796
+ * Lightweight buffer for exploration tool events (Read, Glob, Grep).
1797
+ *
1798
+ * Instead of creating full observations for these low-signal tools,
1799
+ * they are stored in a temporary buffer. When a Write/Edit observation
1800
+ * is created, the recent buffer entries are attached as research context,
1801
+ * creating provenance links between exploration and changes.
1802
+ *
1803
+ * Buffer entries are flushed after 30 minutes.
1804
+ */
1805
+ var ResearchBufferRepository = class {
1806
+ db;
1807
+ projectHash;
1808
+ stmtInsert;
1809
+ stmtGetRecent;
1810
+ stmtFlush;
1811
+ constructor(db, projectHash) {
1812
+ this.db = db;
1813
+ this.projectHash = projectHash;
1814
+ this.stmtInsert = db.prepare(`
1815
+ INSERT INTO research_buffer (project_hash, session_id, tool_name, target)
1816
+ VALUES (?, ?, ?, ?)
1817
+ `);
1818
+ this.stmtGetRecent = db.prepare(`
1819
+ SELECT tool_name, target, created_at FROM research_buffer
1820
+ WHERE session_id = ? AND project_hash = ?
1821
+ AND created_at >= datetime('now', '-' || ? || ' minutes')
1822
+ ORDER BY created_at DESC
1823
+ `);
1824
+ this.stmtFlush = db.prepare(`
1825
+ DELETE FROM research_buffer
1826
+ WHERE created_at < datetime('now', '-' || ? || ' minutes')
1827
+ `);
1828
+ debug("research-buffer", "ResearchBufferRepository initialized", { projectHash });
1829
+ }
1830
+ /**
1831
+ * Records a research tool event in the buffer.
1832
+ */
1833
+ add(entry) {
1834
+ this.stmtInsert.run(this.projectHash, entry.sessionId, entry.toolName, entry.target);
1835
+ debug("research-buffer", "Buffered research event", {
1836
+ tool: entry.toolName,
1837
+ target: entry.target
1838
+ });
1839
+ }
1840
+ /**
1841
+ * Returns recent buffer entries for a session within a time window.
1842
+ */
1843
+ getRecent(sessionId, windowMinutes = 5) {
1844
+ return this.stmtGetRecent.all(sessionId, this.projectHash, windowMinutes).map((r) => ({
1845
+ toolName: r.tool_name,
1846
+ target: r.target,
1847
+ createdAt: r.created_at
1848
+ }));
1849
+ }
1850
+ /**
1851
+ * Deletes buffer entries older than the specified number of minutes.
1852
+ */
1853
+ flush(olderThanMinutes = 30) {
1854
+ const result = this.stmtFlush.run(olderThanMinutes);
1855
+ if (result.changes > 0) debug("research-buffer", "Flushed old entries", { deleted: result.changes });
1856
+ return result.changes;
1857
+ }
1858
+ };
1859
+
1860
+ //#endregion
1861
+ //#region src/storage/notifications.ts
1862
+ var NotificationStore = class {
1863
+ stmtInsert;
1864
+ stmtConsume;
1865
+ stmtSelect;
1866
+ constructor(db) {
1867
+ db.exec(`
1868
+ CREATE TABLE IF NOT EXISTS pending_notifications (
1869
+ id TEXT PRIMARY KEY,
1870
+ project_id TEXT NOT NULL,
1871
+ message TEXT NOT NULL,
1872
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1873
+ )
1874
+ `);
1875
+ this.stmtInsert = db.prepare("INSERT INTO pending_notifications (id, project_id, message) VALUES (?, ?, ?)");
1876
+ this.stmtSelect = db.prepare("SELECT * FROM pending_notifications WHERE project_id = ? ORDER BY created_at ASC LIMIT 10");
1877
+ this.stmtConsume = db.prepare("DELETE FROM pending_notifications WHERE project_id = ?");
1878
+ debug("db", "NotificationStore initialized");
1879
+ }
1880
+ add(projectId, message) {
1881
+ const id = randomBytes(16).toString("hex");
1882
+ this.stmtInsert.run(id, projectId, message);
1883
+ debug("db", "Notification added", { projectId });
1884
+ }
1885
+ /** Fetch and delete all pending notifications for a project (consume pattern). */
1886
+ consumePending(projectId) {
1887
+ const rows = this.stmtSelect.all(projectId);
1888
+ if (rows.length > 0) this.stmtConsume.run(projectId);
1889
+ return rows.map((r) => ({
1890
+ id: r.id,
1891
+ projectId: r.project_id,
1892
+ message: r.message,
1893
+ createdAt: r.created_at
1894
+ }));
1895
+ }
1896
+ };
1897
+
1898
+ //#endregion
1899
+ //#region src/paths/schema.ts
1900
+ const PATH_SCHEMA_DDL = `
1901
+ CREATE TABLE IF NOT EXISTS debug_paths (
1902
+ id TEXT PRIMARY KEY,
1903
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'resolved', 'abandoned')),
1904
+ trigger_summary TEXT NOT NULL,
1905
+ resolution_summary TEXT,
1906
+ kiss_summary TEXT,
1907
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
1908
+ resolved_at TEXT,
1909
+ project_hash TEXT NOT NULL
1910
+ );
1911
+
1912
+ CREATE TABLE IF NOT EXISTS path_waypoints (
1913
+ id TEXT PRIMARY KEY,
1914
+ path_id TEXT NOT NULL REFERENCES debug_paths(id) ON DELETE CASCADE,
1915
+ observation_id TEXT,
1916
+ waypoint_type TEXT NOT NULL CHECK(waypoint_type IN ('error', 'attempt', 'failure', 'success', 'pivot', 'revert', 'discovery', 'resolution')),
1917
+ sequence_order INTEGER NOT NULL,
1918
+ summary TEXT NOT NULL,
1919
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1920
+ );
1921
+
1922
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_project_status
1923
+ ON debug_paths(project_hash, status);
1924
+
1925
+ CREATE INDEX IF NOT EXISTS idx_debug_paths_started
1926
+ ON debug_paths(started_at DESC);
1927
+
1928
+ CREATE INDEX IF NOT EXISTS idx_path_waypoints_path_order
1929
+ ON path_waypoints(path_id, sequence_order);
1930
+ `;
1931
+ /**
1932
+ * Initializes debug path tables if they do not exist.
1933
+ * Safe to call multiple times (all statements use IF NOT EXISTS).
1934
+ */
1935
+ function initPathSchema(db) {
1936
+ db.exec(PATH_SCHEMA_DDL);
1937
+ }
1938
+
1939
+ //#endregion
1940
+ //#region src/paths/path-repository.ts
1941
+ var PathRepository = class {
1942
+ db;
1943
+ projectHash;
1944
+ stmtCreatePath;
1945
+ stmtResolvePath;
1946
+ stmtAbandonPath;
1947
+ stmtGetActivePath;
1948
+ stmtGetPath;
1949
+ stmtListPaths;
1950
+ stmtUpdateKiss;
1951
+ stmtFindRecentActive;
1952
+ stmtListByStatus;
1953
+ stmtAddWaypoint;
1954
+ stmtGetWaypoints;
1955
+ stmtCountWaypoints;
1956
+ stmtMaxSequence;
1957
+ constructor(db, projectHash) {
1958
+ this.db = db;
1959
+ this.projectHash = projectHash;
1960
+ this.stmtCreatePath = db.prepare(`
1961
+ INSERT INTO debug_paths (id, status, trigger_summary, started_at, project_hash)
1962
+ VALUES (?, 'active', ?, datetime('now'), ?)
1963
+ `);
1964
+ this.stmtResolvePath = db.prepare(`
1965
+ UPDATE debug_paths
1966
+ SET status = 'resolved', resolution_summary = ?, resolved_at = datetime('now')
1967
+ WHERE id = ? AND project_hash = ?
1968
+ `);
1969
+ this.stmtAbandonPath = db.prepare(`
1970
+ UPDATE debug_paths
1971
+ SET status = 'abandoned', resolved_at = datetime('now')
1972
+ WHERE id = ? AND project_hash = ?
1973
+ `);
1974
+ this.stmtGetActivePath = db.prepare(`
1975
+ SELECT * FROM debug_paths
1976
+ WHERE project_hash = ? AND status = 'active'
1977
+ ORDER BY started_at DESC
1978
+ LIMIT 1
1979
+ `);
1980
+ this.stmtGetPath = db.prepare(`
1981
+ SELECT * FROM debug_paths
1982
+ WHERE id = ? AND project_hash = ?
1983
+ `);
1984
+ this.stmtListPaths = db.prepare(`
1985
+ SELECT * FROM debug_paths
1986
+ WHERE project_hash = ?
1987
+ ORDER BY started_at DESC
1988
+ LIMIT ?
1989
+ `);
1990
+ this.stmtFindRecentActive = db.prepare(`
1991
+ SELECT * FROM debug_paths
1992
+ WHERE project_hash = ? AND status = 'active'
1993
+ AND started_at > datetime('now', '-24 hours')
1994
+ ORDER BY started_at DESC
1995
+ LIMIT 1
1996
+ `);
1997
+ this.stmtListByStatus = db.prepare(`
1998
+ SELECT * FROM debug_paths
1999
+ WHERE project_hash = ? AND status = ?
2000
+ ORDER BY started_at DESC
2001
+ LIMIT ?
2002
+ `);
2003
+ this.stmtUpdateKiss = db.prepare(`
2004
+ UPDATE debug_paths SET kiss_summary = ? WHERE id = ? AND project_hash = ?
2005
+ `);
2006
+ this.stmtAddWaypoint = db.prepare(`
2007
+ INSERT INTO path_waypoints (id, path_id, observation_id, waypoint_type, sequence_order, summary, created_at)
2008
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
2009
+ `);
2010
+ this.stmtGetWaypoints = db.prepare(`
2011
+ SELECT * FROM path_waypoints
2012
+ WHERE path_id = ?
2013
+ ORDER BY sequence_order ASC
2014
+ `);
2015
+ this.stmtCountWaypoints = db.prepare(`
2016
+ SELECT COUNT(*) AS count FROM path_waypoints
2017
+ WHERE path_id = ?
2018
+ `);
2019
+ this.stmtMaxSequence = db.prepare(`
2020
+ SELECT COALESCE(MAX(sequence_order), 0) AS max_seq FROM path_waypoints
2021
+ WHERE path_id = ?
2022
+ `);
2023
+ }
2024
+ /**
2025
+ * Creates a new active debug path.
2026
+ * Generates a UUID id, sets status='active' and started_at=now.
2027
+ */
2028
+ createPath(triggerSummary) {
2029
+ const id = randomBytes(16).toString("hex");
2030
+ this.stmtCreatePath.run(id, triggerSummary, this.projectHash);
2031
+ return this.getPath(id);
2032
+ }
2033
+ /**
2034
+ * Resolves a debug path with a resolution summary.
2035
+ * Sets status='resolved', resolved_at=now.
2036
+ */
2037
+ resolvePath(pathId, resolutionSummary) {
2038
+ this.stmtResolvePath.run(resolutionSummary, pathId, this.projectHash);
2039
+ }
2040
+ /**
2041
+ * Abandons a debug path.
2042
+ * Sets status='abandoned', resolved_at=now.
2043
+ */
2044
+ abandonPath(pathId) {
2045
+ this.stmtAbandonPath.run(pathId, this.projectHash);
2046
+ }
2047
+ /**
2048
+ * Returns the active path for this project (at most one active at a time).
2049
+ * Returns null if no active path exists.
2050
+ */
2051
+ getActivePath() {
2052
+ const row = this.stmtGetActivePath.get(this.projectHash);
2053
+ return row ? rowToDebugPath(row) : null;
2054
+ }
2055
+ /**
2056
+ * Gets a debug path by ID, scoped to this project.
2057
+ * Returns null if not found.
2058
+ */
2059
+ getPath(pathId) {
2060
+ const row = this.stmtGetPath.get(pathId, this.projectHash);
2061
+ return row ? rowToDebugPath(row) : null;
2062
+ }
2063
+ /**
2064
+ * Lists recent paths for this project, ordered by started_at DESC.
2065
+ * Default limit is 20.
2066
+ */
2067
+ listPaths(limit = 20) {
2068
+ return this.stmtListPaths.all(this.projectHash, limit).map(rowToDebugPath);
2069
+ }
2070
+ /**
2071
+ * Finds a recently active path (started within the last 24 hours).
2072
+ * Used for cross-session path linking — detects paths that may need
2073
+ * continuation from a prior session.
2074
+ */
2075
+ findRecentActivePath() {
2076
+ const row = this.stmtFindRecentActive.get(this.projectHash);
2077
+ return row ? rowToDebugPath(row) : null;
2078
+ }
2079
+ /**
2080
+ * Lists paths filtered by status, ordered by started_at DESC.
2081
+ * Useful for filtering to resolved/active/abandoned paths specifically.
2082
+ */
2083
+ listPathsByStatus(status, limit = 20) {
2084
+ return this.stmtListByStatus.all(this.projectHash, status, limit).map(rowToDebugPath);
2085
+ }
2086
+ /**
2087
+ * Updates the kiss_summary column for a resolved debug path.
2088
+ * Stores the full KissSummary JSON string.
2089
+ */
2090
+ updateKissSummary(pathId, kissSummary) {
2091
+ this.stmtUpdateKiss.run(kissSummary, pathId, this.projectHash);
2092
+ }
2093
+ /**
2094
+ * Adds a waypoint to a debug path.
2095
+ * Auto-increments sequence_order based on existing waypoints.
2096
+ */
2097
+ addWaypoint(pathId, type, summary, observationId) {
2098
+ const id = randomBytes(16).toString("hex");
2099
+ const { max_seq } = this.stmtMaxSequence.get(pathId);
2100
+ const sequenceOrder = max_seq + 1;
2101
+ this.stmtAddWaypoint.run(id, pathId, observationId ?? null, type, sequenceOrder, summary);
2102
+ return this.getWaypoints(pathId).find((w) => w.id === id);
2103
+ }
2104
+ /**
2105
+ * Returns all waypoints for a path, ordered by sequence_order ASC.
2106
+ */
2107
+ getWaypoints(pathId) {
2108
+ return this.stmtGetWaypoints.all(pathId).map(rowToPathWaypoint);
2109
+ }
2110
+ /**
2111
+ * Counts waypoints for a path. Used for cap enforcement (max 30 per path).
2112
+ */
2113
+ countWaypoints(pathId) {
2114
+ return this.stmtCountWaypoints.get(pathId).count;
2115
+ }
2116
+ };
2117
+ function rowToDebugPath(row) {
2118
+ return {
2119
+ id: row.id,
2120
+ status: row.status,
2121
+ trigger_summary: row.trigger_summary,
2122
+ resolution_summary: row.resolution_summary,
2123
+ kiss_summary: row.kiss_summary,
2124
+ started_at: row.started_at,
2125
+ resolved_at: row.resolved_at,
2126
+ project_hash: row.project_hash
2127
+ };
2128
+ }
2129
+ function rowToPathWaypoint(row) {
2130
+ return {
2131
+ id: row.id,
2132
+ path_id: row.path_id,
2133
+ observation_id: row.observation_id,
2134
+ waypoint_type: row.waypoint_type,
2135
+ sequence_order: row.sequence_order,
2136
+ summary: row.summary,
2137
+ created_at: row.created_at
2138
+ };
2139
+ }
2140
+
2141
+ //#endregion
2142
+ //#region src/storage/tool-registry.ts
2143
+ /**
2144
+ * Repository for tool registry CRUD operations.
2145
+ *
2146
+ * Unlike ObservationRepository, this is NOT scoped to a single project --
2147
+ * the tool registry spans all scopes (global, project, plugin) and is
2148
+ * queried cross-project for tool discovery and routing.
2149
+ *
2150
+ * All SQL statements are prepared once in the constructor and reused for
2151
+ * every call (better-sqlite3 performance best practice).
2152
+ */
2153
+ var ToolRegistryRepository = class {
2154
+ db;
2155
+ stmtUpsert;
2156
+ stmtRecordUsage;
2157
+ stmtGetByScope;
2158
+ stmtGetByName;
2159
+ stmtGetAll;
2160
+ stmtCount;
2161
+ stmtGetAvailableForSession;
2162
+ stmtInsertEvent;
2163
+ stmtGetUsageForTool;
2164
+ stmtGetUsageForSession;
2165
+ stmtGetUsageSince;
2166
+ stmtGetRecentUsage;
2167
+ stmtMarkStale;
2168
+ stmtMarkDemoted;
2169
+ stmtMarkActive;
2170
+ stmtGetConfigSourced;
2171
+ stmtGetRecentEventsForTool;
2172
+ constructor(db) {
2173
+ this.db = db;
2174
+ try {
2175
+ this.stmtUpsert = db.prepare(`
2176
+ INSERT INTO tool_registry (name, tool_type, scope, source, project_hash, description, server_name, discovered_at)
2177
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
2178
+ ON CONFLICT (name, COALESCE(project_hash, ''))
2179
+ DO UPDATE SET
2180
+ description = COALESCE(excluded.description, tool_registry.description),
2181
+ source = excluded.source,
2182
+ status = 'active',
2183
+ updated_at = datetime('now')
2184
+ `);
2185
+ this.stmtRecordUsage = db.prepare(`
2186
+ UPDATE tool_registry
2187
+ SET usage_count = usage_count + 1,
2188
+ last_used_at = datetime('now'),
2189
+ updated_at = datetime('now')
2190
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
2191
+ `);
2192
+ this.stmtGetByScope = db.prepare(`
2193
+ SELECT * FROM tool_registry
2194
+ WHERE scope = 'global' OR project_hash = ?
2195
+ ORDER BY usage_count DESC, discovered_at DESC
2196
+ `);
2197
+ this.stmtGetByName = db.prepare(`
2198
+ SELECT * FROM tool_registry
2199
+ WHERE name = ?
2200
+ ORDER BY usage_count DESC
2201
+ LIMIT 1
2202
+ `);
2203
+ this.stmtGetAll = db.prepare(`
2204
+ SELECT * FROM tool_registry
2205
+ ORDER BY usage_count DESC, discovered_at DESC
2206
+ `);
2207
+ this.stmtCount = db.prepare(`
2208
+ SELECT COUNT(*) AS count FROM tool_registry
2209
+ `);
2210
+ this.stmtGetAvailableForSession = db.prepare(`
2211
+ SELECT * FROM tool_registry
2212
+ WHERE
2213
+ scope = 'global'
2214
+ OR (scope = 'project' AND project_hash = ?)
2215
+ OR (scope = 'plugin' AND (project_hash IS NULL OR project_hash = ?))
2216
+ ORDER BY
2217
+ CASE status
2218
+ WHEN 'active' THEN 0
2219
+ WHEN 'stale' THEN 1
2220
+ WHEN 'demoted' THEN 2
2221
+ ELSE 3
2222
+ END,
2223
+ CASE tool_type
2224
+ WHEN 'mcp_server' THEN 0
2225
+ WHEN 'slash_command' THEN 1
2226
+ WHEN 'skill' THEN 2
2227
+ WHEN 'plugin' THEN 3
2228
+ ELSE 4
2229
+ END,
2230
+ usage_count DESC,
2231
+ discovered_at DESC
2232
+ `);
2233
+ this.stmtInsertEvent = db.prepare(`
2234
+ INSERT INTO tool_usage_events (tool_name, session_id, project_hash, success)
2235
+ VALUES (?, ?, ?, ?)
2236
+ `);
2237
+ this.stmtGetUsageForTool = db.prepare(`
2238
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
2239
+ FROM tool_usage_events
2240
+ WHERE tool_name = ? AND project_hash = ?
2241
+ AND created_at >= datetime('now', ?)
2242
+ GROUP BY tool_name
2243
+ `);
2244
+ this.stmtGetUsageForSession = db.prepare(`
2245
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
2246
+ FROM tool_usage_events
2247
+ WHERE session_id = ?
2248
+ GROUP BY tool_name
2249
+ ORDER BY usage_count DESC
2250
+ `);
2251
+ this.stmtGetUsageSince = db.prepare(`
2252
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
2253
+ FROM tool_usage_events
2254
+ WHERE project_hash = ?
2255
+ AND created_at >= datetime('now', ?)
2256
+ GROUP BY tool_name
2257
+ ORDER BY usage_count DESC
2258
+ `);
2259
+ this.stmtGetRecentUsage = db.prepare(`
2260
+ SELECT tool_name, COUNT(*) as usage_count, MAX(created_at) as last_used
2261
+ FROM (
2262
+ SELECT tool_name, created_at
2263
+ FROM tool_usage_events
2264
+ WHERE project_hash = ?
2265
+ ORDER BY created_at DESC
2266
+ LIMIT ?
2267
+ )
2268
+ GROUP BY tool_name
2269
+ ORDER BY usage_count DESC
2270
+ `);
2271
+ this.stmtMarkStale = db.prepare(`
2272
+ UPDATE tool_registry
2273
+ SET status = 'stale', updated_at = datetime('now')
2274
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
2275
+ AND status != 'stale'
2276
+ `);
2277
+ this.stmtMarkDemoted = db.prepare(`
2278
+ UPDATE tool_registry
2279
+ SET status = 'demoted', updated_at = datetime('now')
2280
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
2281
+ `);
2282
+ this.stmtMarkActive = db.prepare(`
2283
+ UPDATE tool_registry
2284
+ SET status = 'active', updated_at = datetime('now')
2285
+ WHERE name = ? AND COALESCE(project_hash, '') = COALESCE(?, '')
2286
+ AND status != 'active'
2287
+ `);
2288
+ this.stmtGetConfigSourced = db.prepare(`
2289
+ SELECT * FROM tool_registry
2290
+ WHERE source LIKE 'config:%'
2291
+ AND status = 'active'
2292
+ AND (project_hash = ? OR project_hash IS NULL)
2293
+ `);
2294
+ this.stmtGetRecentEventsForTool = db.prepare(`
2295
+ SELECT success FROM tool_usage_events
2296
+ WHERE tool_name = ? AND project_hash = ?
2297
+ ORDER BY created_at DESC
2298
+ LIMIT ?
2299
+ `);
2300
+ debug("tool-registry", "ToolRegistryRepository initialized");
2301
+ } catch (err) {
2302
+ throw err;
2303
+ }
2304
+ }
2305
+ /**
2306
+ * Inserts or updates a discovered tool in the registry.
2307
+ * On conflict (same name + project_hash), updates description and source.
2308
+ */
2309
+ upsert(tool) {
2310
+ try {
2311
+ this.stmtUpsert.run(tool.name, tool.toolType, tool.scope, tool.source, tool.projectHash, tool.description, tool.serverName);
2312
+ debug("tool-registry", "Upserted tool", {
2313
+ name: tool.name,
2314
+ scope: tool.scope
2315
+ });
2316
+ } catch (err) {
2317
+ debug("tool-registry", "Failed to upsert tool", {
2318
+ name: tool.name,
2319
+ error: String(err)
2320
+ });
2321
+ }
2322
+ }
2323
+ /**
2324
+ * Increments usage_count and updates last_used_at for a tool.
2325
+ * Called from organic PostToolUse discovery to track usage.
2326
+ */
2327
+ recordUsage(name, projectHash) {
2328
+ try {
2329
+ this.stmtRecordUsage.run(name, projectHash);
2330
+ debug("tool-registry", "Recorded usage", { name });
2331
+ } catch (err) {
2332
+ debug("tool-registry", "Failed to record usage", {
2333
+ name,
2334
+ error: String(err)
2335
+ });
2336
+ }
2337
+ }
2338
+ /**
2339
+ * Records usage for an existing tool, or creates it if not yet in the registry.
2340
+ * This is the entry point for organic discovery -- an upsert-and-increment-if-exists pattern.
2341
+ *
2342
+ * First tries recordUsage. If the tool is not in the registry (changes === 0),
2343
+ * calls upsert with the full tool info, which initializes it with usage_count = 0.
2344
+ */
2345
+ recordOrCreate(name, defaults, sessionId, success) {
2346
+ try {
2347
+ const result = this.stmtRecordUsage.run(name, defaults.projectHash);
2348
+ if (result.changes === 0) this.upsert({
2349
+ name,
2350
+ ...defaults
2351
+ });
2352
+ if (sessionId !== void 0) this.stmtInsertEvent.run(name, sessionId, defaults.projectHash, success === false ? 0 : 1);
2353
+ debug("tool-registry", "recordOrCreate completed", {
2354
+ name,
2355
+ created: result.changes === 0
2356
+ });
2357
+ } catch (err) {
2358
+ debug("tool-registry", "Failed recordOrCreate", {
2359
+ name,
2360
+ error: String(err)
2361
+ });
2362
+ }
2363
+ }
2364
+ /**
2365
+ * Returns global tools plus project-specific tools for the given project.
2366
+ */
2367
+ getForProject(projectHash) {
2368
+ return this.stmtGetByScope.all(projectHash);
2369
+ }
2370
+ /**
2371
+ * Returns tools available in the resolved scope for a given project.
2372
+ * Implements SCOP-01/SCOP-02/SCOP-03 scope resolution rules.
2373
+ */
2374
+ getAvailableForSession(projectHash) {
2375
+ return this.stmtGetAvailableForSession.all(projectHash, projectHash);
2376
+ }
2377
+ /**
2378
+ * Returns the top-usage entry for a given tool name.
2379
+ */
2380
+ getByName(name) {
2381
+ return this.stmtGetByName.get(name) ?? null;
2382
+ }
2383
+ /**
2384
+ * Returns all tools in the registry (for debugging/admin).
2385
+ */
2386
+ getAll() {
2387
+ return this.stmtGetAll.all();
2388
+ }
2389
+ /**
2390
+ * Returns total number of tools in the registry.
2391
+ */
2392
+ count() {
2393
+ return this.stmtCount.get().count;
2394
+ }
2395
+ /**
2396
+ * Returns usage stats for a specific tool within a time window.
2397
+ * @param timeModifier - SQLite datetime modifier, e.g., '-7 days', '-30 days'
2398
+ */
2399
+ getUsageForTool(toolName, projectHash, timeModifier = "-7 days") {
2400
+ return this.stmtGetUsageForTool.get(toolName, projectHash, timeModifier) ?? null;
2401
+ }
2402
+ /**
2403
+ * Returns per-tool usage stats for a specific session.
2404
+ */
2405
+ getUsageForSession(sessionId) {
2406
+ return this.stmtGetUsageForSession.all(sessionId);
2407
+ }
2408
+ /**
2409
+ * Returns per-tool usage stats since a time offset for a project.
2410
+ * @param timeModifier - SQLite datetime modifier, e.g., '-7 days', '-30 days'
2411
+ */
2412
+ getUsageSince(projectHash, timeModifier = "-7 days") {
2413
+ return this.stmtGetUsageSince.all(projectHash, timeModifier);
2414
+ }
2415
+ /**
2416
+ * Returns per-tool usage stats from the last N events for a project.
2417
+ * Event-count-based window instead of time-based — immune to usage gaps.
2418
+ * @param limit - Number of recent events to consider (default 200)
2419
+ */
2420
+ getRecentUsage(projectHash, limit = 200) {
2421
+ return this.stmtGetRecentUsage.all(projectHash, limit);
2422
+ }
2423
+ /**
2424
+ * Marks a tool as stale (no longer in config but still in registry).
2425
+ * Idempotent -- no-op if already stale.
2426
+ */
2427
+ markStale(name, projectHash) {
2428
+ try {
2429
+ this.stmtMarkStale.run(name, projectHash);
2430
+ debug("tool-registry", "Marked tool stale", { name });
2431
+ } catch (err) {
2432
+ debug("tool-registry", "Failed to mark tool stale", {
2433
+ name,
2434
+ error: String(err)
2435
+ });
2436
+ }
2437
+ }
2438
+ /**
2439
+ * Marks a tool as demoted (high failure rate detected).
2440
+ */
2441
+ markDemoted(name, projectHash) {
2442
+ try {
2443
+ this.stmtMarkDemoted.run(name, projectHash);
2444
+ debug("tool-registry", "Marked tool demoted", { name });
2445
+ } catch (err) {
2446
+ debug("tool-registry", "Failed to mark tool demoted", {
2447
+ name,
2448
+ error: String(err)
2449
+ });
2450
+ }
2451
+ }
2452
+ /**
2453
+ * Marks a tool as active (restored from stale/demoted).
2454
+ * Idempotent -- no-op if already active.
2455
+ */
2456
+ markActive(name, projectHash) {
2457
+ try {
2458
+ this.stmtMarkActive.run(name, projectHash);
2459
+ debug("tool-registry", "Marked tool active", { name });
2460
+ } catch (err) {
2461
+ debug("tool-registry", "Failed to mark tool active", {
2462
+ name,
2463
+ error: String(err)
2464
+ });
2465
+ }
2466
+ }
2467
+ /**
2468
+ * Returns all config-sourced active tools for a given project (or global).
2469
+ * Used by staleness detection to compare against current config state.
2470
+ */
2471
+ getConfigSourcedTools(projectHash) {
2472
+ try {
2473
+ return this.stmtGetConfigSourced.all(projectHash);
2474
+ } catch (err) {
2475
+ debug("tool-registry", "Failed to get config-sourced tools", { error: String(err) });
2476
+ return [];
2477
+ }
2478
+ }
2479
+ /**
2480
+ * Returns recent success/failure events for a specific tool.
2481
+ * Used by failure-driven demotion to check failure rate.
2482
+ * @param limit - Number of recent events to check (default 5)
2483
+ */
2484
+ getRecentEventsForTool(toolName, projectHash, limit = 5) {
2485
+ try {
2486
+ return this.stmtGetRecentEventsForTool.all(toolName, projectHash, limit);
2487
+ } catch (err) {
2488
+ debug("tool-registry", "Failed to get recent events for tool", {
2489
+ toolName,
2490
+ error: String(err)
2491
+ });
2492
+ return [];
2493
+ }
2494
+ }
2495
+ /**
2496
+ * Sanitizes a user query for safe FTS5 MATCH usage.
2497
+ * Removes FTS5 operators and special characters to prevent syntax errors.
2498
+ * Returns null if the query is empty after sanitization.
2499
+ */
2500
+ sanitizeQuery(query) {
2501
+ const words = query.trim().split(/\s+/).filter(Boolean);
2502
+ if (words.length === 0) return null;
2503
+ const sanitized = words.map((w) => {
2504
+ let cleaned = w.replace(/["*()^{}[\]]/g, "");
2505
+ if (/^(NEAR|OR|AND|NOT)$/i.test(cleaned)) return "";
2506
+ cleaned = cleaned.replace(/[^\w\-]/g, "");
2507
+ return cleaned;
2508
+ }).filter(Boolean);
2509
+ if (sanitized.length === 0) return null;
2510
+ return sanitized.join(" ");
2511
+ }
2512
+ /**
2513
+ * FTS5 keyword search on tool_registry_fts (name + description).
2514
+ * Returns ranked results using BM25 with name weighted 2x over description.
2515
+ */
2516
+ searchByKeyword(query, options) {
2517
+ const sanitized = this.sanitizeQuery(query);
2518
+ if (!sanitized) return [];
2519
+ const limit = options?.limit ?? 20;
2520
+ let sql = `
2521
+ SELECT tr.*, bm25(tool_registry_fts, 2.0, 1.0) AS rank
2522
+ FROM tool_registry_fts
2523
+ JOIN tool_registry tr ON tr.id = tool_registry_fts.rowid
2524
+ WHERE tool_registry_fts MATCH ?
2525
+ `;
2526
+ const params = [sanitized];
2527
+ if (options?.scope) {
2528
+ sql += " AND tr.scope = ?";
2529
+ params.push(options.scope);
2530
+ }
2531
+ sql += " ORDER BY rank LIMIT ?";
2532
+ params.push(limit);
2533
+ try {
2534
+ return this.db.prepare(sql).all(...params).map(({ rank, ...toolFields }) => ({
2535
+ tool: toolFields,
2536
+ score: Math.abs(rank),
2537
+ matchType: "fts"
2538
+ }));
2539
+ } catch (err) {
2540
+ debug("tool-registry", "FTS5 search failed", { error: String(err) });
2541
+ return [];
2542
+ }
2543
+ }
2544
+ /**
2545
+ * Vector similarity search on tool_registry_embeddings using vec0 KNN.
2546
+ * Returns tool IDs and distances sorted by cosine similarity.
2547
+ */
2548
+ searchByVector(queryEmbedding, options) {
2549
+ const limit = options?.limit ?? 40;
2550
+ try {
2551
+ let sql;
2552
+ const params = [queryEmbedding];
2553
+ if (options?.scope) {
2554
+ sql = `
2555
+ SELECT tre.tool_id, tre.distance
2556
+ FROM tool_registry_embeddings tre
2557
+ JOIN tool_registry tr ON tr.id = tre.tool_id
2558
+ WHERE tre.embedding MATCH ? AND tr.scope = ?
2559
+ ORDER BY tre.distance LIMIT ?
2560
+ `;
2561
+ params.push(options.scope);
2562
+ } else sql = `
2563
+ SELECT tre.tool_id, tre.distance
2564
+ FROM tool_registry_embeddings tre
2565
+ WHERE tre.embedding MATCH ?
2566
+ ORDER BY tre.distance LIMIT ?
2567
+ `;
2568
+ params.push(limit);
2569
+ return this.db.prepare(sql).all(...params);
2570
+ } catch (err) {
2571
+ debug("tool-registry", "Vector search failed", { error: String(err) });
2572
+ return [];
2573
+ }
2574
+ }
2575
+ /**
2576
+ * Hybrid search combining FTS5 keyword and vec0 vector results via
2577
+ * reciprocal rank fusion (RRF). Falls back to FTS5-only when vector
2578
+ * search is unavailable (no worker, no sqlite-vec, no embeddings).
2579
+ */
2580
+ async searchTools(query, options) {
2581
+ const limit = options?.limit ?? 20;
2582
+ const ftsResults = this.searchByKeyword(query, {
2583
+ scope: options?.scope,
2584
+ limit
2585
+ });
2586
+ let vectorResults = [];
2587
+ if (options?.worker?.isReady() && options?.hasVectorSupport) {
2588
+ const queryEmbedding = await options.worker.embed(query);
2589
+ if (queryEmbedding) vectorResults = this.searchByVector(queryEmbedding, {
2590
+ scope: options?.scope,
2591
+ limit: limit * 2
2592
+ });
2593
+ }
2594
+ if (vectorResults.length === 0) return ftsResults.slice(0, limit);
2595
+ const fused = reciprocalRankFusion([ftsResults.map((r) => ({ id: String(r.tool.id) })), vectorResults.map((r) => ({ id: String(r.tool_id) }))]);
2596
+ const ftsMap = /* @__PURE__ */ new Map();
2597
+ for (const r of ftsResults) ftsMap.set(String(r.tool.id), r);
2598
+ const vecIds = new Set(vectorResults.map((r) => String(r.tool_id)));
2599
+ const results = [];
2600
+ for (const item of fused) {
2601
+ if (results.length >= limit) break;
2602
+ const fromFts = ftsMap.get(item.id);
2603
+ const fromVec = vecIds.has(item.id);
2604
+ if (fromFts) results.push({
2605
+ tool: fromFts.tool,
2606
+ score: item.fusedScore,
2607
+ matchType: fromFts && fromVec ? "hybrid" : "fts"
2608
+ });
2609
+ else if (fromVec) {
2610
+ const toolRow = this.db.prepare("SELECT * FROM tool_registry WHERE id = ?").get(Number(item.id));
2611
+ if (toolRow) results.push({
2612
+ tool: toolRow,
2613
+ score: item.fusedScore,
2614
+ matchType: "vector"
2615
+ });
2616
+ }
2617
+ }
2618
+ return results;
2619
+ }
2620
+ /**
2621
+ * Stores an embedding vector for a tool in tool_registry_embeddings.
2622
+ * Used by the background embedding loop to index tool descriptions.
2623
+ */
2624
+ storeEmbedding(toolId, embedding) {
2625
+ try {
2626
+ this.db.prepare("INSERT OR REPLACE INTO tool_registry_embeddings(tool_id, embedding) VALUES (?, ?)").run(toolId, embedding);
2627
+ } catch (err) {
2628
+ debug("tool-registry", "Failed to store tool embedding", {
2629
+ toolId,
2630
+ error: String(err)
2631
+ });
2632
+ }
2633
+ }
2634
+ /**
2635
+ * Returns tools that have descriptions but no embedding yet.
2636
+ * Used by the background embedding loop to find work.
2637
+ */
2638
+ findUnembeddedTools(limit = 5) {
2639
+ try {
2640
+ return this.db.prepare(`
2641
+ SELECT id, name, description FROM tool_registry
2642
+ WHERE description IS NOT NULL
2643
+ AND id NOT IN (SELECT tool_id FROM tool_registry_embeddings)
2644
+ LIMIT ?
2645
+ `).all(limit);
2646
+ } catch (err) {
2647
+ debug("tool-registry", "Failed to find unembedded tools", { error: String(err) });
2648
+ return [];
2649
+ }
2650
+ }
2651
+ };
2652
+
2653
+ //#endregion
2654
+ export { rowToObservation as C, debug as D, runMigrations as E, debugTimed as O, ObservationRepository as S, MIGRATIONS as T, SaveGuard as _, ResearchBufferRepository as a, SearchEngine as b, inferToolType as c, getNodeByNameAndType as d, getNodesByType as f, upsertNode as g, traverseFrom as h, NotificationStore as i, countEdgesForNode as l, insertEdge as m, PathRepository as n, extractServerName as o, initGraphSchema as p, initPathSchema as r, inferScope as s, ToolRegistryRepository as t, getEdgesForNode as u, jaccardSimilarity as v, openDatabase as w, SessionRepository as x, hybridSearch as y };
2655
+ //# sourceMappingURL=tool-registry-CZ3mJ4iR.mjs.map