engrm 0.4.6 → 0.4.8
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 +42 -1
- package/dist/cli.js +532 -55
- package/dist/hooks/elicitation-result.js +248 -1
- package/dist/hooks/post-tool-use.js +286 -1
- package/dist/hooks/pre-compact.js +292 -9
- package/dist/hooks/sentinel.js +166 -0
- package/dist/hooks/session-start.js +376 -17
- package/dist/hooks/stop.js +489 -15
- package/dist/hooks/user-prompt-submit.js +1387 -0
- package/dist/server.js +1895 -48
- package/package.json +1 -1
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// src/storage/projects.ts
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
function normaliseGitRemoteUrl(remoteUrl) {
|
|
10
|
+
let url = remoteUrl.trim();
|
|
11
|
+
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
12
|
+
url = url.replace(/^[^@]+@/, "");
|
|
13
|
+
url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
|
|
14
|
+
url = url.replace(/\.git$/, "");
|
|
15
|
+
url = url.replace(/\/+$/, "");
|
|
16
|
+
const slashIndex = url.indexOf("/");
|
|
17
|
+
if (slashIndex !== -1) {
|
|
18
|
+
const host = url.substring(0, slashIndex).toLowerCase();
|
|
19
|
+
const path = url.substring(slashIndex);
|
|
20
|
+
url = host + path;
|
|
21
|
+
} else {
|
|
22
|
+
url = url.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
function projectNameFromCanonicalId(canonicalId) {
|
|
27
|
+
const parts = canonicalId.split("/");
|
|
28
|
+
return parts[parts.length - 1] ?? canonicalId;
|
|
29
|
+
}
|
|
30
|
+
function getGitRemoteUrl(directory) {
|
|
31
|
+
try {
|
|
32
|
+
const url = execSync("git remote get-url origin", {
|
|
33
|
+
cwd: directory,
|
|
34
|
+
encoding: "utf-8",
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
37
|
+
}).trim();
|
|
38
|
+
return url || null;
|
|
39
|
+
} catch {
|
|
40
|
+
try {
|
|
41
|
+
const remotes = execSync("git remote", {
|
|
42
|
+
cwd: directory,
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
timeout: 5000,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
46
|
+
}).trim().split(`
|
|
47
|
+
`).filter(Boolean);
|
|
48
|
+
if (remotes.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const url = execSync(`git remote get-url ${remotes[0]}`, {
|
|
51
|
+
cwd: directory,
|
|
52
|
+
encoding: "utf-8",
|
|
53
|
+
timeout: 5000,
|
|
54
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
55
|
+
}).trim();
|
|
56
|
+
return url || null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function readProjectConfigFile(directory) {
|
|
63
|
+
const configPath = join(directory, ".engrm.json");
|
|
64
|
+
if (!existsSync(configPath))
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
project_id: parsed["project_id"],
|
|
74
|
+
name: typeof parsed["name"] === "string" ? parsed["name"] : undefined
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function detectProject(directory) {
|
|
81
|
+
const remoteUrl = getGitRemoteUrl(directory);
|
|
82
|
+
if (remoteUrl) {
|
|
83
|
+
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
84
|
+
return {
|
|
85
|
+
canonical_id: canonicalId,
|
|
86
|
+
name: projectNameFromCanonicalId(canonicalId),
|
|
87
|
+
remote_url: remoteUrl,
|
|
88
|
+
local_path: directory
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const configFile = readProjectConfigFile(directory);
|
|
92
|
+
if (configFile) {
|
|
93
|
+
return {
|
|
94
|
+
canonical_id: configFile.project_id,
|
|
95
|
+
name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
|
|
96
|
+
remote_url: null,
|
|
97
|
+
local_path: directory
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const dirName = basename(directory);
|
|
101
|
+
return {
|
|
102
|
+
canonical_id: `local/${dirName}`,
|
|
103
|
+
name: dirName,
|
|
104
|
+
remote_url: null,
|
|
105
|
+
local_path: directory
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/config.ts
|
|
110
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
111
|
+
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
112
|
+
import { join as join2 } from "node:path";
|
|
113
|
+
import { createHash } from "node:crypto";
|
|
114
|
+
var CONFIG_DIR = join2(homedir(), ".engrm");
|
|
115
|
+
var SETTINGS_PATH = join2(CONFIG_DIR, "settings.json");
|
|
116
|
+
var DB_PATH = join2(CONFIG_DIR, "engrm.db");
|
|
117
|
+
function getDbPath() {
|
|
118
|
+
return DB_PATH;
|
|
119
|
+
}
|
|
120
|
+
function generateDeviceId() {
|
|
121
|
+
const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
122
|
+
let mac = "";
|
|
123
|
+
const ifaces = networkInterfaces();
|
|
124
|
+
for (const entries of Object.values(ifaces)) {
|
|
125
|
+
if (!entries)
|
|
126
|
+
continue;
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
|
|
129
|
+
mac = entry.mac;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (mac)
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const material = `${host}:${mac || "no-mac"}`;
|
|
137
|
+
const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
|
|
138
|
+
return `${host}-${suffix}`;
|
|
139
|
+
}
|
|
140
|
+
function createDefaultConfig() {
|
|
141
|
+
return {
|
|
142
|
+
candengo_url: "",
|
|
143
|
+
candengo_api_key: "",
|
|
144
|
+
site_id: "",
|
|
145
|
+
namespace: "",
|
|
146
|
+
user_id: "",
|
|
147
|
+
user_email: "",
|
|
148
|
+
device_id: generateDeviceId(),
|
|
149
|
+
teams: [],
|
|
150
|
+
sync: {
|
|
151
|
+
enabled: true,
|
|
152
|
+
interval_seconds: 30,
|
|
153
|
+
batch_size: 50
|
|
154
|
+
},
|
|
155
|
+
search: {
|
|
156
|
+
default_limit: 10,
|
|
157
|
+
local_boost: 1.2,
|
|
158
|
+
scope: "all"
|
|
159
|
+
},
|
|
160
|
+
scrubbing: {
|
|
161
|
+
enabled: true,
|
|
162
|
+
custom_patterns: [],
|
|
163
|
+
default_sensitivity: "shared"
|
|
164
|
+
},
|
|
165
|
+
sentinel: {
|
|
166
|
+
enabled: false,
|
|
167
|
+
mode: "advisory",
|
|
168
|
+
provider: "openai",
|
|
169
|
+
model: "gpt-4o-mini",
|
|
170
|
+
api_key: "",
|
|
171
|
+
base_url: "",
|
|
172
|
+
skip_patterns: [],
|
|
173
|
+
daily_limit: 100,
|
|
174
|
+
tier: "free"
|
|
175
|
+
},
|
|
176
|
+
observer: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
mode: "per_event",
|
|
179
|
+
model: "sonnet"
|
|
180
|
+
},
|
|
181
|
+
transcript_analysis: {
|
|
182
|
+
enabled: false
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function loadConfig() {
|
|
187
|
+
if (!existsSync2(SETTINGS_PATH)) {
|
|
188
|
+
throw new Error(`Config not found at ${SETTINGS_PATH}. Run 'engrm init --manual' to configure.`);
|
|
189
|
+
}
|
|
190
|
+
const raw = readFileSync2(SETTINGS_PATH, "utf-8");
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`Invalid JSON in ${SETTINGS_PATH}`);
|
|
196
|
+
}
|
|
197
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
198
|
+
throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
|
|
199
|
+
}
|
|
200
|
+
const config = parsed;
|
|
201
|
+
const defaults = createDefaultConfig();
|
|
202
|
+
return {
|
|
203
|
+
candengo_url: asString(config["candengo_url"], defaults.candengo_url),
|
|
204
|
+
candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
|
|
205
|
+
site_id: asString(config["site_id"], defaults.site_id),
|
|
206
|
+
namespace: asString(config["namespace"], defaults.namespace),
|
|
207
|
+
user_id: asString(config["user_id"], defaults.user_id),
|
|
208
|
+
user_email: asString(config["user_email"], defaults.user_email),
|
|
209
|
+
device_id: asString(config["device_id"], defaults.device_id),
|
|
210
|
+
teams: asTeams(config["teams"], defaults.teams),
|
|
211
|
+
sync: {
|
|
212
|
+
enabled: asBool(config["sync"]?.["enabled"], defaults.sync.enabled),
|
|
213
|
+
interval_seconds: asNumber(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
|
|
214
|
+
batch_size: asNumber(config["sync"]?.["batch_size"], defaults.sync.batch_size)
|
|
215
|
+
},
|
|
216
|
+
search: {
|
|
217
|
+
default_limit: asNumber(config["search"]?.["default_limit"], defaults.search.default_limit),
|
|
218
|
+
local_boost: asNumber(config["search"]?.["local_boost"], defaults.search.local_boost),
|
|
219
|
+
scope: asScope(config["search"]?.["scope"], defaults.search.scope)
|
|
220
|
+
},
|
|
221
|
+
scrubbing: {
|
|
222
|
+
enabled: asBool(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
|
|
223
|
+
custom_patterns: asStringArray(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
|
|
224
|
+
default_sensitivity: asSensitivity(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
|
|
225
|
+
},
|
|
226
|
+
sentinel: {
|
|
227
|
+
enabled: asBool(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
|
|
228
|
+
mode: asSentinelMode(config["sentinel"]?.["mode"], defaults.sentinel.mode),
|
|
229
|
+
provider: asLlmProvider(config["sentinel"]?.["provider"], defaults.sentinel.provider),
|
|
230
|
+
model: asString(config["sentinel"]?.["model"], defaults.sentinel.model),
|
|
231
|
+
api_key: asString(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
|
|
232
|
+
base_url: asString(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
|
|
233
|
+
skip_patterns: asStringArray(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
|
|
234
|
+
daily_limit: asNumber(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
|
|
235
|
+
tier: asTier(config["sentinel"]?.["tier"], defaults.sentinel.tier)
|
|
236
|
+
},
|
|
237
|
+
observer: {
|
|
238
|
+
enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
|
|
239
|
+
mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
|
|
240
|
+
model: asString(config["observer"]?.["model"], defaults.observer.model)
|
|
241
|
+
},
|
|
242
|
+
transcript_analysis: {
|
|
243
|
+
enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function saveConfig(config) {
|
|
248
|
+
if (!existsSync2(CONFIG_DIR)) {
|
|
249
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
250
|
+
}
|
|
251
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(config, null, 2) + `
|
|
252
|
+
`, "utf-8");
|
|
253
|
+
}
|
|
254
|
+
function configExists() {
|
|
255
|
+
return existsSync2(SETTINGS_PATH);
|
|
256
|
+
}
|
|
257
|
+
function asString(value, fallback) {
|
|
258
|
+
return typeof value === "string" ? value : fallback;
|
|
259
|
+
}
|
|
260
|
+
function asNumber(value, fallback) {
|
|
261
|
+
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
|
|
262
|
+
}
|
|
263
|
+
function asBool(value, fallback) {
|
|
264
|
+
return typeof value === "boolean" ? value : fallback;
|
|
265
|
+
}
|
|
266
|
+
function asStringArray(value, fallback) {
|
|
267
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
|
|
268
|
+
}
|
|
269
|
+
function asScope(value, fallback) {
|
|
270
|
+
if (value === "personal" || value === "team" || value === "all")
|
|
271
|
+
return value;
|
|
272
|
+
return fallback;
|
|
273
|
+
}
|
|
274
|
+
function asSensitivity(value, fallback) {
|
|
275
|
+
if (value === "shared" || value === "personal" || value === "secret")
|
|
276
|
+
return value;
|
|
277
|
+
return fallback;
|
|
278
|
+
}
|
|
279
|
+
function asSentinelMode(value, fallback) {
|
|
280
|
+
if (value === "advisory" || value === "blocking")
|
|
281
|
+
return value;
|
|
282
|
+
return fallback;
|
|
283
|
+
}
|
|
284
|
+
function asLlmProvider(value, fallback) {
|
|
285
|
+
if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
|
|
286
|
+
return value;
|
|
287
|
+
return fallback;
|
|
288
|
+
}
|
|
289
|
+
function asTier(value, fallback) {
|
|
290
|
+
if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
|
|
291
|
+
return value;
|
|
292
|
+
return fallback;
|
|
293
|
+
}
|
|
294
|
+
function asObserverMode(value, fallback) {
|
|
295
|
+
if (value === "per_event" || value === "per_session")
|
|
296
|
+
return value;
|
|
297
|
+
return fallback;
|
|
298
|
+
}
|
|
299
|
+
function asTeams(value, fallback) {
|
|
300
|
+
if (!Array.isArray(value))
|
|
301
|
+
return fallback;
|
|
302
|
+
return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/storage/migrations.ts
|
|
306
|
+
var MIGRATIONS = [
|
|
307
|
+
{
|
|
308
|
+
version: 1,
|
|
309
|
+
description: "Initial schema: projects, observations, sessions, sync, FTS5",
|
|
310
|
+
sql: `
|
|
311
|
+
-- Projects (canonical identity across machines)
|
|
312
|
+
CREATE TABLE projects (
|
|
313
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
314
|
+
canonical_id TEXT UNIQUE NOT NULL,
|
|
315
|
+
name TEXT NOT NULL,
|
|
316
|
+
local_path TEXT,
|
|
317
|
+
remote_url TEXT,
|
|
318
|
+
first_seen_epoch INTEGER NOT NULL,
|
|
319
|
+
last_active_epoch INTEGER NOT NULL
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
-- Core observations table
|
|
323
|
+
CREATE TABLE observations (
|
|
324
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
325
|
+
session_id TEXT,
|
|
326
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
327
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
328
|
+
'bugfix', 'discovery', 'decision', 'pattern',
|
|
329
|
+
'change', 'feature', 'refactor', 'digest'
|
|
330
|
+
)),
|
|
331
|
+
title TEXT NOT NULL,
|
|
332
|
+
narrative TEXT,
|
|
333
|
+
facts TEXT,
|
|
334
|
+
concepts TEXT,
|
|
335
|
+
files_read TEXT,
|
|
336
|
+
files_modified TEXT,
|
|
337
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
338
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
|
|
339
|
+
'active', 'aging', 'archived', 'purged', 'pinned'
|
|
340
|
+
)),
|
|
341
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
|
|
342
|
+
'shared', 'personal', 'secret'
|
|
343
|
+
)),
|
|
344
|
+
user_id TEXT NOT NULL,
|
|
345
|
+
device_id TEXT NOT NULL,
|
|
346
|
+
agent TEXT DEFAULT 'claude-code',
|
|
347
|
+
created_at TEXT NOT NULL,
|
|
348
|
+
created_at_epoch INTEGER NOT NULL,
|
|
349
|
+
archived_at_epoch INTEGER,
|
|
350
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
-- Session tracking
|
|
354
|
+
CREATE TABLE sessions (
|
|
355
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
356
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
357
|
+
project_id INTEGER REFERENCES projects(id),
|
|
358
|
+
user_id TEXT NOT NULL,
|
|
359
|
+
device_id TEXT NOT NULL,
|
|
360
|
+
agent TEXT DEFAULT 'claude-code',
|
|
361
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
|
|
362
|
+
observation_count INTEGER DEFAULT 0,
|
|
363
|
+
started_at_epoch INTEGER,
|
|
364
|
+
completed_at_epoch INTEGER
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
-- Session summaries (generated on Stop hook)
|
|
368
|
+
CREATE TABLE session_summaries (
|
|
369
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
370
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
371
|
+
project_id INTEGER REFERENCES projects(id),
|
|
372
|
+
user_id TEXT NOT NULL,
|
|
373
|
+
request TEXT,
|
|
374
|
+
investigated TEXT,
|
|
375
|
+
learned TEXT,
|
|
376
|
+
completed TEXT,
|
|
377
|
+
next_steps TEXT,
|
|
378
|
+
created_at_epoch INTEGER
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
-- Sync outbox (offline-first queue)
|
|
382
|
+
CREATE TABLE sync_outbox (
|
|
383
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
384
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
|
|
385
|
+
record_id INTEGER NOT NULL,
|
|
386
|
+
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
387
|
+
'pending', 'syncing', 'synced', 'failed'
|
|
388
|
+
)),
|
|
389
|
+
retry_count INTEGER DEFAULT 0,
|
|
390
|
+
max_retries INTEGER DEFAULT 10,
|
|
391
|
+
last_error TEXT,
|
|
392
|
+
created_at_epoch INTEGER NOT NULL,
|
|
393
|
+
synced_at_epoch INTEGER,
|
|
394
|
+
next_retry_epoch INTEGER
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
-- Sync high-water mark and lifecycle job tracking
|
|
398
|
+
CREATE TABLE sync_state (
|
|
399
|
+
key TEXT PRIMARY KEY,
|
|
400
|
+
value TEXT NOT NULL
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
-- FTS5 for local offline search (external content mode)
|
|
404
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
405
|
+
title, narrative, facts, concepts,
|
|
406
|
+
content=observations,
|
|
407
|
+
content_rowid=id
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
-- Indexes: observations
|
|
411
|
+
CREATE INDEX idx_observations_project ON observations(project_id);
|
|
412
|
+
CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
|
|
413
|
+
CREATE INDEX idx_observations_type ON observations(type);
|
|
414
|
+
CREATE INDEX idx_observations_created ON observations(created_at_epoch);
|
|
415
|
+
CREATE INDEX idx_observations_session ON observations(session_id);
|
|
416
|
+
CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
|
|
417
|
+
CREATE INDEX idx_observations_quality ON observations(quality);
|
|
418
|
+
CREATE INDEX idx_observations_user ON observations(user_id);
|
|
419
|
+
|
|
420
|
+
-- Indexes: sessions
|
|
421
|
+
CREATE INDEX idx_sessions_project ON sessions(project_id);
|
|
422
|
+
|
|
423
|
+
-- Indexes: sync outbox
|
|
424
|
+
CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
|
|
425
|
+
CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
|
|
426
|
+
`
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
version: 2,
|
|
430
|
+
description: "Add superseded_by for knowledge supersession",
|
|
431
|
+
sql: `
|
|
432
|
+
ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
|
|
433
|
+
CREATE INDEX idx_observations_superseded ON observations(superseded_by);
|
|
434
|
+
`
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
version: 3,
|
|
438
|
+
description: "Add remote_source_id for pull deduplication",
|
|
439
|
+
sql: `
|
|
440
|
+
ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
|
|
441
|
+
CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
442
|
+
`
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
version: 4,
|
|
446
|
+
description: "Add sqlite-vec for local semantic search",
|
|
447
|
+
sql: `
|
|
448
|
+
CREATE VIRTUAL TABLE vec_observations USING vec0(
|
|
449
|
+
observation_id INTEGER PRIMARY KEY,
|
|
450
|
+
embedding float[384]
|
|
451
|
+
);
|
|
452
|
+
`,
|
|
453
|
+
condition: (db) => isVecExtensionLoaded(db)
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
version: 5,
|
|
457
|
+
description: "Session metrics and security findings",
|
|
458
|
+
sql: `
|
|
459
|
+
ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
|
|
460
|
+
ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
|
|
461
|
+
ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
|
|
462
|
+
|
|
463
|
+
CREATE TABLE security_findings (
|
|
464
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
465
|
+
session_id TEXT,
|
|
466
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
467
|
+
finding_type TEXT NOT NULL,
|
|
468
|
+
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
|
|
469
|
+
pattern_name TEXT NOT NULL,
|
|
470
|
+
file_path TEXT,
|
|
471
|
+
snippet TEXT,
|
|
472
|
+
tool_name TEXT,
|
|
473
|
+
user_id TEXT NOT NULL,
|
|
474
|
+
device_id TEXT NOT NULL,
|
|
475
|
+
created_at_epoch INTEGER NOT NULL
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
CREATE INDEX idx_security_findings_session ON security_findings(session_id);
|
|
479
|
+
CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
|
|
480
|
+
CREATE INDEX idx_security_findings_severity ON security_findings(severity);
|
|
481
|
+
`
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
version: 6,
|
|
485
|
+
description: "Add risk_score, expand observation types to include standard",
|
|
486
|
+
sql: `
|
|
487
|
+
ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
|
|
488
|
+
|
|
489
|
+
-- Recreate observations table with expanded type CHECK to include 'standard'
|
|
490
|
+
-- SQLite doesn't support ALTER CHECK, so we recreate the table
|
|
491
|
+
CREATE TABLE observations_new (
|
|
492
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
493
|
+
session_id TEXT,
|
|
494
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
495
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
496
|
+
'bugfix', 'discovery', 'decision', 'pattern',
|
|
497
|
+
'change', 'feature', 'refactor', 'digest', 'standard'
|
|
498
|
+
)),
|
|
499
|
+
title TEXT NOT NULL,
|
|
500
|
+
narrative TEXT,
|
|
501
|
+
facts TEXT,
|
|
502
|
+
concepts TEXT,
|
|
503
|
+
files_read TEXT,
|
|
504
|
+
files_modified TEXT,
|
|
505
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
506
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
|
|
507
|
+
'active', 'aging', 'archived', 'purged', 'pinned'
|
|
508
|
+
)),
|
|
509
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
|
|
510
|
+
'shared', 'personal', 'secret'
|
|
511
|
+
)),
|
|
512
|
+
user_id TEXT NOT NULL,
|
|
513
|
+
device_id TEXT NOT NULL,
|
|
514
|
+
agent TEXT DEFAULT 'claude-code',
|
|
515
|
+
created_at TEXT NOT NULL,
|
|
516
|
+
created_at_epoch INTEGER NOT NULL,
|
|
517
|
+
archived_at_epoch INTEGER,
|
|
518
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
519
|
+
superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
520
|
+
remote_source_id TEXT
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
INSERT INTO observations_new SELECT * FROM observations;
|
|
524
|
+
|
|
525
|
+
DROP TABLE observations;
|
|
526
|
+
ALTER TABLE observations_new RENAME TO observations;
|
|
527
|
+
|
|
528
|
+
-- Recreate indexes
|
|
529
|
+
CREATE INDEX idx_observations_project ON observations(project_id);
|
|
530
|
+
CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
|
|
531
|
+
CREATE INDEX idx_observations_type ON observations(type);
|
|
532
|
+
CREATE INDEX idx_observations_created ON observations(created_at_epoch);
|
|
533
|
+
CREATE INDEX idx_observations_session ON observations(session_id);
|
|
534
|
+
CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
|
|
535
|
+
CREATE INDEX idx_observations_quality ON observations(quality);
|
|
536
|
+
CREATE INDEX idx_observations_user ON observations(user_id);
|
|
537
|
+
CREATE INDEX idx_observations_superseded ON observations(superseded_by);
|
|
538
|
+
CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
539
|
+
|
|
540
|
+
-- Recreate FTS5 (external content mode — must rebuild after table recreation)
|
|
541
|
+
DROP TABLE IF EXISTS observations_fts;
|
|
542
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
543
|
+
title, narrative, facts, concepts,
|
|
544
|
+
content=observations,
|
|
545
|
+
content_rowid=id
|
|
546
|
+
);
|
|
547
|
+
-- Rebuild FTS index
|
|
548
|
+
INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
|
|
549
|
+
`
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
version: 7,
|
|
553
|
+
description: "Add packs_installed table for help pack tracking",
|
|
554
|
+
sql: `
|
|
555
|
+
CREATE TABLE IF NOT EXISTS packs_installed (
|
|
556
|
+
name TEXT PRIMARY KEY,
|
|
557
|
+
installed_at INTEGER NOT NULL,
|
|
558
|
+
observation_count INTEGER DEFAULT 0
|
|
559
|
+
);
|
|
560
|
+
`
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
version: 8,
|
|
564
|
+
description: "Add message type to observations CHECK constraint",
|
|
565
|
+
sql: `
|
|
566
|
+
CREATE TABLE IF NOT EXISTS observations_v8 (
|
|
567
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
568
|
+
session_id TEXT,
|
|
569
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
570
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
571
|
+
'bugfix', 'discovery', 'decision', 'pattern',
|
|
572
|
+
'change', 'feature', 'refactor', 'digest', 'standard', 'message'
|
|
573
|
+
)),
|
|
574
|
+
title TEXT NOT NULL,
|
|
575
|
+
narrative TEXT,
|
|
576
|
+
facts TEXT,
|
|
577
|
+
concepts TEXT,
|
|
578
|
+
files_read TEXT,
|
|
579
|
+
files_modified TEXT,
|
|
580
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
581
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
|
|
582
|
+
'active', 'aging', 'archived', 'purged', 'pinned'
|
|
583
|
+
)),
|
|
584
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
|
|
585
|
+
'shared', 'personal', 'secret'
|
|
586
|
+
)),
|
|
587
|
+
user_id TEXT NOT NULL,
|
|
588
|
+
device_id TEXT NOT NULL,
|
|
589
|
+
agent TEXT DEFAULT 'claude-code',
|
|
590
|
+
created_at TEXT NOT NULL,
|
|
591
|
+
created_at_epoch INTEGER NOT NULL,
|
|
592
|
+
archived_at_epoch INTEGER,
|
|
593
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
594
|
+
superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
595
|
+
remote_source_id TEXT
|
|
596
|
+
);
|
|
597
|
+
INSERT INTO observations_v8 SELECT * FROM observations;
|
|
598
|
+
DROP TABLE observations;
|
|
599
|
+
ALTER TABLE observations_v8 RENAME TO observations;
|
|
600
|
+
CREATE INDEX idx_observations_project ON observations(project_id);
|
|
601
|
+
CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
|
|
602
|
+
CREATE INDEX idx_observations_type ON observations(type);
|
|
603
|
+
CREATE INDEX idx_observations_created ON observations(created_at_epoch);
|
|
604
|
+
CREATE INDEX idx_observations_session ON observations(session_id);
|
|
605
|
+
CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
|
|
606
|
+
CREATE INDEX idx_observations_quality ON observations(quality);
|
|
607
|
+
CREATE INDEX idx_observations_user ON observations(user_id);
|
|
608
|
+
CREATE INDEX idx_observations_superseded ON observations(superseded_by);
|
|
609
|
+
CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
610
|
+
DROP TABLE IF EXISTS observations_fts;
|
|
611
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
612
|
+
title, narrative, facts, concepts,
|
|
613
|
+
content=observations,
|
|
614
|
+
content_rowid=id
|
|
615
|
+
);
|
|
616
|
+
INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
|
|
617
|
+
`
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
version: 9,
|
|
621
|
+
description: "Add first-class user prompt capture",
|
|
622
|
+
sql: `
|
|
623
|
+
CREATE TABLE IF NOT EXISTS user_prompts (
|
|
624
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
625
|
+
session_id TEXT NOT NULL,
|
|
626
|
+
project_id INTEGER REFERENCES projects(id),
|
|
627
|
+
prompt_number INTEGER NOT NULL,
|
|
628
|
+
prompt TEXT NOT NULL,
|
|
629
|
+
prompt_hash TEXT NOT NULL,
|
|
630
|
+
cwd TEXT,
|
|
631
|
+
user_id TEXT NOT NULL,
|
|
632
|
+
device_id TEXT NOT NULL,
|
|
633
|
+
agent TEXT DEFAULT 'claude-code',
|
|
634
|
+
created_at_epoch INTEGER NOT NULL,
|
|
635
|
+
UNIQUE(session_id, prompt_number)
|
|
636
|
+
);
|
|
637
|
+
|
|
638
|
+
CREATE INDEX IF NOT EXISTS idx_user_prompts_session
|
|
639
|
+
ON user_prompts(session_id, prompt_number DESC);
|
|
640
|
+
CREATE INDEX IF NOT EXISTS idx_user_prompts_project
|
|
641
|
+
ON user_prompts(project_id, created_at_epoch DESC);
|
|
642
|
+
CREATE INDEX IF NOT EXISTS idx_user_prompts_created
|
|
643
|
+
ON user_prompts(created_at_epoch DESC);
|
|
644
|
+
CREATE INDEX IF NOT EXISTS idx_user_prompts_hash
|
|
645
|
+
ON user_prompts(prompt_hash);
|
|
646
|
+
`
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
version: 10,
|
|
650
|
+
description: "Add first-class tool event chronology",
|
|
651
|
+
sql: `
|
|
652
|
+
CREATE TABLE IF NOT EXISTS tool_events (
|
|
653
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
654
|
+
session_id TEXT NOT NULL,
|
|
655
|
+
project_id INTEGER REFERENCES projects(id),
|
|
656
|
+
tool_name TEXT NOT NULL,
|
|
657
|
+
tool_input_json TEXT,
|
|
658
|
+
tool_response_preview TEXT,
|
|
659
|
+
file_path TEXT,
|
|
660
|
+
command TEXT,
|
|
661
|
+
user_id TEXT NOT NULL,
|
|
662
|
+
device_id TEXT NOT NULL,
|
|
663
|
+
agent TEXT DEFAULT 'claude-code',
|
|
664
|
+
created_at_epoch INTEGER NOT NULL
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_session
|
|
668
|
+
ON tool_events(session_id, created_at_epoch DESC, id DESC);
|
|
669
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_project
|
|
670
|
+
ON tool_events(project_id, created_at_epoch DESC, id DESC);
|
|
671
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_tool_name
|
|
672
|
+
ON tool_events(tool_name, created_at_epoch DESC);
|
|
673
|
+
CREATE INDEX IF NOT EXISTS idx_tool_events_created
|
|
674
|
+
ON tool_events(created_at_epoch DESC, id DESC);
|
|
675
|
+
`
|
|
676
|
+
}
|
|
677
|
+
];
|
|
678
|
+
function isVecExtensionLoaded(db) {
|
|
679
|
+
try {
|
|
680
|
+
db.exec("SELECT vec_version()");
|
|
681
|
+
return true;
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function runMigrations(db) {
|
|
687
|
+
const currentVersion = db.query("PRAGMA user_version").get();
|
|
688
|
+
let version = currentVersion.user_version;
|
|
689
|
+
for (const migration of MIGRATIONS) {
|
|
690
|
+
if (migration.version <= version)
|
|
691
|
+
continue;
|
|
692
|
+
if (migration.condition && !migration.condition(db)) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
db.exec("BEGIN TRANSACTION");
|
|
696
|
+
try {
|
|
697
|
+
db.exec(migration.sql);
|
|
698
|
+
db.exec(`PRAGMA user_version = ${migration.version}`);
|
|
699
|
+
db.exec("COMMIT");
|
|
700
|
+
version = migration.version;
|
|
701
|
+
} catch (error) {
|
|
702
|
+
db.exec("ROLLBACK");
|
|
703
|
+
throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function ensureObservationTypes(db) {
|
|
708
|
+
try {
|
|
709
|
+
db.exec("INSERT INTO observations (session_id, project_id, type, title, user_id, device_id, agent, created_at, created_at_epoch) " + "VALUES ('_typecheck', 1, 'message', '_test', '_test', '_test', '_test', '2000-01-01', 0)");
|
|
710
|
+
db.exec("DELETE FROM observations WHERE session_id = '_typecheck'");
|
|
711
|
+
} catch {
|
|
712
|
+
db.exec("BEGIN TRANSACTION");
|
|
713
|
+
try {
|
|
714
|
+
db.exec(`
|
|
715
|
+
CREATE TABLE observations_repair (
|
|
716
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
|
|
717
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
718
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
719
|
+
'bugfix','discovery','decision','pattern','change','feature',
|
|
720
|
+
'refactor','digest','standard','message')),
|
|
721
|
+
title TEXT NOT NULL, narrative TEXT, facts TEXT, concepts TEXT,
|
|
722
|
+
files_read TEXT, files_modified TEXT,
|
|
723
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
724
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active','aging','archived','purged','pinned')),
|
|
725
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN ('shared','personal','secret')),
|
|
726
|
+
user_id TEXT NOT NULL, device_id TEXT NOT NULL, agent TEXT DEFAULT 'claude-code',
|
|
727
|
+
created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL,
|
|
728
|
+
archived_at_epoch INTEGER,
|
|
729
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
730
|
+
superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
731
|
+
remote_source_id TEXT
|
|
732
|
+
);
|
|
733
|
+
INSERT INTO observations_repair SELECT * FROM observations;
|
|
734
|
+
DROP TABLE observations;
|
|
735
|
+
ALTER TABLE observations_repair RENAME TO observations;
|
|
736
|
+
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
|
|
737
|
+
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
|
|
738
|
+
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
|
|
739
|
+
CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
|
|
740
|
+
CREATE INDEX IF NOT EXISTS idx_observations_lifecycle ON observations(lifecycle);
|
|
741
|
+
CREATE INDEX IF NOT EXISTS idx_observations_quality ON observations(quality);
|
|
742
|
+
CREATE INDEX IF NOT EXISTS idx_observations_user ON observations(user_id);
|
|
743
|
+
CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(superseded_by);
|
|
744
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
745
|
+
DROP TABLE IF EXISTS observations_fts;
|
|
746
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
747
|
+
title, narrative, facts, concepts, content=observations, content_rowid=id
|
|
748
|
+
);
|
|
749
|
+
INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
|
|
750
|
+
`);
|
|
751
|
+
db.exec("COMMIT");
|
|
752
|
+
} catch (err) {
|
|
753
|
+
db.exec("ROLLBACK");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
|
|
758
|
+
|
|
759
|
+
// src/storage/sqlite.ts
|
|
760
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
761
|
+
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
762
|
+
function openDatabase(dbPath) {
|
|
763
|
+
if (IS_BUN) {
|
|
764
|
+
return openBunDatabase(dbPath);
|
|
765
|
+
}
|
|
766
|
+
return openNodeDatabase(dbPath);
|
|
767
|
+
}
|
|
768
|
+
function openBunDatabase(dbPath) {
|
|
769
|
+
const { Database } = __require("bun:sqlite");
|
|
770
|
+
if (process.platform === "darwin") {
|
|
771
|
+
const { existsSync: existsSync3 } = __require("node:fs");
|
|
772
|
+
const paths = [
|
|
773
|
+
"/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
|
|
774
|
+
"/usr/local/opt/sqlite3/lib/libsqlite3.dylib"
|
|
775
|
+
];
|
|
776
|
+
for (const p of paths) {
|
|
777
|
+
if (existsSync3(p)) {
|
|
778
|
+
try {
|
|
779
|
+
Database.setCustomSQLite(p);
|
|
780
|
+
} catch {}
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const db = new Database(dbPath);
|
|
786
|
+
return db;
|
|
787
|
+
}
|
|
788
|
+
function openNodeDatabase(dbPath) {
|
|
789
|
+
const BetterSqlite3 = __require("better-sqlite3");
|
|
790
|
+
const raw = new BetterSqlite3(dbPath);
|
|
791
|
+
return {
|
|
792
|
+
query(sql) {
|
|
793
|
+
const stmt = raw.prepare(sql);
|
|
794
|
+
return {
|
|
795
|
+
get(...params) {
|
|
796
|
+
return stmt.get(...params);
|
|
797
|
+
},
|
|
798
|
+
all(...params) {
|
|
799
|
+
return stmt.all(...params);
|
|
800
|
+
},
|
|
801
|
+
run(...params) {
|
|
802
|
+
return stmt.run(...params);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
},
|
|
806
|
+
exec(sql) {
|
|
807
|
+
raw.exec(sql);
|
|
808
|
+
},
|
|
809
|
+
close() {
|
|
810
|
+
raw.close();
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
class MemDatabase {
|
|
816
|
+
db;
|
|
817
|
+
vecAvailable;
|
|
818
|
+
constructor(dbPath) {
|
|
819
|
+
this.db = openDatabase(dbPath);
|
|
820
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
821
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
822
|
+
this.vecAvailable = this.loadVecExtension();
|
|
823
|
+
runMigrations(this.db);
|
|
824
|
+
ensureObservationTypes(this.db);
|
|
825
|
+
}
|
|
826
|
+
loadVecExtension() {
|
|
827
|
+
try {
|
|
828
|
+
const sqliteVec = __require("sqlite-vec");
|
|
829
|
+
sqliteVec.load(this.db);
|
|
830
|
+
return true;
|
|
831
|
+
} catch {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
close() {
|
|
836
|
+
this.db.close();
|
|
837
|
+
}
|
|
838
|
+
upsertProject(project) {
|
|
839
|
+
const now = Math.floor(Date.now() / 1000);
|
|
840
|
+
const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
|
|
841
|
+
if (existing) {
|
|
842
|
+
this.db.query(`UPDATE projects SET
|
|
843
|
+
local_path = COALESCE(?, local_path),
|
|
844
|
+
remote_url = COALESCE(?, remote_url),
|
|
845
|
+
last_active_epoch = ?
|
|
846
|
+
WHERE id = ?`).run(project.local_path ?? null, project.remote_url ?? null, now, existing.id);
|
|
847
|
+
return {
|
|
848
|
+
...existing,
|
|
849
|
+
local_path: project.local_path ?? existing.local_path,
|
|
850
|
+
remote_url: project.remote_url ?? existing.remote_url,
|
|
851
|
+
last_active_epoch: now
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
|
|
855
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
|
|
856
|
+
return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
|
|
857
|
+
}
|
|
858
|
+
getProjectByCanonicalId(canonicalId) {
|
|
859
|
+
return this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId) ?? null;
|
|
860
|
+
}
|
|
861
|
+
getProjectById(id) {
|
|
862
|
+
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
|
|
863
|
+
}
|
|
864
|
+
insertObservation(obs) {
|
|
865
|
+
const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
866
|
+
const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
|
|
867
|
+
const result = this.db.query(`INSERT INTO observations (
|
|
868
|
+
session_id, project_id, type, title, narrative, facts, concepts,
|
|
869
|
+
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
870
|
+
user_id, device_id, agent, created_at, created_at_epoch
|
|
871
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(obs.session_id ?? null, obs.project_id, obs.type, obs.title, obs.narrative ?? null, obs.facts ?? null, obs.concepts ?? null, obs.files_read ?? null, obs.files_modified ?? null, obs.quality, obs.lifecycle ?? "active", obs.sensitivity ?? "shared", obs.user_id, obs.device_id, obs.agent ?? "claude-code", createdAt, now);
|
|
872
|
+
const id = Number(result.lastInsertRowid);
|
|
873
|
+
const row = this.getObservationById(id);
|
|
874
|
+
this.ftsInsert(row);
|
|
875
|
+
if (obs.session_id) {
|
|
876
|
+
this.db.query("UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?").run(obs.session_id);
|
|
877
|
+
}
|
|
878
|
+
return row;
|
|
879
|
+
}
|
|
880
|
+
getObservationById(id) {
|
|
881
|
+
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
882
|
+
}
|
|
883
|
+
getObservationsByIds(ids, userId) {
|
|
884
|
+
if (ids.length === 0)
|
|
885
|
+
return [];
|
|
886
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
887
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
888
|
+
return this.db.query(`SELECT * FROM observations
|
|
889
|
+
WHERE id IN (${placeholders})${visibilityClause}
|
|
890
|
+
ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
|
|
891
|
+
}
|
|
892
|
+
getRecentObservations(projectId, sincEpoch, limit = 50) {
|
|
893
|
+
return this.db.query(`SELECT * FROM observations
|
|
894
|
+
WHERE project_id = ? AND created_at_epoch > ?
|
|
895
|
+
ORDER BY created_at_epoch DESC
|
|
896
|
+
LIMIT ?`).all(projectId, sincEpoch, limit);
|
|
897
|
+
}
|
|
898
|
+
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
899
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
900
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
901
|
+
if (projectId !== null) {
|
|
902
|
+
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
903
|
+
FROM observations_fts
|
|
904
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
905
|
+
WHERE observations_fts MATCH ?
|
|
906
|
+
AND o.project_id = ?
|
|
907
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
908
|
+
${visibilityClause}
|
|
909
|
+
ORDER BY observations_fts.rank
|
|
910
|
+
LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
911
|
+
}
|
|
912
|
+
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
913
|
+
FROM observations_fts
|
|
914
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
915
|
+
WHERE observations_fts MATCH ?
|
|
916
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
917
|
+
${visibilityClause}
|
|
918
|
+
ORDER BY observations_fts.rank
|
|
919
|
+
LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
920
|
+
}
|
|
921
|
+
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
|
|
922
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
923
|
+
const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
|
|
924
|
+
if (!anchor)
|
|
925
|
+
return [];
|
|
926
|
+
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
927
|
+
const projectParams = projectId !== null ? [projectId] : [];
|
|
928
|
+
const visibilityParams = userId ? [userId] : [];
|
|
929
|
+
const before = this.db.query(`SELECT * FROM observations
|
|
930
|
+
WHERE created_at_epoch < ? ${projectFilter}
|
|
931
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
932
|
+
${visibilityClause}
|
|
933
|
+
ORDER BY created_at_epoch DESC
|
|
934
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
|
|
935
|
+
const after = this.db.query(`SELECT * FROM observations
|
|
936
|
+
WHERE created_at_epoch > ? ${projectFilter}
|
|
937
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
938
|
+
${visibilityClause}
|
|
939
|
+
ORDER BY created_at_epoch ASC
|
|
940
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
|
|
941
|
+
return [...before.reverse(), anchor, ...after];
|
|
942
|
+
}
|
|
943
|
+
pinObservation(id, pinned) {
|
|
944
|
+
const obs = this.getObservationById(id);
|
|
945
|
+
if (!obs)
|
|
946
|
+
return false;
|
|
947
|
+
if (pinned) {
|
|
948
|
+
if (obs.lifecycle !== "active" && obs.lifecycle !== "aging")
|
|
949
|
+
return false;
|
|
950
|
+
this.db.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?").run(id);
|
|
951
|
+
} else {
|
|
952
|
+
if (obs.lifecycle !== "pinned")
|
|
953
|
+
return false;
|
|
954
|
+
this.db.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?").run(id);
|
|
955
|
+
}
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
getActiveObservationCount(userId) {
|
|
959
|
+
if (userId) {
|
|
960
|
+
const result2 = this.db.query(`SELECT COUNT(*) as count FROM observations
|
|
961
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
962
|
+
AND sensitivity != 'secret'
|
|
963
|
+
AND user_id = ?`).get(userId);
|
|
964
|
+
return result2?.count ?? 0;
|
|
965
|
+
}
|
|
966
|
+
const result = this.db.query(`SELECT COUNT(*) as count FROM observations
|
|
967
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
968
|
+
AND sensitivity != 'secret'`).get();
|
|
969
|
+
return result?.count ?? 0;
|
|
970
|
+
}
|
|
971
|
+
supersedeObservation(oldId, newId) {
|
|
972
|
+
if (oldId === newId)
|
|
973
|
+
return false;
|
|
974
|
+
const replacement = this.getObservationById(newId);
|
|
975
|
+
if (!replacement)
|
|
976
|
+
return false;
|
|
977
|
+
let targetId = oldId;
|
|
978
|
+
const visited = new Set;
|
|
979
|
+
for (let depth = 0;depth < 10; depth++) {
|
|
980
|
+
const target2 = this.getObservationById(targetId);
|
|
981
|
+
if (!target2)
|
|
982
|
+
return false;
|
|
983
|
+
if (target2.superseded_by === null)
|
|
984
|
+
break;
|
|
985
|
+
if (target2.superseded_by === newId)
|
|
986
|
+
return true;
|
|
987
|
+
visited.add(targetId);
|
|
988
|
+
targetId = target2.superseded_by;
|
|
989
|
+
if (visited.has(targetId))
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
const target = this.getObservationById(targetId);
|
|
993
|
+
if (!target)
|
|
994
|
+
return false;
|
|
995
|
+
if (target.superseded_by !== null)
|
|
996
|
+
return false;
|
|
997
|
+
if (targetId === newId)
|
|
998
|
+
return false;
|
|
999
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1000
|
+
this.db.query(`UPDATE observations
|
|
1001
|
+
SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
|
|
1002
|
+
WHERE id = ?`).run(newId, now, targetId);
|
|
1003
|
+
this.ftsDelete(target);
|
|
1004
|
+
this.vecDelete(targetId);
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
isSuperseded(id) {
|
|
1008
|
+
const obs = this.getObservationById(id);
|
|
1009
|
+
return obs !== null && obs.superseded_by !== null;
|
|
1010
|
+
}
|
|
1011
|
+
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
1012
|
+
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1013
|
+
if (existing)
|
|
1014
|
+
return existing;
|
|
1015
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1016
|
+
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
1017
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
1018
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
1019
|
+
}
|
|
1020
|
+
completeSession(sessionId) {
|
|
1021
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1022
|
+
this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
|
|
1023
|
+
}
|
|
1024
|
+
getSessionById(sessionId) {
|
|
1025
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
|
|
1026
|
+
}
|
|
1027
|
+
getRecentSessions(projectId, limit = 10, userId) {
|
|
1028
|
+
const visibilityClause = userId ? " AND s.user_id = ?" : "";
|
|
1029
|
+
if (projectId !== null) {
|
|
1030
|
+
return this.db.query(`SELECT
|
|
1031
|
+
s.*,
|
|
1032
|
+
p.name AS project_name,
|
|
1033
|
+
ss.request AS request,
|
|
1034
|
+
ss.completed AS completed,
|
|
1035
|
+
(SELECT COUNT(*) FROM user_prompts up WHERE up.session_id = s.session_id) AS prompt_count,
|
|
1036
|
+
(SELECT COUNT(*) FROM tool_events te WHERE te.session_id = s.session_id) AS tool_event_count
|
|
1037
|
+
FROM sessions s
|
|
1038
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
1039
|
+
LEFT JOIN session_summaries ss ON ss.session_id = s.session_id
|
|
1040
|
+
WHERE s.project_id = ?${visibilityClause}
|
|
1041
|
+
ORDER BY COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) DESC, s.id DESC
|
|
1042
|
+
LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
|
|
1043
|
+
}
|
|
1044
|
+
return this.db.query(`SELECT
|
|
1045
|
+
s.*,
|
|
1046
|
+
p.name AS project_name,
|
|
1047
|
+
ss.request AS request,
|
|
1048
|
+
ss.completed AS completed,
|
|
1049
|
+
(SELECT COUNT(*) FROM user_prompts up WHERE up.session_id = s.session_id) AS prompt_count,
|
|
1050
|
+
(SELECT COUNT(*) FROM tool_events te WHERE te.session_id = s.session_id) AS tool_event_count
|
|
1051
|
+
FROM sessions s
|
|
1052
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
1053
|
+
LEFT JOIN session_summaries ss ON ss.session_id = s.session_id
|
|
1054
|
+
WHERE 1 = 1${visibilityClause}
|
|
1055
|
+
ORDER BY COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) DESC, s.id DESC
|
|
1056
|
+
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1057
|
+
}
|
|
1058
|
+
insertUserPrompt(input) {
|
|
1059
|
+
const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
1060
|
+
const normalizedPrompt = input.prompt.trim();
|
|
1061
|
+
const promptHash = hashPrompt(normalizedPrompt);
|
|
1062
|
+
const latest = this.db.query(`SELECT * FROM user_prompts
|
|
1063
|
+
WHERE session_id = ?
|
|
1064
|
+
ORDER BY prompt_number DESC
|
|
1065
|
+
LIMIT 1`).get(input.session_id);
|
|
1066
|
+
if (latest && latest.prompt_hash === promptHash) {
|
|
1067
|
+
return latest;
|
|
1068
|
+
}
|
|
1069
|
+
const promptNumber = (latest?.prompt_number ?? 0) + 1;
|
|
1070
|
+
const result = this.db.query(`INSERT INTO user_prompts (
|
|
1071
|
+
session_id, project_id, prompt_number, prompt, prompt_hash, cwd,
|
|
1072
|
+
user_id, device_id, agent, created_at_epoch
|
|
1073
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, promptNumber, normalizedPrompt, promptHash, input.cwd ?? null, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt);
|
|
1074
|
+
return this.getUserPromptById(Number(result.lastInsertRowid));
|
|
1075
|
+
}
|
|
1076
|
+
getUserPromptById(id) {
|
|
1077
|
+
return this.db.query("SELECT * FROM user_prompts WHERE id = ?").get(id) ?? null;
|
|
1078
|
+
}
|
|
1079
|
+
getRecentUserPrompts(projectId, limit = 10, userId) {
|
|
1080
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1081
|
+
if (projectId !== null) {
|
|
1082
|
+
return this.db.query(`SELECT * FROM user_prompts
|
|
1083
|
+
WHERE project_id = ?${visibilityClause}
|
|
1084
|
+
ORDER BY created_at_epoch DESC, prompt_number DESC
|
|
1085
|
+
LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
|
|
1086
|
+
}
|
|
1087
|
+
return this.db.query(`SELECT * FROM user_prompts
|
|
1088
|
+
WHERE 1 = 1${visibilityClause}
|
|
1089
|
+
ORDER BY created_at_epoch DESC, prompt_number DESC
|
|
1090
|
+
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1091
|
+
}
|
|
1092
|
+
getSessionUserPrompts(sessionId, limit = 20) {
|
|
1093
|
+
return this.db.query(`SELECT * FROM user_prompts
|
|
1094
|
+
WHERE session_id = ?
|
|
1095
|
+
ORDER BY prompt_number ASC
|
|
1096
|
+
LIMIT ?`).all(sessionId, limit);
|
|
1097
|
+
}
|
|
1098
|
+
insertToolEvent(input) {
|
|
1099
|
+
const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
1100
|
+
const result = this.db.query(`INSERT INTO tool_events (
|
|
1101
|
+
session_id, project_id, tool_name, tool_input_json, tool_response_preview,
|
|
1102
|
+
file_path, command, user_id, device_id, agent, created_at_epoch
|
|
1103
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.tool_name, input.tool_input_json ?? null, input.tool_response_preview ?? null, input.file_path ?? null, input.command ?? null, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt);
|
|
1104
|
+
return this.getToolEventById(Number(result.lastInsertRowid));
|
|
1105
|
+
}
|
|
1106
|
+
getToolEventById(id) {
|
|
1107
|
+
return this.db.query("SELECT * FROM tool_events WHERE id = ?").get(id) ?? null;
|
|
1108
|
+
}
|
|
1109
|
+
getSessionToolEvents(sessionId, limit = 20) {
|
|
1110
|
+
return this.db.query(`SELECT * FROM tool_events
|
|
1111
|
+
WHERE session_id = ?
|
|
1112
|
+
ORDER BY created_at_epoch ASC, id ASC
|
|
1113
|
+
LIMIT ?`).all(sessionId, limit);
|
|
1114
|
+
}
|
|
1115
|
+
getRecentToolEvents(projectId, limit = 20, userId) {
|
|
1116
|
+
const visibilityClause = userId ? " AND user_id = ?" : "";
|
|
1117
|
+
if (projectId !== null) {
|
|
1118
|
+
return this.db.query(`SELECT * FROM tool_events
|
|
1119
|
+
WHERE project_id = ?${visibilityClause}
|
|
1120
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1121
|
+
LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
|
|
1122
|
+
}
|
|
1123
|
+
return this.db.query(`SELECT * FROM tool_events
|
|
1124
|
+
WHERE 1 = 1${visibilityClause}
|
|
1125
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1126
|
+
LIMIT ?`).all(...userId ? [userId] : [], limit);
|
|
1127
|
+
}
|
|
1128
|
+
addToOutbox(recordType, recordId) {
|
|
1129
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1130
|
+
this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
1131
|
+
VALUES (?, ?, ?)`).run(recordType, recordId, now);
|
|
1132
|
+
}
|
|
1133
|
+
getSyncState(key) {
|
|
1134
|
+
const row = this.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
|
|
1135
|
+
return row?.value ?? null;
|
|
1136
|
+
}
|
|
1137
|
+
setSyncState(key, value) {
|
|
1138
|
+
this.db.query("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
|
|
1139
|
+
}
|
|
1140
|
+
ftsInsert(obs) {
|
|
1141
|
+
this.db.query(`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
|
|
1142
|
+
VALUES (?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
1143
|
+
}
|
|
1144
|
+
ftsDelete(obs) {
|
|
1145
|
+
this.db.query(`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
|
|
1146
|
+
VALUES ('delete', ?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
1147
|
+
}
|
|
1148
|
+
vecInsert(observationId, embedding) {
|
|
1149
|
+
if (!this.vecAvailable)
|
|
1150
|
+
return;
|
|
1151
|
+
this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
|
|
1152
|
+
}
|
|
1153
|
+
vecDelete(observationId) {
|
|
1154
|
+
if (!this.vecAvailable)
|
|
1155
|
+
return;
|
|
1156
|
+
this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
|
|
1157
|
+
}
|
|
1158
|
+
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
1159
|
+
if (!this.vecAvailable)
|
|
1160
|
+
return [];
|
|
1161
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
1162
|
+
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
1163
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
1164
|
+
if (projectId !== null) {
|
|
1165
|
+
return this.db.query(`SELECT v.observation_id, v.distance
|
|
1166
|
+
FROM vec_observations v
|
|
1167
|
+
JOIN observations o ON o.id = v.observation_id
|
|
1168
|
+
WHERE v.embedding MATCH ?
|
|
1169
|
+
AND k = ?
|
|
1170
|
+
AND o.project_id = ?
|
|
1171
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
1172
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
|
|
1173
|
+
}
|
|
1174
|
+
return this.db.query(`SELECT v.observation_id, v.distance
|
|
1175
|
+
FROM vec_observations v
|
|
1176
|
+
JOIN observations o ON o.id = v.observation_id
|
|
1177
|
+
WHERE v.embedding MATCH ?
|
|
1178
|
+
AND k = ?
|
|
1179
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
1180
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
|
|
1181
|
+
}
|
|
1182
|
+
getUnembeddedCount() {
|
|
1183
|
+
if (!this.vecAvailable)
|
|
1184
|
+
return 0;
|
|
1185
|
+
const result = this.db.query(`SELECT COUNT(*) as count FROM observations o
|
|
1186
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
1187
|
+
AND o.superseded_by IS NULL
|
|
1188
|
+
AND NOT EXISTS (
|
|
1189
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
1190
|
+
)`).get();
|
|
1191
|
+
return result?.count ?? 0;
|
|
1192
|
+
}
|
|
1193
|
+
getUnembeddedObservations(limit = 100) {
|
|
1194
|
+
if (!this.vecAvailable)
|
|
1195
|
+
return [];
|
|
1196
|
+
return this.db.query(`SELECT o.* FROM observations o
|
|
1197
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
1198
|
+
AND o.superseded_by IS NULL
|
|
1199
|
+
AND NOT EXISTS (
|
|
1200
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
1201
|
+
)
|
|
1202
|
+
ORDER BY o.created_at_epoch DESC
|
|
1203
|
+
LIMIT ?`).all(limit);
|
|
1204
|
+
}
|
|
1205
|
+
insertSessionSummary(summary) {
|
|
1206
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1207
|
+
const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
|
|
1208
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
|
|
1209
|
+
const id = Number(result.lastInsertRowid);
|
|
1210
|
+
return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
|
|
1211
|
+
}
|
|
1212
|
+
getSessionSummary(sessionId) {
|
|
1213
|
+
return this.db.query("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId) ?? null;
|
|
1214
|
+
}
|
|
1215
|
+
getRecentSummaries(projectId, limit = 5) {
|
|
1216
|
+
return this.db.query(`SELECT * FROM session_summaries
|
|
1217
|
+
WHERE project_id = ?
|
|
1218
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1219
|
+
LIMIT ?`).all(projectId, limit);
|
|
1220
|
+
}
|
|
1221
|
+
incrementSessionMetrics(sessionId, increments) {
|
|
1222
|
+
const sets = [];
|
|
1223
|
+
const params = [];
|
|
1224
|
+
if (increments.files) {
|
|
1225
|
+
sets.push("files_touched_count = files_touched_count + ?");
|
|
1226
|
+
params.push(increments.files);
|
|
1227
|
+
}
|
|
1228
|
+
if (increments.searches) {
|
|
1229
|
+
sets.push("searches_performed = searches_performed + ?");
|
|
1230
|
+
params.push(increments.searches);
|
|
1231
|
+
}
|
|
1232
|
+
if (increments.toolCalls) {
|
|
1233
|
+
sets.push("tool_calls_count = tool_calls_count + ?");
|
|
1234
|
+
params.push(increments.toolCalls);
|
|
1235
|
+
}
|
|
1236
|
+
if (sets.length === 0)
|
|
1237
|
+
return;
|
|
1238
|
+
params.push(sessionId);
|
|
1239
|
+
this.db.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`).run(...params);
|
|
1240
|
+
}
|
|
1241
|
+
getSessionMetrics(sessionId) {
|
|
1242
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
|
|
1243
|
+
}
|
|
1244
|
+
insertSecurityFinding(finding) {
|
|
1245
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1246
|
+
const result = this.db.query(`INSERT INTO security_findings (session_id, project_id, finding_type, severity, pattern_name, file_path, snippet, tool_name, user_id, device_id, created_at_epoch)
|
|
1247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(finding.session_id ?? null, finding.project_id, finding.finding_type, finding.severity, finding.pattern_name, finding.file_path ?? null, finding.snippet ?? null, finding.tool_name ?? null, finding.user_id, finding.device_id, now);
|
|
1248
|
+
const id = Number(result.lastInsertRowid);
|
|
1249
|
+
return this.db.query("SELECT * FROM security_findings WHERE id = ?").get(id);
|
|
1250
|
+
}
|
|
1251
|
+
getSecurityFindings(projectId, options = {}) {
|
|
1252
|
+
const limit = options.limit ?? 50;
|
|
1253
|
+
if (options.severity) {
|
|
1254
|
+
return this.db.query(`SELECT * FROM security_findings
|
|
1255
|
+
WHERE project_id = ? AND severity = ?
|
|
1256
|
+
ORDER BY created_at_epoch DESC
|
|
1257
|
+
LIMIT ?`).all(projectId, options.severity, limit);
|
|
1258
|
+
}
|
|
1259
|
+
return this.db.query(`SELECT * FROM security_findings
|
|
1260
|
+
WHERE project_id = ?
|
|
1261
|
+
ORDER BY created_at_epoch DESC
|
|
1262
|
+
LIMIT ?`).all(projectId, limit);
|
|
1263
|
+
}
|
|
1264
|
+
getSecurityFindingsCount(projectId) {
|
|
1265
|
+
const rows = this.db.query(`SELECT severity, COUNT(*) as count FROM security_findings
|
|
1266
|
+
WHERE project_id = ?
|
|
1267
|
+
GROUP BY severity`).all(projectId);
|
|
1268
|
+
const counts = {
|
|
1269
|
+
critical: 0,
|
|
1270
|
+
high: 0,
|
|
1271
|
+
medium: 0,
|
|
1272
|
+
low: 0
|
|
1273
|
+
};
|
|
1274
|
+
for (const row of rows) {
|
|
1275
|
+
counts[row.severity] = row.count;
|
|
1276
|
+
}
|
|
1277
|
+
return counts;
|
|
1278
|
+
}
|
|
1279
|
+
setSessionRiskScore(sessionId, score) {
|
|
1280
|
+
this.db.query("UPDATE sessions SET risk_score = ? WHERE session_id = ?").run(score, sessionId);
|
|
1281
|
+
}
|
|
1282
|
+
getObservationsBySession(sessionId) {
|
|
1283
|
+
return this.db.query(`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`).all(sessionId);
|
|
1284
|
+
}
|
|
1285
|
+
getInstalledPacks() {
|
|
1286
|
+
try {
|
|
1287
|
+
const rows = this.db.query("SELECT name FROM packs_installed").all();
|
|
1288
|
+
return rows.map((r) => r.name);
|
|
1289
|
+
} catch {
|
|
1290
|
+
return [];
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
markPackInstalled(name, observationCount) {
|
|
1294
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1295
|
+
this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function hashPrompt(prompt) {
|
|
1299
|
+
return createHash2("sha256").update(prompt).digest("hex");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/hooks/common.ts
|
|
1303
|
+
var c = {
|
|
1304
|
+
dim: "\x1B[2m",
|
|
1305
|
+
yellow: "\x1B[33m",
|
|
1306
|
+
reset: "\x1B[0m"
|
|
1307
|
+
};
|
|
1308
|
+
async function readStdin() {
|
|
1309
|
+
const chunks = [];
|
|
1310
|
+
for await (const chunk of process.stdin) {
|
|
1311
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
1312
|
+
}
|
|
1313
|
+
return chunks.join("");
|
|
1314
|
+
}
|
|
1315
|
+
async function parseStdinJson() {
|
|
1316
|
+
const raw = await readStdin();
|
|
1317
|
+
if (!raw.trim())
|
|
1318
|
+
return null;
|
|
1319
|
+
try {
|
|
1320
|
+
return JSON.parse(raw);
|
|
1321
|
+
} catch {
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function bootstrapHook(hookName) {
|
|
1326
|
+
if (!configExists()) {
|
|
1327
|
+
warnUser(hookName, "Engrm not configured. Run: npx engrm init");
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
let config;
|
|
1331
|
+
try {
|
|
1332
|
+
config = loadConfig();
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
warnUser(hookName, `Config error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
let db;
|
|
1338
|
+
try {
|
|
1339
|
+
db = new MemDatabase(getDbPath());
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
warnUser(hookName, `Database error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
return { config, db };
|
|
1345
|
+
}
|
|
1346
|
+
function warnUser(hookName, message) {
|
|
1347
|
+
console.error(`${c.yellow}engrm ${hookName}:${c.reset} ${c.dim}${message}${c.reset}`);
|
|
1348
|
+
}
|
|
1349
|
+
function runHook(hookName, fn) {
|
|
1350
|
+
fn().catch((err) => {
|
|
1351
|
+
warnUser(hookName, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1352
|
+
process.exit(0);
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// hooks/user-prompt-submit.ts
|
|
1357
|
+
async function main() {
|
|
1358
|
+
const event = await parseStdinJson();
|
|
1359
|
+
if (!event?.prompt?.trim())
|
|
1360
|
+
process.exit(0);
|
|
1361
|
+
const boot = bootstrapHook("user-prompt-submit");
|
|
1362
|
+
if (!boot)
|
|
1363
|
+
process.exit(0);
|
|
1364
|
+
const { config, db } = boot;
|
|
1365
|
+
try {
|
|
1366
|
+
const detected = detectProject(event.cwd);
|
|
1367
|
+
const project = db.upsertProject({
|
|
1368
|
+
canonical_id: detected.canonical_id,
|
|
1369
|
+
name: detected.name,
|
|
1370
|
+
local_path: event.cwd,
|
|
1371
|
+
remote_url: detected.remote_url ?? null
|
|
1372
|
+
});
|
|
1373
|
+
db.upsertSession(event.session_id, project.id, config.user_id, config.device_id, "claude-code");
|
|
1374
|
+
db.insertUserPrompt({
|
|
1375
|
+
session_id: event.session_id,
|
|
1376
|
+
project_id: project.id,
|
|
1377
|
+
prompt: event.prompt,
|
|
1378
|
+
cwd: event.cwd,
|
|
1379
|
+
user_id: config.user_id,
|
|
1380
|
+
device_id: config.device_id,
|
|
1381
|
+
agent: "claude-code"
|
|
1382
|
+
});
|
|
1383
|
+
} finally {
|
|
1384
|
+
db.close();
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
runHook("user-prompt-submit", main);
|