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