engrm 0.4.0 → 0.4.3

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.
@@ -3,2158 +3,2486 @@
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
6
- // src/config.ts
7
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { homedir, hostname, networkInterfaces } from "node:os";
9
- import { join } from "node:path";
10
- import { createHash } from "node:crypto";
11
- var CONFIG_DIR = join(homedir(), ".engrm");
12
- var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
13
- var DB_PATH = join(CONFIG_DIR, "engrm.db");
14
- function getDbPath() {
15
- return DB_PATH;
16
- }
17
- function generateDeviceId() {
18
- const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
19
- let mac = "";
20
- const ifaces = networkInterfaces();
21
- for (const entries of Object.values(ifaces)) {
22
- if (!entries)
23
- continue;
24
- for (const entry of entries) {
25
- if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
26
- mac = entry.mac;
27
- break;
28
- }
29
- }
30
- if (mac)
31
- break;
6
+ // hooks/session-start.ts
7
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
8
+ import { join as join6 } from "path";
9
+ import { homedir as homedir3 } from "os";
10
+
11
+ // src/storage/projects.ts
12
+ import { execSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { basename, join } from "node:path";
15
+ function normaliseGitRemoteUrl(remoteUrl) {
16
+ let url = remoteUrl.trim();
17
+ url = url.replace(/^(?:https?|ssh|git):\/\//, "");
18
+ url = url.replace(/^[^@]+@/, "");
19
+ url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
20
+ url = url.replace(/\.git$/, "");
21
+ url = url.replace(/\/+$/, "");
22
+ const slashIndex = url.indexOf("/");
23
+ if (slashIndex !== -1) {
24
+ const host = url.substring(0, slashIndex).toLowerCase();
25
+ const path = url.substring(slashIndex);
26
+ url = host + path;
27
+ } else {
28
+ url = url.toLowerCase();
32
29
  }
33
- const material = `${host}:${mac || "no-mac"}`;
34
- const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
35
- return `${host}-${suffix}`;
30
+ return url;
36
31
  }
37
- function createDefaultConfig() {
38
- return {
39
- candengo_url: "",
40
- candengo_api_key: "",
41
- site_id: "",
42
- namespace: "",
43
- user_id: "",
44
- user_email: "",
45
- device_id: generateDeviceId(),
46
- teams: [],
47
- sync: {
48
- enabled: true,
49
- interval_seconds: 30,
50
- batch_size: 50
51
- },
52
- search: {
53
- default_limit: 10,
54
- local_boost: 1.2,
55
- scope: "all"
56
- },
57
- scrubbing: {
58
- enabled: true,
59
- custom_patterns: [],
60
- default_sensitivity: "shared"
61
- },
62
- sentinel: {
63
- enabled: false,
64
- mode: "advisory",
65
- provider: "openai",
66
- model: "gpt-4o-mini",
67
- api_key: "",
68
- base_url: "",
69
- skip_patterns: [],
70
- daily_limit: 100,
71
- tier: "free"
72
- },
73
- observer: {
74
- enabled: true,
75
- mode: "per_event",
76
- model: "sonnet"
77
- },
78
- transcript_analysis: {
79
- enabled: false
80
- }
81
- };
32
+ function projectNameFromCanonicalId(canonicalId) {
33
+ const parts = canonicalId.split("/");
34
+ return parts[parts.length - 1] ?? canonicalId;
82
35
  }
83
- function loadConfig() {
84
- if (!existsSync(SETTINGS_PATH)) {
85
- throw new Error(`Config not found at ${SETTINGS_PATH}. Run 'engrm init --manual' to configure.`);
36
+ function getGitRemoteUrl(directory) {
37
+ try {
38
+ const url = execSync("git remote get-url origin", {
39
+ cwd: directory,
40
+ encoding: "utf-8",
41
+ timeout: 5000,
42
+ stdio: ["pipe", "pipe", "pipe"]
43
+ }).trim();
44
+ return url || null;
45
+ } catch {
46
+ try {
47
+ const remotes = execSync("git remote", {
48
+ cwd: directory,
49
+ encoding: "utf-8",
50
+ timeout: 5000,
51
+ stdio: ["pipe", "pipe", "pipe"]
52
+ }).trim().split(`
53
+ `).filter(Boolean);
54
+ if (remotes.length === 0)
55
+ return null;
56
+ const url = execSync(`git remote get-url ${remotes[0]}`, {
57
+ cwd: directory,
58
+ encoding: "utf-8",
59
+ timeout: 5000,
60
+ stdio: ["pipe", "pipe", "pipe"]
61
+ }).trim();
62
+ return url || null;
63
+ } catch {
64
+ return null;
65
+ }
86
66
  }
87
- const raw = readFileSync(SETTINGS_PATH, "utf-8");
88
- let parsed;
67
+ }
68
+ function readProjectConfigFile(directory) {
69
+ const configPath = join(directory, ".engrm.json");
70
+ if (!existsSync(configPath))
71
+ return null;
89
72
  try {
90
- parsed = JSON.parse(raw);
73
+ const raw = readFileSync(configPath, "utf-8");
74
+ const parsed = JSON.parse(raw);
75
+ if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
76
+ return null;
77
+ }
78
+ return {
79
+ project_id: parsed["project_id"],
80
+ name: typeof parsed["name"] === "string" ? parsed["name"] : undefined
81
+ };
91
82
  } catch {
92
- throw new Error(`Invalid JSON in ${SETTINGS_PATH}`);
83
+ return null;
93
84
  }
94
- if (typeof parsed !== "object" || parsed === null) {
95
- throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
85
+ }
86
+ function detectProject(directory) {
87
+ const remoteUrl = getGitRemoteUrl(directory);
88
+ if (remoteUrl) {
89
+ const canonicalId = normaliseGitRemoteUrl(remoteUrl);
90
+ return {
91
+ canonical_id: canonicalId,
92
+ name: projectNameFromCanonicalId(canonicalId),
93
+ remote_url: remoteUrl,
94
+ local_path: directory
95
+ };
96
96
  }
97
- const config = parsed;
98
- const defaults = createDefaultConfig();
97
+ const configFile = readProjectConfigFile(directory);
98
+ if (configFile) {
99
+ return {
100
+ canonical_id: configFile.project_id,
101
+ name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
102
+ remote_url: null,
103
+ local_path: directory
104
+ };
105
+ }
106
+ const dirName = basename(directory);
99
107
  return {
100
- candengo_url: asString(config["candengo_url"], defaults.candengo_url),
101
- candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
102
- site_id: asString(config["site_id"], defaults.site_id),
103
- namespace: asString(config["namespace"], defaults.namespace),
104
- user_id: asString(config["user_id"], defaults.user_id),
105
- user_email: asString(config["user_email"], defaults.user_email),
106
- device_id: asString(config["device_id"], defaults.device_id),
107
- teams: asTeams(config["teams"], defaults.teams),
108
- sync: {
109
- enabled: asBool(config["sync"]?.["enabled"], defaults.sync.enabled),
110
- interval_seconds: asNumber(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
111
- batch_size: asNumber(config["sync"]?.["batch_size"], defaults.sync.batch_size)
112
- },
113
- search: {
114
- default_limit: asNumber(config["search"]?.["default_limit"], defaults.search.default_limit),
115
- local_boost: asNumber(config["search"]?.["local_boost"], defaults.search.local_boost),
116
- scope: asScope(config["search"]?.["scope"], defaults.search.scope)
117
- },
118
- scrubbing: {
119
- enabled: asBool(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
120
- custom_patterns: asStringArray(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
121
- default_sensitivity: asSensitivity(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
122
- },
123
- sentinel: {
124
- enabled: asBool(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
125
- mode: asSentinelMode(config["sentinel"]?.["mode"], defaults.sentinel.mode),
126
- provider: asLlmProvider(config["sentinel"]?.["provider"], defaults.sentinel.provider),
127
- model: asString(config["sentinel"]?.["model"], defaults.sentinel.model),
128
- api_key: asString(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
129
- base_url: asString(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
130
- skip_patterns: asStringArray(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
131
- daily_limit: asNumber(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
132
- tier: asTier(config["sentinel"]?.["tier"], defaults.sentinel.tier)
133
- },
134
- observer: {
135
- enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
136
- mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
137
- model: asString(config["observer"]?.["model"], defaults.observer.model)
138
- },
139
- transcript_analysis: {
140
- enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
141
- }
108
+ canonical_id: `local/${dirName}`,
109
+ name: dirName,
110
+ remote_url: null,
111
+ local_path: directory
142
112
  };
143
113
  }
144
- function configExists() {
145
- return existsSync(SETTINGS_PATH);
114
+
115
+ // src/capture/dedup.ts
116
+ function tokenise(text) {
117
+ const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
118
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
119
+ return new Set(tokens);
120
+ }
121
+ function jaccardSimilarity(a, b) {
122
+ const tokensA = tokenise(a);
123
+ const tokensB = tokenise(b);
124
+ if (tokensA.size === 0 && tokensB.size === 0)
125
+ return 1;
126
+ if (tokensA.size === 0 || tokensB.size === 0)
127
+ return 0;
128
+ let intersectionSize = 0;
129
+ for (const token of tokensA) {
130
+ if (tokensB.has(token))
131
+ intersectionSize++;
132
+ }
133
+ const unionSize = tokensA.size + tokensB.size - intersectionSize;
134
+ if (unionSize === 0)
135
+ return 0;
136
+ return intersectionSize / unionSize;
137
+ }
138
+ var DEDUP_THRESHOLD = 0.8;
139
+ function findDuplicate(newTitle, candidates) {
140
+ let bestMatch = null;
141
+ let bestScore = 0;
142
+ for (const candidate of candidates) {
143
+ const similarity = jaccardSimilarity(newTitle, candidate.title);
144
+ if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
145
+ bestScore = similarity;
146
+ bestMatch = candidate;
147
+ }
148
+ }
149
+ return bestMatch;
146
150
  }
147
- function asString(value, fallback) {
148
- return typeof value === "string" ? value : fallback;
151
+
152
+ // src/intelligence/followthrough.ts
153
+ var FOLLOW_THROUGH_THRESHOLD = 0.25;
154
+ var STALE_AFTER_DAYS = 3;
155
+ var DECISION_WINDOW_DAYS = 30;
156
+ var IMPLEMENTATION_TYPES = new Set([
157
+ "feature",
158
+ "bugfix",
159
+ "change",
160
+ "refactor"
161
+ ]);
162
+ function findStaleDecisions(db, projectId, options) {
163
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
164
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
165
+ const nowEpoch = Math.floor(Date.now() / 1000);
166
+ const windowStart = nowEpoch - windowDays * 86400;
167
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
168
+ const decisions = db.db.query(`SELECT * FROM observations
169
+ WHERE project_id = ? AND type = 'decision'
170
+ AND lifecycle IN ('active', 'aging', 'pinned')
171
+ AND superseded_by IS NULL
172
+ AND created_at_epoch >= ?
173
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
174
+ if (decisions.length === 0)
175
+ return [];
176
+ const implementations = db.db.query(`SELECT * FROM observations
177
+ WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
178
+ AND lifecycle IN ('active', 'aging', 'pinned')
179
+ AND superseded_by IS NULL
180
+ AND created_at_epoch >= ?
181
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
182
+ const crossProjectImpls = db.db.query(`SELECT * FROM observations
183
+ WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
184
+ AND lifecycle IN ('active', 'aging', 'pinned')
185
+ AND superseded_by IS NULL
186
+ AND created_at_epoch >= ?
187
+ ORDER BY created_at_epoch DESC
188
+ LIMIT 200`).all(projectId, windowStart);
189
+ const allImpls = [...implementations, ...crossProjectImpls];
190
+ const stale = [];
191
+ for (const decision of decisions) {
192
+ if (decision.created_at_epoch > staleThreshold)
193
+ continue;
194
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
195
+ let decisionConcepts = [];
196
+ try {
197
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
198
+ if (Array.isArray(parsed))
199
+ decisionConcepts = parsed;
200
+ } catch {}
201
+ let bestTitle = "";
202
+ let bestScore = 0;
203
+ for (const impl of allImpls) {
204
+ if (impl.created_at_epoch <= decision.created_at_epoch)
205
+ continue;
206
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
207
+ let conceptBoost = 0;
208
+ if (decisionConcepts.length > 0) {
209
+ try {
210
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
211
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
212
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
213
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
214
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
215
+ }
216
+ } catch {}
217
+ }
218
+ let narrativeBoost = 0;
219
+ if (impl.narrative) {
220
+ const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
221
+ if (decWords.size > 0) {
222
+ const implNarrativeLower = impl.narrative.toLowerCase();
223
+ const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
224
+ narrativeBoost = hits / decWords.size * 0.1;
225
+ }
226
+ }
227
+ const totalScore = titleScore + conceptBoost + narrativeBoost;
228
+ if (totalScore > bestScore) {
229
+ bestScore = totalScore;
230
+ bestTitle = impl.title;
231
+ }
232
+ }
233
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
234
+ stale.push({
235
+ id: decision.id,
236
+ title: decision.title,
237
+ narrative: decision.narrative,
238
+ concepts: decisionConcepts,
239
+ created_at: decision.created_at,
240
+ days_ago: daysAgo,
241
+ ...bestScore > 0.1 ? {
242
+ best_match_title: bestTitle,
243
+ best_match_similarity: Math.round(bestScore * 100) / 100
244
+ } : {}
245
+ });
246
+ }
247
+ }
248
+ stale.sort((a, b) => b.days_ago - a.days_ago);
249
+ return stale.slice(0, 5);
149
250
  }
150
- function asNumber(value, fallback) {
151
- return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
251
+ function findStaleDecisionsGlobal(db, options) {
252
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
253
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
254
+ const nowEpoch = Math.floor(Date.now() / 1000);
255
+ const windowStart = nowEpoch - windowDays * 86400;
256
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
257
+ const decisions = db.db.query(`SELECT * FROM observations
258
+ WHERE type = 'decision'
259
+ AND lifecycle IN ('active', 'aging', 'pinned')
260
+ AND superseded_by IS NULL
261
+ AND created_at_epoch >= ?
262
+ ORDER BY created_at_epoch DESC`).all(windowStart);
263
+ if (decisions.length === 0)
264
+ return [];
265
+ const implementations = db.db.query(`SELECT * FROM observations
266
+ WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
267
+ AND lifecycle IN ('active', 'aging', 'pinned')
268
+ AND superseded_by IS NULL
269
+ AND created_at_epoch >= ?
270
+ ORDER BY created_at_epoch DESC
271
+ LIMIT 500`).all(windowStart);
272
+ const stale = [];
273
+ for (const decision of decisions) {
274
+ if (decision.created_at_epoch > staleThreshold)
275
+ continue;
276
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
277
+ let decisionConcepts = [];
278
+ try {
279
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
280
+ if (Array.isArray(parsed))
281
+ decisionConcepts = parsed;
282
+ } catch {}
283
+ let bestScore = 0;
284
+ let bestTitle = "";
285
+ for (const impl of implementations) {
286
+ if (impl.created_at_epoch <= decision.created_at_epoch)
287
+ continue;
288
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
289
+ let conceptBoost = 0;
290
+ if (decisionConcepts.length > 0) {
291
+ try {
292
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
293
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
294
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
295
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
296
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
297
+ }
298
+ } catch {}
299
+ }
300
+ const totalScore = titleScore + conceptBoost;
301
+ if (totalScore > bestScore) {
302
+ bestScore = totalScore;
303
+ bestTitle = impl.title;
304
+ }
305
+ }
306
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
307
+ stale.push({
308
+ id: decision.id,
309
+ title: decision.title,
310
+ narrative: decision.narrative,
311
+ concepts: decisionConcepts,
312
+ created_at: decision.created_at,
313
+ days_ago: daysAgo,
314
+ ...bestScore > 0.1 ? {
315
+ best_match_title: bestTitle,
316
+ best_match_similarity: Math.round(bestScore * 100) / 100
317
+ } : {}
318
+ });
319
+ }
320
+ }
321
+ stale.sort((a, b) => b.days_ago - a.days_ago);
322
+ return stale.slice(0, 5);
152
323
  }
153
- function asBool(value, fallback) {
154
- return typeof value === "boolean" ? value : fallback;
324
+
325
+ // src/context/inject.ts
326
+ var RECENCY_WINDOW_SECONDS = 30 * 86400;
327
+ function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
328
+ const age = nowEpoch - createdAtEpoch;
329
+ const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
330
+ return quality * 0.6 + recencyNorm * 0.4;
155
331
  }
156
- function asStringArray(value, fallback) {
157
- return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
332
+ function estimateTokens(text) {
333
+ if (!text)
334
+ return 0;
335
+ return Math.ceil(text.length / 4);
158
336
  }
159
- function asScope(value, fallback) {
160
- if (value === "personal" || value === "team" || value === "all")
161
- return value;
162
- return fallback;
337
+ function buildSessionContext(db, cwd, options = {}) {
338
+ const opts = typeof options === "number" ? { maxCount: options } : options;
339
+ const tokenBudget = opts.tokenBudget ?? 3000;
340
+ const maxCount = opts.maxCount;
341
+ const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
342
+ const visibilityParams = opts.userId ? [opts.userId] : [];
343
+ const detected = detectProject(cwd);
344
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
345
+ const projectId = project?.id ?? -1;
346
+ const isNewProject = !project;
347
+ const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
348
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
349
+ ${visibilityClause}
350
+ AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
351
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
352
+ ${visibilityClause}
353
+ AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
354
+ const candidateLimit = maxCount ?? 50;
355
+ let pinned = [];
356
+ let recent = [];
357
+ let candidates = [];
358
+ if (!isNewProject) {
359
+ const MAX_PINNED = 5;
360
+ pinned = db.db.query(`SELECT * FROM observations
361
+ WHERE project_id = ? AND lifecycle = 'pinned'
362
+ AND superseded_by IS NULL
363
+ ${visibilityClause}
364
+ ORDER BY quality DESC, created_at_epoch DESC
365
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
366
+ const MAX_RECENT = 5;
367
+ recent = db.db.query(`SELECT * FROM observations
368
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
369
+ AND superseded_by IS NULL
370
+ ${visibilityClause}
371
+ ORDER BY created_at_epoch DESC
372
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
373
+ candidates = db.db.query(`SELECT * FROM observations
374
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
375
+ AND quality >= 0.3
376
+ AND superseded_by IS NULL
377
+ ${visibilityClause}
378
+ ORDER BY quality DESC, created_at_epoch DESC
379
+ LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
380
+ }
381
+ let crossProjectCandidates = [];
382
+ if (opts.scope === "all" || isNewProject) {
383
+ const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
384
+ const qualityThreshold = isNewProject ? 0.3 : 0.5;
385
+ const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
386
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
387
+ AND quality >= ?
388
+ AND superseded_by IS NULL
389
+ ${visibilityClause}
390
+ ORDER BY quality DESC, created_at_epoch DESC
391
+ LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
392
+ WHERE project_id != ? AND lifecycle IN ('active', 'aging')
393
+ AND quality >= ?
394
+ AND superseded_by IS NULL
395
+ ${visibilityClause}
396
+ ORDER BY quality DESC, created_at_epoch DESC
397
+ LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
398
+ const projectNameCache = new Map;
399
+ crossProjectCandidates = rawCross.map((obs) => {
400
+ if (!projectNameCache.has(obs.project_id)) {
401
+ const proj = db.getProjectById(obs.project_id);
402
+ if (proj)
403
+ projectNameCache.set(obs.project_id, proj.name);
404
+ }
405
+ return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
406
+ });
407
+ }
408
+ const seenIds = new Set(pinned.map((o) => o.id));
409
+ const dedupedRecent = recent.filter((o) => {
410
+ if (seenIds.has(o.id))
411
+ return false;
412
+ seenIds.add(o.id);
413
+ return true;
414
+ });
415
+ const deduped = candidates.filter((o) => !seenIds.has(o.id));
416
+ for (const obs of crossProjectCandidates) {
417
+ if (!seenIds.has(obs.id)) {
418
+ seenIds.add(obs.id);
419
+ deduped.push(obs);
420
+ }
421
+ }
422
+ const nowEpoch = Math.floor(Date.now() / 1000);
423
+ const sorted = [...deduped].sort((a, b) => {
424
+ const boostA = a.type === "digest" ? 0.15 : 0;
425
+ const boostB = b.type === "digest" ? 0.15 : 0;
426
+ const scoreA = computeBlendedScore(a.quality, a.created_at_epoch, nowEpoch) + boostA;
427
+ const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
428
+ return scoreB - scoreA;
429
+ });
430
+ const projectName = project?.name ?? detected.name;
431
+ const canonicalId = project?.canonical_id ?? detected.canonical_id;
432
+ if (maxCount !== undefined) {
433
+ const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
434
+ const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
435
+ return {
436
+ project_name: projectName,
437
+ canonical_id: canonicalId,
438
+ observations: all.map(toContextObservation),
439
+ session_count: all.length,
440
+ total_active: totalActive
441
+ };
442
+ }
443
+ let remainingBudget = tokenBudget - 30;
444
+ const selected = [];
445
+ for (const obs of pinned) {
446
+ const cost = estimateObservationTokens(obs, selected.length);
447
+ remainingBudget -= cost;
448
+ selected.push(obs);
449
+ }
450
+ for (const obs of dedupedRecent) {
451
+ const cost = estimateObservationTokens(obs, selected.length);
452
+ remainingBudget -= cost;
453
+ selected.push(obs);
454
+ }
455
+ for (const obs of sorted) {
456
+ const cost = estimateObservationTokens(obs, selected.length);
457
+ if (remainingBudget - cost < 0 && selected.length > 0)
458
+ break;
459
+ remainingBudget -= cost;
460
+ selected.push(obs);
461
+ }
462
+ const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
463
+ let securityFindings = [];
464
+ if (!isNewProject) {
465
+ try {
466
+ const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
467
+ securityFindings = db.db.query(`SELECT * FROM security_findings
468
+ WHERE project_id = ? AND created_at_epoch > ?
469
+ ORDER BY severity DESC, created_at_epoch DESC
470
+ LIMIT ?`).all(projectId, weekAgo, 10);
471
+ } catch {}
472
+ }
473
+ let recentProjects;
474
+ if (isNewProject) {
475
+ try {
476
+ const nowEpochSec = Math.floor(Date.now() / 1000);
477
+ const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
478
+ (SELECT COUNT(*) FROM observations o
479
+ WHERE o.project_id = p.id
480
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
481
+ ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
482
+ AND o.superseded_by IS NULL) as obs_count
483
+ FROM projects p
484
+ ORDER BY p.last_active_epoch DESC
485
+ LIMIT 10`).all(...visibilityParams);
486
+ if (projectRows.length > 0) {
487
+ recentProjects = projectRows.map((r) => {
488
+ const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
489
+ const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
490
+ return {
491
+ name: r.name,
492
+ canonical_id: r.canonical_id,
493
+ observation_count: r.obs_count,
494
+ last_active: lastActive,
495
+ days_ago: daysAgo
496
+ };
497
+ });
498
+ }
499
+ } catch {}
500
+ }
501
+ let staleDecisions;
502
+ try {
503
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
504
+ if (stale.length > 0)
505
+ staleDecisions = stale;
506
+ } catch {}
507
+ return {
508
+ project_name: projectName,
509
+ canonical_id: canonicalId,
510
+ observations: selected.map(toContextObservation),
511
+ session_count: selected.length,
512
+ total_active: totalActive,
513
+ summaries: summaries.length > 0 ? summaries : undefined,
514
+ securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
515
+ recentProjects,
516
+ staleDecisions
517
+ };
163
518
  }
164
- function asSensitivity(value, fallback) {
165
- if (value === "shared" || value === "personal" || value === "secret")
166
- return value;
167
- return fallback;
519
+ function estimateObservationTokens(obs, index) {
520
+ const DETAILED_THRESHOLD = 5;
521
+ const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
522
+ if (index >= DETAILED_THRESHOLD) {
523
+ return titleCost;
524
+ }
525
+ const detailText = formatObservationDetail(obs);
526
+ return titleCost + estimateTokens(detailText);
168
527
  }
169
- function asSentinelMode(value, fallback) {
170
- if (value === "advisory" || value === "blocking")
171
- return value;
172
- return fallback;
528
+ function formatContextForInjection(context) {
529
+ if (context.observations.length === 0) {
530
+ return `Project: ${context.project_name} (no prior observations)`;
531
+ }
532
+ const DETAILED_COUNT = 5;
533
+ const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
534
+ const lines = [];
535
+ if (isCrossProject) {
536
+ lines.push(`## Engrm Memory — Workspace Overview`);
537
+ lines.push(`This is a new project folder. Here is context from your recent work:`);
538
+ lines.push("");
539
+ lines.push("**Active projects in memory:**");
540
+ for (const rp of context.recentProjects) {
541
+ const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
542
+ lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
543
+ }
544
+ lines.push("");
545
+ lines.push(`${context.session_count} relevant observation(s) from across projects:`);
546
+ lines.push("");
547
+ } else {
548
+ lines.push(`## Project Memory: ${context.project_name}`);
549
+ lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
550
+ lines.push("");
551
+ }
552
+ for (let i = 0;i < context.observations.length; i++) {
553
+ const obs = context.observations[i];
554
+ const date = obs.created_at.split("T")[0];
555
+ const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
556
+ lines.push(`- **[${obs.type}]** ${obs.title} (${date}, q=${obs.quality.toFixed(1)})${fromLabel}`);
557
+ if (i < DETAILED_COUNT) {
558
+ const detail = formatObservationDetailFromContext(obs);
559
+ if (detail) {
560
+ lines.push(detail);
561
+ }
562
+ }
563
+ }
564
+ if (context.summaries && context.summaries.length > 0) {
565
+ lines.push("");
566
+ lines.push("## Recent Project Briefs");
567
+ for (const summary of context.summaries.slice(0, 3)) {
568
+ lines.push(...formatSessionBrief(summary));
569
+ lines.push("");
570
+ }
571
+ }
572
+ if (context.securityFindings && context.securityFindings.length > 0) {
573
+ lines.push("");
574
+ lines.push("Security findings (recent):");
575
+ for (const finding of context.securityFindings) {
576
+ const date = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
577
+ const file = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
578
+ lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file} (${date})`);
579
+ }
580
+ }
581
+ if (context.staleDecisions && context.staleDecisions.length > 0) {
582
+ lines.push("");
583
+ lines.push("Stale commitments (decided but no implementation observed):");
584
+ for (const sd of context.staleDecisions) {
585
+ const date = sd.created_at.split("T")[0];
586
+ lines.push(`- [DECISION] ${sd.title} (${date}, ${sd.days_ago}d ago)`);
587
+ if (sd.best_match_title) {
588
+ lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
589
+ }
590
+ }
591
+ }
592
+ const remaining = context.total_active - context.session_count;
593
+ if (remaining > 0) {
594
+ lines.push("");
595
+ lines.push(`${remaining} more observation(s) available via search tool.`);
596
+ }
597
+ return lines.join(`
598
+ `);
173
599
  }
174
- function asLlmProvider(value, fallback) {
175
- if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
176
- return value;
177
- return fallback;
600
+ function formatSessionBrief(summary) {
601
+ const lines = [];
602
+ const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
603
+ lines.push(heading);
604
+ const sections = [
605
+ ["Investigated", summary.investigated, 180],
606
+ ["Learned", summary.learned, 180],
607
+ ["Completed", summary.completed, 180],
608
+ ["Next Steps", summary.next_steps, 140]
609
+ ];
610
+ for (const [label, value, maxLen] of sections) {
611
+ const formatted = formatSummarySection(value, maxLen);
612
+ if (formatted) {
613
+ lines.push(`${label}:`);
614
+ lines.push(formatted);
615
+ }
616
+ }
617
+ return lines;
178
618
  }
179
- function asTier(value, fallback) {
180
- if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
181
- return value;
182
- return fallback;
619
+ function formatSummarySection(value, maxLen) {
620
+ if (!value)
621
+ return null;
622
+ const cleaned = value.split(`
623
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.startsWith("-") ? line : `- ${line}`).join(`
624
+ `);
625
+ if (!cleaned)
626
+ return null;
627
+ return truncateMultilineText(cleaned, maxLen);
183
628
  }
184
- function asObserverMode(value, fallback) {
185
- if (value === "per_event" || value === "per_session")
186
- return value;
187
- return fallback;
629
+ function truncateMultilineText(text, maxLen) {
630
+ if (text.length <= maxLen)
631
+ return text;
632
+ const truncated = text.slice(0, maxLen).trimEnd();
633
+ const lastBreak = Math.max(truncated.lastIndexOf(`
634
+ `), truncated.lastIndexOf(" "));
635
+ const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
636
+ return `${safe.trimEnd()}…`;
188
637
  }
189
- function asTeams(value, fallback) {
190
- if (!Array.isArray(value))
191
- return fallback;
192
- return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
638
+ function truncateText(text, maxLen) {
639
+ if (text.length <= maxLen)
640
+ return text;
641
+ return text.slice(0, maxLen - 3) + "...";
642
+ }
643
+ function formatObservationDetailFromContext(obs) {
644
+ if (obs.facts) {
645
+ const bullets = parseFacts(obs.facts);
646
+ if (bullets.length > 0) {
647
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
648
+ `);
649
+ }
650
+ }
651
+ if (obs.narrative) {
652
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
653
+ return ` ${snippet}`;
654
+ }
655
+ return null;
656
+ }
657
+ function formatObservationDetail(obs) {
658
+ if (obs.facts) {
659
+ const bullets = parseFacts(obs.facts);
660
+ if (bullets.length > 0) {
661
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
662
+ `);
663
+ }
664
+ }
665
+ if (obs.narrative) {
666
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
667
+ return ` ${snippet}`;
668
+ }
669
+ return "";
670
+ }
671
+ function parseFacts(facts) {
672
+ if (!facts)
673
+ return [];
674
+ try {
675
+ const parsed = JSON.parse(facts);
676
+ if (Array.isArray(parsed)) {
677
+ return parsed.filter((f) => typeof f === "string" && f.length > 0);
678
+ }
679
+ } catch {
680
+ if (facts.trim().length > 0) {
681
+ return [facts.trim()];
682
+ }
683
+ }
684
+ return [];
685
+ }
686
+ function toContextObservation(obs) {
687
+ return {
688
+ id: obs.id,
689
+ type: obs.type,
690
+ title: obs.title,
691
+ narrative: obs.narrative,
692
+ facts: obs.facts,
693
+ quality: obs.quality,
694
+ created_at: obs.created_at,
695
+ ...obs._source_project ? { source_project: obs._source_project } : {}
696
+ };
193
697
  }
194
698
 
195
- // src/storage/migrations.ts
196
- var MIGRATIONS = [
197
- {
198
- version: 1,
199
- description: "Initial schema: projects, observations, sessions, sync, FTS5",
200
- sql: `
201
- -- Projects (canonical identity across machines)
202
- CREATE TABLE projects (
203
- id INTEGER PRIMARY KEY AUTOINCREMENT,
204
- canonical_id TEXT UNIQUE NOT NULL,
205
- name TEXT NOT NULL,
206
- local_path TEXT,
207
- remote_url TEXT,
208
- first_seen_epoch INTEGER NOT NULL,
209
- last_active_epoch INTEGER NOT NULL
210
- );
211
-
212
- -- Core observations table
213
- CREATE TABLE observations (
214
- id INTEGER PRIMARY KEY AUTOINCREMENT,
215
- session_id TEXT,
216
- project_id INTEGER NOT NULL REFERENCES projects(id),
217
- type TEXT NOT NULL CHECK (type IN (
218
- 'bugfix', 'discovery', 'decision', 'pattern',
219
- 'change', 'feature', 'refactor', 'digest'
220
- )),
221
- title TEXT NOT NULL,
222
- narrative TEXT,
223
- facts TEXT,
224
- concepts TEXT,
225
- files_read TEXT,
226
- files_modified TEXT,
227
- quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
228
- lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
229
- 'active', 'aging', 'archived', 'purged', 'pinned'
230
- )),
231
- sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
232
- 'shared', 'personal', 'secret'
233
- )),
234
- user_id TEXT NOT NULL,
235
- device_id TEXT NOT NULL,
236
- agent TEXT DEFAULT 'claude-code',
237
- created_at TEXT NOT NULL,
238
- created_at_epoch INTEGER NOT NULL,
239
- archived_at_epoch INTEGER,
240
- compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
241
- );
242
-
243
- -- Session tracking
244
- CREATE TABLE sessions (
245
- id INTEGER PRIMARY KEY AUTOINCREMENT,
246
- session_id TEXT UNIQUE NOT NULL,
247
- project_id INTEGER REFERENCES projects(id),
248
- user_id TEXT NOT NULL,
249
- device_id TEXT NOT NULL,
250
- agent TEXT DEFAULT 'claude-code',
251
- status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
252
- observation_count INTEGER DEFAULT 0,
253
- started_at_epoch INTEGER,
254
- completed_at_epoch INTEGER
255
- );
256
-
257
- -- Session summaries (generated on Stop hook)
258
- CREATE TABLE session_summaries (
259
- id INTEGER PRIMARY KEY AUTOINCREMENT,
260
- session_id TEXT UNIQUE NOT NULL,
261
- project_id INTEGER REFERENCES projects(id),
262
- user_id TEXT NOT NULL,
263
- request TEXT,
264
- investigated TEXT,
265
- learned TEXT,
266
- completed TEXT,
267
- next_steps TEXT,
268
- created_at_epoch INTEGER
269
- );
270
-
271
- -- Sync outbox (offline-first queue)
272
- CREATE TABLE sync_outbox (
273
- id INTEGER PRIMARY KEY AUTOINCREMENT,
274
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
275
- record_id INTEGER NOT NULL,
276
- status TEXT DEFAULT 'pending' CHECK (status IN (
277
- 'pending', 'syncing', 'synced', 'failed'
278
- )),
279
- retry_count INTEGER DEFAULT 0,
280
- max_retries INTEGER DEFAULT 10,
281
- last_error TEXT,
282
- created_at_epoch INTEGER NOT NULL,
283
- synced_at_epoch INTEGER,
284
- next_retry_epoch INTEGER
285
- );
286
-
287
- -- Sync high-water mark and lifecycle job tracking
288
- CREATE TABLE sync_state (
289
- key TEXT PRIMARY KEY,
290
- value TEXT NOT NULL
291
- );
292
-
293
- -- FTS5 for local offline search (external content mode)
294
- CREATE VIRTUAL TABLE observations_fts USING fts5(
295
- title, narrative, facts, concepts,
296
- content=observations,
297
- content_rowid=id
298
- );
299
-
300
- -- Indexes: observations
301
- CREATE INDEX idx_observations_project ON observations(project_id);
302
- CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
303
- CREATE INDEX idx_observations_type ON observations(type);
304
- CREATE INDEX idx_observations_created ON observations(created_at_epoch);
305
- CREATE INDEX idx_observations_session ON observations(session_id);
306
- CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
307
- CREATE INDEX idx_observations_quality ON observations(quality);
308
- CREATE INDEX idx_observations_user ON observations(user_id);
699
+ // src/telemetry/stack-detect.ts
700
+ import { existsSync as existsSync2 } from "node:fs";
701
+ import { join as join2, extname } from "node:path";
702
+ var EXTENSION_MAP = {
703
+ ".tsx": "react",
704
+ ".jsx": "react",
705
+ ".ts": "typescript",
706
+ ".js": "javascript",
707
+ ".py": "python",
708
+ ".rs": "rust",
709
+ ".go": "go",
710
+ ".java": "java",
711
+ ".kt": "kotlin",
712
+ ".swift": "swift",
713
+ ".rb": "ruby",
714
+ ".php": "php",
715
+ ".cs": "csharp",
716
+ ".cpp": "cpp",
717
+ ".c": "c",
718
+ ".zig": "zig",
719
+ ".ex": "elixir",
720
+ ".exs": "elixir",
721
+ ".erl": "erlang",
722
+ ".hs": "haskell",
723
+ ".lua": "lua",
724
+ ".dart": "dart",
725
+ ".scala": "scala",
726
+ ".clj": "clojure",
727
+ ".vue": "vue",
728
+ ".svelte": "svelte",
729
+ ".astro": "astro"
730
+ };
731
+ var CONFIG_FILE_STACKS = [
732
+ ["next.config.js", "nextjs"],
733
+ ["next.config.mjs", "nextjs"],
734
+ ["next.config.ts", "nextjs"],
735
+ ["nuxt.config.ts", "nuxt"],
736
+ ["nuxt.config.js", "nuxt"],
737
+ ["vite.config.ts", "vite"],
738
+ ["vite.config.js", "vite"],
739
+ ["vite.config.mjs", "vite"],
740
+ ["svelte.config.js", "svelte"],
741
+ ["astro.config.mjs", "astro"],
742
+ ["remix.config.js", "remix"],
743
+ ["angular.json", "angular"],
744
+ ["tailwind.config.js", "tailwind"],
745
+ ["tailwind.config.ts", "tailwind"],
746
+ ["postcss.config.js", "postcss"],
747
+ ["webpack.config.js", "webpack"],
748
+ ["tsconfig.json", "typescript"],
749
+ ["Cargo.toml", "rust"],
750
+ ["go.mod", "go"],
751
+ ["pyproject.toml", "python"],
752
+ ["setup.py", "python"],
753
+ ["requirements.txt", "python"],
754
+ ["Pipfile", "python"],
755
+ ["manage.py", "django"],
756
+ ["Gemfile", "ruby"],
757
+ ["composer.json", "php"],
758
+ ["pom.xml", "java"],
759
+ ["build.gradle", "gradle"],
760
+ ["build.gradle.kts", "gradle"],
761
+ ["Package.swift", "swift"],
762
+ ["pubspec.yaml", "flutter"],
763
+ ["mix.exs", "elixir"],
764
+ ["deno.json", "deno"],
765
+ ["bun.lock", "bun"],
766
+ ["bun.lockb", "bun"],
767
+ ["docker-compose.yml", "docker"],
768
+ ["docker-compose.yaml", "docker"],
769
+ ["Dockerfile", "docker"],
770
+ [".prisma/schema.prisma", "prisma"],
771
+ ["prisma/schema.prisma", "prisma"],
772
+ ["drizzle.config.ts", "drizzle"]
773
+ ];
774
+ var PATH_PATTERN_STACKS = [
775
+ ["/__tests__/", "jest"],
776
+ ["/.storybook/", "storybook"],
777
+ ["/cypress/", "cypress"],
778
+ ["/playwright/", "playwright"],
779
+ ["/terraform/", "terraform"],
780
+ ["/k8s/", "kubernetes"],
781
+ ["/helm/", "helm"]
782
+ ];
783
+ function detectStacks(filePaths) {
784
+ const stacks = new Set;
785
+ for (const fp of filePaths) {
786
+ const ext = extname(fp).toLowerCase();
787
+ if (ext && EXTENSION_MAP[ext]) {
788
+ stacks.add(EXTENSION_MAP[ext]);
789
+ }
790
+ for (const [pattern, stack] of PATH_PATTERN_STACKS) {
791
+ if (fp.includes(pattern)) {
792
+ stacks.add(stack);
793
+ }
794
+ }
795
+ }
796
+ return Array.from(stacks).sort();
797
+ }
798
+ function detectStacksFromProject(projectRoot, filePaths = []) {
799
+ const stacks = new Set(detectStacks(filePaths));
800
+ for (const [configFile, stack] of CONFIG_FILE_STACKS) {
801
+ try {
802
+ if (existsSync2(join2(projectRoot, configFile))) {
803
+ stacks.add(stack);
804
+ }
805
+ } catch {}
806
+ }
807
+ const sorted = Array.from(stacks).sort();
808
+ const frameworks = ["nextjs", "nuxt", "remix", "angular", "django", "flutter", "svelte", "astro"];
809
+ const primary = sorted.find((s) => frameworks.includes(s)) ?? sorted[0] ?? "unknown";
810
+ return { stacks: sorted, primary };
811
+ }
309
812
 
310
- -- Indexes: sessions
311
- CREATE INDEX idx_sessions_project ON sessions(project_id);
813
+ // src/telemetry/config-fingerprint.ts
814
+ import { createHash } from "node:crypto";
815
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync, readdirSync, mkdirSync } from "node:fs";
816
+ import { join as join3 } from "node:path";
817
+ import { homedir } from "node:os";
818
+ var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
819
+ var CLIENT_VERSION = "0.4.0";
820
+ function hashFile(filePath) {
821
+ try {
822
+ if (!existsSync3(filePath))
823
+ return null;
824
+ const content = readFileSync2(filePath, "utf-8");
825
+ return createHash("sha256").update(content).digest("hex");
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
830
+ function countMemoryFiles(memoryDir) {
831
+ try {
832
+ if (!existsSync3(memoryDir))
833
+ return 0;
834
+ return readdirSync(memoryDir).filter((f) => f.endsWith(".md")).length;
835
+ } catch {
836
+ return 0;
837
+ }
838
+ }
839
+ function readPreviousFingerprint() {
840
+ try {
841
+ if (!existsSync3(STATE_PATH))
842
+ return null;
843
+ return JSON.parse(readFileSync2(STATE_PATH, "utf-8"));
844
+ } catch {
845
+ return null;
846
+ }
847
+ }
848
+ function saveFingerprint(fp) {
849
+ try {
850
+ const dir = join3(homedir(), ".engrm");
851
+ if (!existsSync3(dir))
852
+ mkdirSync(dir, { recursive: true });
853
+ writeFileSync(STATE_PATH, JSON.stringify(fp, null, 2) + `
854
+ `, "utf-8");
855
+ } catch {}
856
+ }
857
+ function computeAndSaveFingerprint(cwd) {
858
+ const claudeMdHash = hashFile(join3(cwd, "CLAUDE.md"));
859
+ const engrmJsonHash = hashFile(join3(cwd, ".engrm.json"));
860
+ const slug = cwd.replace(/\//g, "-");
861
+ const memoryDir = join3(homedir(), ".claude", "projects", slug, "memory");
862
+ const memoryMdHash = hashFile(join3(memoryDir, "MEMORY.md"));
863
+ const memoryFileCount = countMemoryFiles(memoryDir);
864
+ const material = [
865
+ claudeMdHash ?? "null",
866
+ memoryMdHash ?? "null",
867
+ engrmJsonHash ?? "null",
868
+ String(memoryFileCount),
869
+ CLIENT_VERSION
870
+ ].join("+");
871
+ const configHash = createHash("sha256").update(material).digest("hex");
872
+ const previous = readPreviousFingerprint();
873
+ const configChanged = previous !== null && previous.config_hash !== configHash;
874
+ const fingerprint = {
875
+ config_hash: configHash,
876
+ config_changed: configChanged,
877
+ claude_md_hash: claudeMdHash,
878
+ memory_md_hash: memoryMdHash,
879
+ engrm_json_hash: engrmJsonHash,
880
+ memory_file_count: memoryFileCount,
881
+ client_version: CLIENT_VERSION
882
+ };
883
+ saveFingerprint(fingerprint);
884
+ return fingerprint;
885
+ }
312
886
 
313
- -- Indexes: sync outbox
314
- CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
315
- CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
316
- `
317
- },
318
- {
319
- version: 2,
320
- description: "Add superseded_by for knowledge supersession",
321
- sql: `
322
- ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
323
- CREATE INDEX idx_observations_superseded ON observations(superseded_by);
324
- `
325
- },
326
- {
327
- version: 3,
328
- description: "Add remote_source_id for pull deduplication",
329
- sql: `
330
- ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
331
- CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
332
- `
333
- },
334
- {
335
- version: 4,
336
- description: "Add sqlite-vec for local semantic search",
337
- sql: `
338
- CREATE VIRTUAL TABLE vec_observations USING vec0(
339
- observation_id INTEGER PRIMARY KEY,
340
- embedding float[384]
341
- );
342
- `,
343
- condition: (db) => isVecExtensionLoaded(db)
344
- },
345
- {
346
- version: 5,
347
- description: "Session metrics and security findings",
348
- sql: `
349
- ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
350
- ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
351
- ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
352
-
353
- CREATE TABLE security_findings (
354
- id INTEGER PRIMARY KEY AUTOINCREMENT,
355
- session_id TEXT,
356
- project_id INTEGER NOT NULL REFERENCES projects(id),
357
- finding_type TEXT NOT NULL,
358
- severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
359
- pattern_name TEXT NOT NULL,
360
- file_path TEXT,
361
- snippet TEXT,
362
- tool_name TEXT,
363
- user_id TEXT NOT NULL,
364
- device_id TEXT NOT NULL,
365
- created_at_epoch INTEGER NOT NULL
366
- );
367
-
368
- CREATE INDEX idx_security_findings_session ON security_findings(session_id);
369
- CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
370
- CREATE INDEX idx_security_findings_severity ON security_findings(severity);
371
- `
372
- },
373
- {
374
- version: 6,
375
- description: "Add risk_score, expand observation types to include standard",
376
- sql: `
377
- ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
378
-
379
- -- Recreate observations table with expanded type CHECK to include 'standard'
380
- -- SQLite doesn't support ALTER CHECK, so we recreate the table
381
- CREATE TABLE observations_new (
382
- id INTEGER PRIMARY KEY AUTOINCREMENT,
383
- session_id TEXT,
384
- project_id INTEGER NOT NULL REFERENCES projects(id),
385
- type TEXT NOT NULL CHECK (type IN (
386
- 'bugfix', 'discovery', 'decision', 'pattern',
387
- 'change', 'feature', 'refactor', 'digest', 'standard'
388
- )),
389
- title TEXT NOT NULL,
390
- narrative TEXT,
391
- facts TEXT,
392
- concepts TEXT,
393
- files_read TEXT,
394
- files_modified TEXT,
395
- quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
396
- lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
397
- 'active', 'aging', 'archived', 'purged', 'pinned'
398
- )),
399
- sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
400
- 'shared', 'personal', 'secret'
401
- )),
402
- user_id TEXT NOT NULL,
403
- device_id TEXT NOT NULL,
404
- agent TEXT DEFAULT 'claude-code',
405
- created_at TEXT NOT NULL,
406
- created_at_epoch INTEGER NOT NULL,
407
- archived_at_epoch INTEGER,
408
- compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
409
- superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
410
- remote_source_id TEXT
411
- );
412
-
413
- INSERT INTO observations_new SELECT * FROM observations;
414
-
415
- DROP TABLE observations;
416
- ALTER TABLE observations_new RENAME TO observations;
417
-
418
- -- Recreate indexes
419
- CREATE INDEX idx_observations_project ON observations(project_id);
420
- CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
421
- CREATE INDEX idx_observations_type ON observations(type);
422
- CREATE INDEX idx_observations_created ON observations(created_at_epoch);
423
- CREATE INDEX idx_observations_session ON observations(session_id);
424
- CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
425
- CREATE INDEX idx_observations_quality ON observations(quality);
426
- CREATE INDEX idx_observations_user ON observations(user_id);
427
- CREATE INDEX idx_observations_superseded ON observations(superseded_by);
428
- CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
429
-
430
- -- Recreate FTS5 (external content mode — must rebuild after table recreation)
431
- DROP TABLE IF EXISTS observations_fts;
432
- CREATE VIRTUAL TABLE observations_fts USING fts5(
433
- title, narrative, facts, concepts,
434
- content=observations,
435
- content_rowid=id
436
- );
437
- -- Rebuild FTS index
438
- INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
439
- `
440
- },
441
- {
442
- version: 7,
443
- description: "Add packs_installed table for help pack tracking",
444
- sql: `
445
- CREATE TABLE IF NOT EXISTS packs_installed (
446
- name TEXT PRIMARY KEY,
447
- installed_at INTEGER NOT NULL,
448
- observation_count INTEGER DEFAULT 0
449
- );
450
- `
451
- },
452
- {
453
- version: 8,
454
- description: "Add message type to observations CHECK constraint",
455
- sql: `
456
- CREATE TABLE IF NOT EXISTS observations_v8 (
457
- id INTEGER PRIMARY KEY AUTOINCREMENT,
458
- session_id TEXT,
459
- project_id INTEGER NOT NULL REFERENCES projects(id),
460
- type TEXT NOT NULL CHECK (type IN (
461
- 'bugfix', 'discovery', 'decision', 'pattern',
462
- 'change', 'feature', 'refactor', 'digest', 'standard', 'message'
463
- )),
464
- title TEXT NOT NULL,
465
- narrative TEXT,
466
- facts TEXT,
467
- concepts TEXT,
468
- files_read TEXT,
469
- files_modified TEXT,
470
- quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
471
- lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
472
- 'active', 'aging', 'archived', 'purged', 'pinned'
473
- )),
474
- sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
475
- 'shared', 'personal', 'secret'
476
- )),
477
- user_id TEXT NOT NULL,
478
- device_id TEXT NOT NULL,
479
- agent TEXT DEFAULT 'claude-code',
480
- created_at TEXT NOT NULL,
481
- created_at_epoch INTEGER NOT NULL,
482
- archived_at_epoch INTEGER,
483
- compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
484
- superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
485
- remote_source_id TEXT
486
- );
487
- INSERT INTO observations_v8 SELECT * FROM observations;
488
- DROP TABLE observations;
489
- ALTER TABLE observations_v8 RENAME TO observations;
490
- CREATE INDEX idx_observations_project ON observations(project_id);
491
- CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
492
- CREATE INDEX idx_observations_type ON observations(type);
493
- CREATE INDEX idx_observations_created ON observations(created_at_epoch);
494
- CREATE INDEX idx_observations_session ON observations(session_id);
495
- CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
496
- CREATE INDEX idx_observations_quality ON observations(quality);
497
- CREATE INDEX idx_observations_user ON observations(user_id);
498
- CREATE INDEX idx_observations_superseded ON observations(superseded_by);
499
- CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
500
- DROP TABLE IF EXISTS observations_fts;
501
- CREATE VIRTUAL TABLE observations_fts USING fts5(
502
- title, narrative, facts, concepts,
503
- content=observations,
504
- content_rowid=id
505
- );
506
- INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
507
- `
508
- }
509
- ];
510
- function isVecExtensionLoaded(db) {
511
- try {
512
- db.exec("SELECT vec_version()");
513
- return true;
514
- } catch {
515
- return false;
516
- }
887
+ // src/packs/recommender.ts
888
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
889
+ import { join as join4, basename as basename3, dirname } from "node:path";
890
+ import { fileURLToPath } from "node:url";
891
+ var STACK_PACK_MAP = {
892
+ typescript: ["typescript-patterns"],
893
+ react: ["react-gotchas"],
894
+ nextjs: ["nextjs-patterns"],
895
+ python: ["python-django"],
896
+ django: ["python-django"],
897
+ javascript: ["node-security"],
898
+ bun: ["node-security"]
899
+ };
900
+ function getPacksDir() {
901
+ const thisDir = dirname(fileURLToPath(import.meta.url));
902
+ return join4(thisDir, "../../packs");
517
903
  }
518
- function runMigrations(db) {
519
- const currentVersion = db.query("PRAGMA user_version").get();
520
- let version = currentVersion.user_version;
521
- for (const migration of MIGRATIONS) {
522
- if (migration.version <= version)
523
- continue;
524
- if (migration.condition && !migration.condition(db)) {
525
- continue;
526
- }
527
- db.exec("BEGIN TRANSACTION");
528
- try {
529
- db.exec(migration.sql);
530
- db.exec(`PRAGMA user_version = ${migration.version}`);
531
- db.exec("COMMIT");
532
- version = migration.version;
533
- } catch (error) {
534
- db.exec("ROLLBACK");
535
- throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${error instanceof Error ? error.message : String(error)}`);
536
- }
537
- }
904
+ function listAvailablePacks() {
905
+ const dir = getPacksDir();
906
+ if (!existsSync4(dir))
907
+ return [];
908
+ return readdirSync2(dir).filter((f) => f.endsWith(".json")).map((f) => basename3(f, ".json"));
538
909
  }
539
- function ensureObservationTypes(db) {
910
+ function loadPack(name) {
911
+ const packPath = join4(getPacksDir(), `${name}.json`);
912
+ if (!existsSync4(packPath))
913
+ return null;
540
914
  try {
541
- 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)");
542
- db.exec("DELETE FROM observations WHERE session_id = '_typecheck'");
915
+ const raw = readFileSync3(packPath, "utf-8");
916
+ return JSON.parse(raw);
543
917
  } catch {
544
- db.exec("BEGIN TRANSACTION");
545
- try {
546
- db.exec(`
547
- CREATE TABLE observations_repair (
548
- id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
549
- project_id INTEGER NOT NULL REFERENCES projects(id),
550
- type TEXT NOT NULL CHECK (type IN (
551
- 'bugfix','discovery','decision','pattern','change','feature',
552
- 'refactor','digest','standard','message')),
553
- title TEXT NOT NULL, narrative TEXT, facts TEXT, concepts TEXT,
554
- files_read TEXT, files_modified TEXT,
555
- quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
556
- lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active','aging','archived','purged','pinned')),
557
- sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN ('shared','personal','secret')),
558
- user_id TEXT NOT NULL, device_id TEXT NOT NULL, agent TEXT DEFAULT 'claude-code',
559
- created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL,
560
- archived_at_epoch INTEGER,
561
- compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
562
- superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
563
- remote_source_id TEXT
564
- );
565
- INSERT INTO observations_repair SELECT * FROM observations;
566
- DROP TABLE observations;
567
- ALTER TABLE observations_repair RENAME TO observations;
568
- CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
569
- CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
570
- CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
571
- CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
572
- CREATE INDEX IF NOT EXISTS idx_observations_lifecycle ON observations(lifecycle);
573
- CREATE INDEX IF NOT EXISTS idx_observations_quality ON observations(quality);
574
- CREATE INDEX IF NOT EXISTS idx_observations_user ON observations(user_id);
575
- CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(superseded_by);
576
- CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
577
- DROP TABLE IF EXISTS observations_fts;
578
- CREATE VIRTUAL TABLE observations_fts USING fts5(
579
- title, narrative, facts, concepts, content=observations, content_rowid=id
580
- );
581
- INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
582
- `);
583
- db.exec("COMMIT");
584
- } catch (err) {
585
- db.exec("ROLLBACK");
918
+ return null;
919
+ }
920
+ }
921
+ function recommendPacks(stacks, installedPacks) {
922
+ const installed = new Set(installedPacks);
923
+ const available = listAvailablePacks();
924
+ const availableSet = new Set(available);
925
+ const seen = new Set;
926
+ const recommendations = [];
927
+ for (const stack of stacks) {
928
+ const packNames = STACK_PACK_MAP[stack] ?? [];
929
+ for (const packName of packNames) {
930
+ if (seen.has(packName) || installed.has(packName) || !availableSet.has(packName)) {
931
+ continue;
932
+ }
933
+ seen.add(packName);
934
+ const pack = loadPack(packName);
935
+ if (!pack)
936
+ continue;
937
+ const matchedStacks = stacks.filter((s) => STACK_PACK_MAP[s]?.includes(packName));
938
+ recommendations.push({
939
+ name: packName,
940
+ description: pack.description,
941
+ observationCount: pack.observations.length,
942
+ matchedStacks
943
+ });
586
944
  }
587
945
  }
946
+ return recommendations;
588
947
  }
589
- var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
590
948
 
591
- // src/storage/sqlite.ts
592
- var IS_BUN = typeof globalThis.Bun !== "undefined";
593
- function openDatabase(dbPath) {
594
- if (IS_BUN) {
595
- return openBunDatabase(dbPath);
596
- }
597
- return openNodeDatabase(dbPath);
949
+ // src/config.ts
950
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
951
+ import { homedir as homedir2, hostname, networkInterfaces } from "node:os";
952
+ import { join as join5 } from "node:path";
953
+ import { createHash as createHash2 } from "node:crypto";
954
+ var CONFIG_DIR = join5(homedir2(), ".engrm");
955
+ var SETTINGS_PATH = join5(CONFIG_DIR, "settings.json");
956
+ var DB_PATH = join5(CONFIG_DIR, "engrm.db");
957
+ function getDbPath() {
958
+ return DB_PATH;
598
959
  }
599
- function openBunDatabase(dbPath) {
600
- const { Database } = __require("bun:sqlite");
601
- if (process.platform === "darwin") {
602
- const { existsSync: existsSync2 } = __require("node:fs");
603
- const paths = [
604
- "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
605
- "/usr/local/opt/sqlite3/lib/libsqlite3.dylib"
606
- ];
607
- for (const p of paths) {
608
- if (existsSync2(p)) {
609
- try {
610
- Database.setCustomSQLite(p);
611
- } catch {}
960
+ function generateDeviceId() {
961
+ const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
962
+ let mac = "";
963
+ const ifaces = networkInterfaces();
964
+ for (const entries of Object.values(ifaces)) {
965
+ if (!entries)
966
+ continue;
967
+ for (const entry of entries) {
968
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
969
+ mac = entry.mac;
612
970
  break;
613
971
  }
614
972
  }
973
+ if (mac)
974
+ break;
615
975
  }
616
- const db = new Database(dbPath);
617
- return db;
976
+ const material = `${host}:${mac || "no-mac"}`;
977
+ const suffix = createHash2("sha256").update(material).digest("hex").slice(0, 8);
978
+ return `${host}-${suffix}`;
618
979
  }
619
- function openNodeDatabase(dbPath) {
620
- const BetterSqlite3 = __require("better-sqlite3");
621
- const raw = new BetterSqlite3(dbPath);
980
+ function createDefaultConfig() {
622
981
  return {
623
- query(sql) {
624
- const stmt = raw.prepare(sql);
625
- return {
626
- get(...params) {
627
- return stmt.get(...params);
628
- },
629
- all(...params) {
630
- return stmt.all(...params);
631
- },
632
- run(...params) {
633
- return stmt.run(...params);
634
- }
635
- };
982
+ candengo_url: "",
983
+ candengo_api_key: "",
984
+ site_id: "",
985
+ namespace: "",
986
+ user_id: "",
987
+ user_email: "",
988
+ device_id: generateDeviceId(),
989
+ teams: [],
990
+ sync: {
991
+ enabled: true,
992
+ interval_seconds: 30,
993
+ batch_size: 50
636
994
  },
637
- exec(sql) {
638
- raw.exec(sql);
995
+ search: {
996
+ default_limit: 10,
997
+ local_boost: 1.2,
998
+ scope: "all"
639
999
  },
640
- close() {
641
- raw.close();
1000
+ scrubbing: {
1001
+ enabled: true,
1002
+ custom_patterns: [],
1003
+ default_sensitivity: "shared"
1004
+ },
1005
+ sentinel: {
1006
+ enabled: false,
1007
+ mode: "advisory",
1008
+ provider: "openai",
1009
+ model: "gpt-4o-mini",
1010
+ api_key: "",
1011
+ base_url: "",
1012
+ skip_patterns: [],
1013
+ daily_limit: 100,
1014
+ tier: "free"
1015
+ },
1016
+ observer: {
1017
+ enabled: true,
1018
+ mode: "per_event",
1019
+ model: "sonnet"
1020
+ },
1021
+ transcript_analysis: {
1022
+ enabled: false
642
1023
  }
643
1024
  };
644
1025
  }
645
-
646
- class MemDatabase {
647
- db;
648
- vecAvailable;
649
- constructor(dbPath) {
650
- this.db = openDatabase(dbPath);
651
- this.db.exec("PRAGMA journal_mode = WAL");
652
- this.db.exec("PRAGMA foreign_keys = ON");
653
- this.vecAvailable = this.loadVecExtension();
654
- runMigrations(this.db);
655
- ensureObservationTypes(this.db);
1026
+ function loadConfig() {
1027
+ if (!existsSync5(SETTINGS_PATH)) {
1028
+ throw new Error(`Config not found at ${SETTINGS_PATH}. Run 'engrm init --manual' to configure.`);
656
1029
  }
657
- loadVecExtension() {
658
- try {
659
- const sqliteVec = __require("sqlite-vec");
660
- sqliteVec.load(this.db);
661
- return true;
662
- } catch {
663
- return false;
664
- }
1030
+ const raw = readFileSync4(SETTINGS_PATH, "utf-8");
1031
+ let parsed;
1032
+ try {
1033
+ parsed = JSON.parse(raw);
1034
+ } catch {
1035
+ throw new Error(`Invalid JSON in ${SETTINGS_PATH}`);
665
1036
  }
666
- close() {
667
- this.db.close();
1037
+ if (typeof parsed !== "object" || parsed === null) {
1038
+ throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
668
1039
  }
669
- upsertProject(project) {
670
- const now = Math.floor(Date.now() / 1000);
671
- const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
672
- if (existing) {
673
- this.db.query(`UPDATE projects SET
674
- local_path = COALESCE(?, local_path),
675
- remote_url = COALESCE(?, remote_url),
676
- last_active_epoch = ?
677
- WHERE id = ?`).run(project.local_path ?? null, project.remote_url ?? null, now, existing.id);
678
- return {
679
- ...existing,
680
- local_path: project.local_path ?? existing.local_path,
681
- remote_url: project.remote_url ?? existing.remote_url,
682
- last_active_epoch: now
683
- };
1040
+ const config = parsed;
1041
+ const defaults = createDefaultConfig();
1042
+ return {
1043
+ candengo_url: asString(config["candengo_url"], defaults.candengo_url),
1044
+ candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
1045
+ site_id: asString(config["site_id"], defaults.site_id),
1046
+ namespace: asString(config["namespace"], defaults.namespace),
1047
+ user_id: asString(config["user_id"], defaults.user_id),
1048
+ user_email: asString(config["user_email"], defaults.user_email),
1049
+ device_id: asString(config["device_id"], defaults.device_id),
1050
+ teams: asTeams(config["teams"], defaults.teams),
1051
+ sync: {
1052
+ enabled: asBool(config["sync"]?.["enabled"], defaults.sync.enabled),
1053
+ interval_seconds: asNumber(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
1054
+ batch_size: asNumber(config["sync"]?.["batch_size"], defaults.sync.batch_size)
1055
+ },
1056
+ search: {
1057
+ default_limit: asNumber(config["search"]?.["default_limit"], defaults.search.default_limit),
1058
+ local_boost: asNumber(config["search"]?.["local_boost"], defaults.search.local_boost),
1059
+ scope: asScope(config["search"]?.["scope"], defaults.search.scope)
1060
+ },
1061
+ scrubbing: {
1062
+ enabled: asBool(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
1063
+ custom_patterns: asStringArray(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
1064
+ default_sensitivity: asSensitivity(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
1065
+ },
1066
+ sentinel: {
1067
+ enabled: asBool(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
1068
+ mode: asSentinelMode(config["sentinel"]?.["mode"], defaults.sentinel.mode),
1069
+ provider: asLlmProvider(config["sentinel"]?.["provider"], defaults.sentinel.provider),
1070
+ model: asString(config["sentinel"]?.["model"], defaults.sentinel.model),
1071
+ api_key: asString(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
1072
+ base_url: asString(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
1073
+ skip_patterns: asStringArray(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
1074
+ daily_limit: asNumber(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
1075
+ tier: asTier(config["sentinel"]?.["tier"], defaults.sentinel.tier)
1076
+ },
1077
+ observer: {
1078
+ enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
1079
+ mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
1080
+ model: asString(config["observer"]?.["model"], defaults.observer.model)
1081
+ },
1082
+ transcript_analysis: {
1083
+ enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
684
1084
  }
685
- const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
686
- VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
687
- return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
688
- }
689
- getProjectByCanonicalId(canonicalId) {
690
- return this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId) ?? null;
691
- }
692
- getProjectById(id) {
693
- return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
694
- }
695
- insertObservation(obs) {
696
- const now = Math.floor(Date.now() / 1000);
697
- const createdAt = new Date().toISOString();
698
- const result = this.db.query(`INSERT INTO observations (
699
- session_id, project_id, type, title, narrative, facts, concepts,
700
- files_read, files_modified, quality, lifecycle, sensitivity,
701
- user_id, device_id, agent, created_at, created_at_epoch
702
- ) 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);
703
- const id = Number(result.lastInsertRowid);
704
- const row = this.getObservationById(id);
705
- this.ftsInsert(row);
706
- if (obs.session_id) {
707
- this.db.query("UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?").run(obs.session_id);
708
- }
709
- return row;
1085
+ };
1086
+ }
1087
+ function saveConfig(config) {
1088
+ if (!existsSync5(CONFIG_DIR)) {
1089
+ mkdirSync2(CONFIG_DIR, { recursive: true });
710
1090
  }
711
- getObservationById(id) {
712
- return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1091
+ writeFileSync2(SETTINGS_PATH, JSON.stringify(config, null, 2) + `
1092
+ `, "utf-8");
1093
+ }
1094
+ function configExists() {
1095
+ return existsSync5(SETTINGS_PATH);
1096
+ }
1097
+ function asString(value, fallback) {
1098
+ return typeof value === "string" ? value : fallback;
1099
+ }
1100
+ function asNumber(value, fallback) {
1101
+ return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
1102
+ }
1103
+ function asBool(value, fallback) {
1104
+ return typeof value === "boolean" ? value : fallback;
1105
+ }
1106
+ function asStringArray(value, fallback) {
1107
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
1108
+ }
1109
+ function asScope(value, fallback) {
1110
+ if (value === "personal" || value === "team" || value === "all")
1111
+ return value;
1112
+ return fallback;
1113
+ }
1114
+ function asSensitivity(value, fallback) {
1115
+ if (value === "shared" || value === "personal" || value === "secret")
1116
+ return value;
1117
+ return fallback;
1118
+ }
1119
+ function asSentinelMode(value, fallback) {
1120
+ if (value === "advisory" || value === "blocking")
1121
+ return value;
1122
+ return fallback;
1123
+ }
1124
+ function asLlmProvider(value, fallback) {
1125
+ if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
1126
+ return value;
1127
+ return fallback;
1128
+ }
1129
+ function asTier(value, fallback) {
1130
+ if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
1131
+ return value;
1132
+ return fallback;
1133
+ }
1134
+ function asObserverMode(value, fallback) {
1135
+ if (value === "per_event" || value === "per_session")
1136
+ return value;
1137
+ return fallback;
1138
+ }
1139
+ function asTeams(value, fallback) {
1140
+ if (!Array.isArray(value))
1141
+ return fallback;
1142
+ return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
1143
+ }
1144
+
1145
+ // src/sync/auth.ts
1146
+ function getApiKey(config) {
1147
+ const envKey = process.env.ENGRM_TOKEN;
1148
+ if (envKey && envKey.startsWith("cvk_"))
1149
+ return envKey;
1150
+ if (config.candengo_api_key && config.candengo_api_key.length > 0) {
1151
+ return config.candengo_api_key;
713
1152
  }
714
- getObservationsByIds(ids) {
715
- if (ids.length === 0)
716
- return [];
717
- const placeholders = ids.map(() => "?").join(",");
718
- return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
1153
+ return null;
1154
+ }
1155
+ function getBaseUrl(config) {
1156
+ if (config.candengo_url && config.candengo_url.length > 0) {
1157
+ return config.candengo_url;
719
1158
  }
720
- getRecentObservations(projectId, sincEpoch, limit = 50) {
721
- return this.db.query(`SELECT * FROM observations
722
- WHERE project_id = ? AND created_at_epoch > ?
723
- ORDER BY created_at_epoch DESC
724
- LIMIT ?`).all(projectId, sincEpoch, limit);
1159
+ return null;
1160
+ }
1161
+ function buildSourceId(config, localId, type = "obs") {
1162
+ return `${config.user_id}-${config.device_id}-${type}-${localId}`;
1163
+ }
1164
+ function parseSourceId(sourceId) {
1165
+ const obsIndex = sourceId.lastIndexOf("-obs-");
1166
+ if (obsIndex === -1)
1167
+ return null;
1168
+ const prefix = sourceId.slice(0, obsIndex);
1169
+ const localIdStr = sourceId.slice(obsIndex + 5);
1170
+ const localId = parseInt(localIdStr, 10);
1171
+ if (isNaN(localId))
1172
+ return null;
1173
+ const firstDash = prefix.indexOf("-");
1174
+ if (firstDash === -1)
1175
+ return null;
1176
+ return {
1177
+ userId: prefix.slice(0, firstDash),
1178
+ deviceId: prefix.slice(firstDash + 1),
1179
+ localId
1180
+ };
1181
+ }
1182
+
1183
+ // src/embeddings/embedder.ts
1184
+ var _available = null;
1185
+ var _pipeline = null;
1186
+ var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
1187
+ async function embedText(text) {
1188
+ const pipe = await getPipeline();
1189
+ if (!pipe)
1190
+ return null;
1191
+ try {
1192
+ const output = await pipe(text, { pooling: "mean", normalize: true });
1193
+ return new Float32Array(output.data);
1194
+ } catch {
1195
+ return null;
725
1196
  }
726
- searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
727
- const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
728
- if (projectId !== null) {
729
- return this.db.query(`SELECT o.id, observations_fts.rank
730
- FROM observations_fts
731
- JOIN observations o ON o.id = observations_fts.rowid
732
- WHERE observations_fts MATCH ?
733
- AND o.project_id = ?
734
- AND o.lifecycle IN (${lifecyclePlaceholders})
735
- ORDER BY observations_fts.rank
736
- LIMIT ?`).all(query, projectId, ...lifecycles, limit);
1197
+ }
1198
+ function composeEmbeddingText(obs) {
1199
+ const parts = [obs.title];
1200
+ if (obs.narrative)
1201
+ parts.push(obs.narrative);
1202
+ if (obs.facts) {
1203
+ try {
1204
+ const facts = JSON.parse(obs.facts);
1205
+ if (Array.isArray(facts) && facts.length > 0) {
1206
+ parts.push(facts.map((f) => `- ${f}`).join(`
1207
+ `));
1208
+ }
1209
+ } catch {
1210
+ parts.push(obs.facts);
737
1211
  }
738
- return this.db.query(`SELECT o.id, observations_fts.rank
739
- FROM observations_fts
740
- JOIN observations o ON o.id = observations_fts.rowid
741
- WHERE observations_fts MATCH ?
742
- AND o.lifecycle IN (${lifecyclePlaceholders})
743
- ORDER BY observations_fts.rank
744
- LIMIT ?`).all(query, ...lifecycles, limit);
745
1212
  }
746
- getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
747
- const anchor = this.getObservationById(anchorId);
748
- if (!anchor)
749
- return [];
750
- const projectFilter = projectId !== null ? "AND project_id = ?" : "";
751
- const projectParams = projectId !== null ? [projectId] : [];
752
- const before = this.db.query(`SELECT * FROM observations
753
- WHERE created_at_epoch < ? ${projectFilter}
754
- AND lifecycle IN ('active', 'aging', 'pinned')
755
- ORDER BY created_at_epoch DESC
756
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
757
- const after = this.db.query(`SELECT * FROM observations
758
- WHERE created_at_epoch > ? ${projectFilter}
759
- AND lifecycle IN ('active', 'aging', 'pinned')
760
- ORDER BY created_at_epoch ASC
761
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
762
- return [...before.reverse(), anchor, ...after];
1213
+ if (obs.concepts) {
1214
+ try {
1215
+ const concepts = JSON.parse(obs.concepts);
1216
+ if (Array.isArray(concepts) && concepts.length > 0) {
1217
+ parts.push(concepts.join(", "));
1218
+ }
1219
+ } catch {}
763
1220
  }
764
- pinObservation(id, pinned) {
765
- const obs = this.getObservationById(id);
766
- if (!obs)
767
- return false;
768
- if (pinned) {
769
- if (obs.lifecycle !== "active" && obs.lifecycle !== "aging")
770
- return false;
771
- this.db.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?").run(id);
772
- } else {
773
- if (obs.lifecycle !== "pinned")
774
- return false;
775
- this.db.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?").run(id);
776
- }
777
- return true;
1221
+ return parts.join(`
1222
+
1223
+ `);
1224
+ }
1225
+ async function getPipeline() {
1226
+ if (_pipeline)
1227
+ return _pipeline;
1228
+ if (_available === false)
1229
+ return null;
1230
+ try {
1231
+ const { pipeline } = await import("@xenova/transformers");
1232
+ _pipeline = await pipeline("feature-extraction", MODEL_NAME);
1233
+ _available = true;
1234
+ return _pipeline;
1235
+ } catch (err) {
1236
+ _available = false;
1237
+ console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
1238
+ return null;
778
1239
  }
779
- getActiveObservationCount(userId) {
780
- if (userId) {
781
- const result2 = this.db.query(`SELECT COUNT(*) as count FROM observations
782
- WHERE lifecycle IN ('active', 'aging')
783
- AND sensitivity != 'secret'
784
- AND user_id = ?`).get(userId);
785
- return result2?.count ?? 0;
1240
+ }
1241
+
1242
+ // src/sync/pull.ts
1243
+ var PULL_CURSOR_KEY = "pull_cursor";
1244
+ var MAX_PAGES = 20;
1245
+ async function pullFromVector(db, client, config, limit = 50) {
1246
+ let cursor = db.getSyncState(PULL_CURSOR_KEY) ?? undefined;
1247
+ let totalReceived = 0;
1248
+ let totalMerged = 0;
1249
+ let totalSkipped = 0;
1250
+ for (let page = 0;page < MAX_PAGES; page++) {
1251
+ const response = await client.pullChanges(cursor, limit);
1252
+ const { merged, skipped } = mergeChanges(db, config, response.changes);
1253
+ totalReceived += response.changes.length;
1254
+ totalMerged += merged;
1255
+ totalSkipped += skipped;
1256
+ if (response.cursor) {
1257
+ db.setSyncState(PULL_CURSOR_KEY, response.cursor);
1258
+ cursor = response.cursor;
786
1259
  }
787
- const result = this.db.query(`SELECT COUNT(*) as count FROM observations
788
- WHERE lifecycle IN ('active', 'aging')
789
- AND sensitivity != 'secret'`).get();
790
- return result?.count ?? 0;
1260
+ if (!response.has_more || response.changes.length === 0)
1261
+ break;
791
1262
  }
792
- supersedeObservation(oldId, newId) {
793
- if (oldId === newId)
794
- return false;
795
- const replacement = this.getObservationById(newId);
796
- if (!replacement)
797
- return false;
798
- let targetId = oldId;
799
- const visited = new Set;
800
- for (let depth = 0;depth < 10; depth++) {
801
- const target2 = this.getObservationById(targetId);
802
- if (!target2)
803
- return false;
804
- if (target2.superseded_by === null)
805
- break;
806
- if (target2.superseded_by === newId)
807
- return true;
808
- visited.add(targetId);
809
- targetId = target2.superseded_by;
810
- if (visited.has(targetId))
811
- return false;
1263
+ return { received: totalReceived, merged: totalMerged, skipped: totalSkipped };
1264
+ }
1265
+ function mergeChanges(db, config, changes) {
1266
+ let merged = 0;
1267
+ let skipped = 0;
1268
+ for (const change of changes) {
1269
+ const parsed = parseSourceId(change.source_id);
1270
+ if (parsed && parsed.deviceId === config.device_id) {
1271
+ skipped++;
1272
+ continue;
812
1273
  }
813
- const target = this.getObservationById(targetId);
814
- if (!target)
815
- return false;
816
- if (target.superseded_by !== null)
817
- return false;
818
- if (targetId === newId)
819
- return false;
820
- const now = Math.floor(Date.now() / 1000);
821
- this.db.query(`UPDATE observations
822
- SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
823
- WHERE id = ?`).run(newId, now, targetId);
824
- this.ftsDelete(target);
825
- this.vecDelete(targetId);
826
- return true;
827
- }
828
- isSuperseded(id) {
829
- const obs = this.getObservationById(id);
830
- return obs !== null && obs.superseded_by !== null;
831
- }
832
- upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
833
- const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
834
- if (existing)
835
- return existing;
836
- const now = Math.floor(Date.now() / 1000);
837
- this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
838
- VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
839
- return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1274
+ const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
1275
+ if (existing) {
1276
+ skipped++;
1277
+ continue;
1278
+ }
1279
+ const projectCanonical = change.metadata?.project_canonical ?? null;
1280
+ if (!projectCanonical) {
1281
+ skipped++;
1282
+ continue;
1283
+ }
1284
+ let project = db.getProjectByCanonicalId(projectCanonical);
1285
+ if (!project) {
1286
+ project = db.upsertProject({
1287
+ canonical_id: projectCanonical,
1288
+ name: change.metadata?.project_name ?? projectCanonical.split("/").pop() ?? "unknown"
1289
+ });
1290
+ }
1291
+ const obs = db.insertObservation({
1292
+ session_id: change.metadata?.session_id ?? null,
1293
+ project_id: project.id,
1294
+ type: change.metadata?.type ?? "discovery",
1295
+ title: change.metadata?.title ?? change.content.split(`
1296
+ `)[0] ?? "Untitled",
1297
+ narrative: extractNarrative(change.content),
1298
+ facts: change.metadata?.facts ? JSON.stringify(change.metadata.facts) : null,
1299
+ concepts: change.metadata?.concepts ? JSON.stringify(change.metadata.concepts) : null,
1300
+ quality: change.metadata?.quality ?? 0.5,
1301
+ lifecycle: "active",
1302
+ sensitivity: change.metadata?.sensitivity ?? "shared",
1303
+ user_id: change.metadata?.user_id ?? "unknown",
1304
+ device_id: change.metadata?.device_id ?? "unknown",
1305
+ agent: change.metadata?.agent ?? "unknown",
1306
+ created_at: change.metadata?.created_at ?? undefined,
1307
+ created_at_epoch: change.metadata?.created_at_epoch ?? undefined
1308
+ });
1309
+ db.db.query("UPDATE observations SET remote_source_id = ? WHERE id = ?").run(change.source_id, obs.id);
1310
+ if (db.vecAvailable) {
1311
+ embedAndInsert(db, obs).catch(() => {});
1312
+ }
1313
+ merged++;
840
1314
  }
841
- completeSession(sessionId) {
842
- const now = Math.floor(Date.now() / 1000);
843
- this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
1315
+ return { merged, skipped };
1316
+ }
1317
+ async function embedAndInsert(db, obs) {
1318
+ const text = composeEmbeddingText(obs);
1319
+ const embedding = await embedText(text);
1320
+ if (embedding)
1321
+ db.vecInsert(obs.id, embedding);
1322
+ }
1323
+ function extractNarrative(content) {
1324
+ const lines = content.split(`
1325
+ `);
1326
+ if (lines.length <= 1)
1327
+ return null;
1328
+ const narrative = lines.slice(1).join(`
1329
+ `).trim();
1330
+ return narrative.length > 0 ? narrative : null;
1331
+ }
1332
+ async function pullSettings(client, config) {
1333
+ try {
1334
+ const settings = await client.fetchSettings();
1335
+ if (!settings)
1336
+ return false;
1337
+ let changed = false;
1338
+ if (settings.transcript_analysis !== undefined) {
1339
+ const ta = settings.transcript_analysis;
1340
+ if (typeof ta === "object" && ta !== null) {
1341
+ const taObj = ta;
1342
+ if (taObj.enabled !== undefined && taObj.enabled !== config.transcript_analysis.enabled) {
1343
+ config.transcript_analysis.enabled = !!taObj.enabled;
1344
+ changed = true;
1345
+ }
1346
+ }
1347
+ }
1348
+ if (settings.observer !== undefined) {
1349
+ const obs = settings.observer;
1350
+ if (typeof obs === "object" && obs !== null) {
1351
+ const obsObj = obs;
1352
+ if (obsObj.enabled !== undefined && obsObj.enabled !== config.observer.enabled) {
1353
+ config.observer.enabled = !!obsObj.enabled;
1354
+ changed = true;
1355
+ }
1356
+ if (obsObj.model !== undefined && typeof obsObj.model === "string" && obsObj.model !== config.observer.model) {
1357
+ config.observer.model = obsObj.model;
1358
+ changed = true;
1359
+ }
1360
+ }
1361
+ }
1362
+ if (changed) {
1363
+ saveConfig(config);
1364
+ }
1365
+ return changed;
1366
+ } catch {
1367
+ return false;
844
1368
  }
845
- addToOutbox(recordType, recordId) {
846
- const now = Math.floor(Date.now() / 1000);
847
- this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
848
- VALUES (?, ?, ?)`).run(recordType, recordId, now);
1369
+ }
1370
+
1371
+ // src/sync/client.ts
1372
+ class VectorClient {
1373
+ baseUrl;
1374
+ apiKey;
1375
+ siteId;
1376
+ namespace;
1377
+ constructor(config) {
1378
+ const baseUrl = getBaseUrl(config);
1379
+ const apiKey = getApiKey(config);
1380
+ if (!baseUrl || !apiKey) {
1381
+ throw new Error("VectorClient requires candengo_url and candengo_api_key");
1382
+ }
1383
+ this.baseUrl = baseUrl.replace(/\/$/, "");
1384
+ this.apiKey = apiKey;
1385
+ this.siteId = config.site_id;
1386
+ this.namespace = config.namespace;
849
1387
  }
850
- getSyncState(key) {
851
- const row = this.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
852
- return row?.value ?? null;
1388
+ static isConfigured(config) {
1389
+ return getApiKey(config) !== null && getBaseUrl(config) !== null;
853
1390
  }
854
- setSyncState(key, value) {
855
- this.db.query("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
1391
+ async ingest(doc) {
1392
+ await this.request("POST", "/v1/ingest", doc);
856
1393
  }
857
- ftsInsert(obs) {
858
- this.db.query(`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
859
- VALUES (?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
1394
+ async batchIngest(docs) {
1395
+ if (docs.length === 0)
1396
+ return;
1397
+ await this.request("POST", "/v1/ingest/batch", { documents: docs });
860
1398
  }
861
- ftsDelete(obs) {
862
- this.db.query(`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
863
- VALUES ('delete', ?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
1399
+ async search(query, metadataFilter, limit = 10) {
1400
+ const body = { query, limit };
1401
+ if (metadataFilter) {
1402
+ body.metadata_filter = metadataFilter;
1403
+ }
1404
+ return this.request("POST", "/v1/search", body);
864
1405
  }
865
- vecInsert(observationId, embedding) {
866
- if (!this.vecAvailable)
1406
+ async deleteBySourceIds(sourceIds) {
1407
+ if (sourceIds.length === 0)
867
1408
  return;
868
- this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
1409
+ await this.request("POST", "/v1/documents/delete", {
1410
+ source_ids: sourceIds
1411
+ });
869
1412
  }
870
- vecDelete(observationId) {
871
- if (!this.vecAvailable)
872
- return;
873
- this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
1413
+ async pullChanges(cursor, limit = 50) {
1414
+ const params = new URLSearchParams;
1415
+ if (cursor)
1416
+ params.set("cursor", cursor);
1417
+ params.set("namespace", this.namespace);
1418
+ params.set("limit", String(limit));
1419
+ return this.request("GET", `/v1/sync/changes?${params.toString()}`);
874
1420
  }
875
- searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
876
- if (!this.vecAvailable)
877
- return [];
878
- const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
879
- const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
880
- if (projectId !== null) {
881
- return this.db.query(`SELECT v.observation_id, v.distance
882
- FROM vec_observations v
883
- JOIN observations o ON o.id = v.observation_id
884
- WHERE v.embedding MATCH ?
885
- AND k = ?
886
- AND o.project_id = ?
887
- AND o.lifecycle IN (${lifecyclePlaceholders})
888
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
1421
+ async sendTelemetry(beacon) {
1422
+ await this.request("POST", "/v1/mem/telemetry", beacon);
1423
+ }
1424
+ async fetchSettings() {
1425
+ try {
1426
+ return await this.request("GET", "/v1/mem/user-settings");
1427
+ } catch {
1428
+ return null;
889
1429
  }
890
- return this.db.query(`SELECT v.observation_id, v.distance
891
- FROM vec_observations v
892
- JOIN observations o ON o.id = v.observation_id
893
- WHERE v.embedding MATCH ?
894
- AND k = ?
895
- AND o.lifecycle IN (${lifecyclePlaceholders})
896
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
897
1430
  }
898
- getUnembeddedCount() {
899
- if (!this.vecAvailable)
900
- return 0;
901
- const result = this.db.query(`SELECT COUNT(*) as count FROM observations o
902
- WHERE o.lifecycle IN ('active', 'aging', 'pinned')
903
- AND o.superseded_by IS NULL
904
- AND NOT EXISTS (
905
- SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
906
- )`).get();
907
- return result?.count ?? 0;
1431
+ async health() {
1432
+ try {
1433
+ await this.request("GET", "/health");
1434
+ return true;
1435
+ } catch {
1436
+ return false;
1437
+ }
908
1438
  }
909
- getUnembeddedObservations(limit = 100) {
910
- if (!this.vecAvailable)
911
- return [];
912
- return this.db.query(`SELECT o.* FROM observations o
913
- WHERE o.lifecycle IN ('active', 'aging', 'pinned')
914
- AND o.superseded_by IS NULL
915
- AND NOT EXISTS (
916
- SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
917
- )
918
- ORDER BY o.created_at_epoch DESC
919
- LIMIT ?`).all(limit);
920
- }
921
- insertSessionSummary(summary) {
922
- const now = Math.floor(Date.now() / 1000);
923
- const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
924
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
925
- const id = Number(result.lastInsertRowid);
926
- return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
927
- }
928
- getSessionSummary(sessionId) {
929
- return this.db.query("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId) ?? null;
930
- }
931
- getRecentSummaries(projectId, limit = 5) {
932
- return this.db.query(`SELECT * FROM session_summaries
933
- WHERE project_id = ?
934
- ORDER BY created_at_epoch DESC, id DESC
935
- LIMIT ?`).all(projectId, limit);
936
- }
937
- incrementSessionMetrics(sessionId, increments) {
938
- const sets = [];
939
- const params = [];
940
- if (increments.files) {
941
- sets.push("files_touched_count = files_touched_count + ?");
942
- params.push(increments.files);
943
- }
944
- if (increments.searches) {
945
- sets.push("searches_performed = searches_performed + ?");
946
- params.push(increments.searches);
1439
+ async request(method, path, body) {
1440
+ const url = `${this.baseUrl}${path}`;
1441
+ const headers = {
1442
+ Authorization: `Bearer ${this.apiKey}`,
1443
+ "Content-Type": "application/json"
1444
+ };
1445
+ const init = { method, headers };
1446
+ if (body && method !== "GET") {
1447
+ init.body = JSON.stringify(body);
947
1448
  }
948
- if (increments.toolCalls) {
949
- sets.push("tool_calls_count = tool_calls_count + ?");
950
- params.push(increments.toolCalls);
1449
+ const response = await fetch(url, init);
1450
+ if (!response.ok) {
1451
+ const text = await response.text().catch(() => "");
1452
+ throw new VectorApiError(response.status, text, path);
951
1453
  }
952
- if (sets.length === 0)
1454
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
953
1455
  return;
954
- params.push(sessionId);
955
- this.db.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`).run(...params);
956
- }
957
- getSessionMetrics(sessionId) {
958
- return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
959
- }
960
- insertSecurityFinding(finding) {
961
- const now = Math.floor(Date.now() / 1000);
962
- 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)
963
- 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);
964
- const id = Number(result.lastInsertRowid);
965
- return this.db.query("SELECT * FROM security_findings WHERE id = ?").get(id);
966
- }
967
- getSecurityFindings(projectId, options = {}) {
968
- const limit = options.limit ?? 50;
969
- if (options.severity) {
970
- return this.db.query(`SELECT * FROM security_findings
971
- WHERE project_id = ? AND severity = ?
972
- ORDER BY created_at_epoch DESC
973
- LIMIT ?`).all(projectId, options.severity, limit);
974
- }
975
- return this.db.query(`SELECT * FROM security_findings
976
- WHERE project_id = ?
977
- ORDER BY created_at_epoch DESC
978
- LIMIT ?`).all(projectId, limit);
979
- }
980
- getSecurityFindingsCount(projectId) {
981
- const rows = this.db.query(`SELECT severity, COUNT(*) as count FROM security_findings
982
- WHERE project_id = ?
983
- GROUP BY severity`).all(projectId);
984
- const counts = {
985
- critical: 0,
986
- high: 0,
987
- medium: 0,
988
- low: 0
989
- };
990
- for (const row of rows) {
991
- counts[row.severity] = row.count;
992
- }
993
- return counts;
994
- }
995
- setSessionRiskScore(sessionId, score) {
996
- this.db.query("UPDATE sessions SET risk_score = ? WHERE session_id = ?").run(score, sessionId);
997
- }
998
- getObservationsBySession(sessionId) {
999
- return this.db.query(`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`).all(sessionId);
1000
- }
1001
- getInstalledPacks() {
1002
- try {
1003
- const rows = this.db.query("SELECT name FROM packs_installed").all();
1004
- return rows.map((r) => r.name);
1005
- } catch {
1006
- return [];
1007
1456
  }
1008
- }
1009
- markPackInstalled(name, observationCount) {
1010
- const now = Math.floor(Date.now() / 1000);
1011
- this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
1457
+ return response.json();
1012
1458
  }
1013
1459
  }
1014
1460
 
1015
- // src/storage/projects.ts
1016
- import { execSync } from "node:child_process";
1017
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1018
- import { basename, join as join2 } from "node:path";
1019
- function normaliseGitRemoteUrl(remoteUrl) {
1020
- let url = remoteUrl.trim();
1021
- url = url.replace(/^(?:https?|ssh|git):\/\//, "");
1022
- url = url.replace(/^[^@]+@/, "");
1023
- url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
1024
- url = url.replace(/\.git$/, "");
1025
- url = url.replace(/\/+$/, "");
1026
- const slashIndex = url.indexOf("/");
1027
- if (slashIndex !== -1) {
1028
- const host = url.substring(0, slashIndex).toLowerCase();
1029
- const path = url.substring(slashIndex);
1030
- url = host + path;
1031
- } else {
1032
- url = url.toLowerCase();
1033
- }
1034
- return url;
1035
- }
1036
- function projectNameFromCanonicalId(canonicalId) {
1037
- const parts = canonicalId.split("/");
1038
- return parts[parts.length - 1] ?? canonicalId;
1039
- }
1040
- function getGitRemoteUrl(directory) {
1041
- try {
1042
- const url = execSync("git remote get-url origin", {
1043
- cwd: directory,
1044
- encoding: "utf-8",
1045
- timeout: 5000,
1046
- stdio: ["pipe", "pipe", "pipe"]
1047
- }).trim();
1048
- return url || null;
1049
- } catch {
1050
- try {
1051
- const remotes = execSync("git remote", {
1052
- cwd: directory,
1053
- encoding: "utf-8",
1054
- timeout: 5000,
1055
- stdio: ["pipe", "pipe", "pipe"]
1056
- }).trim().split(`
1057
- `).filter(Boolean);
1058
- if (remotes.length === 0)
1059
- return null;
1060
- const url = execSync(`git remote get-url ${remotes[0]}`, {
1061
- cwd: directory,
1062
- encoding: "utf-8",
1063
- timeout: 5000,
1064
- stdio: ["pipe", "pipe", "pipe"]
1065
- }).trim();
1066
- return url || null;
1067
- } catch {
1068
- return null;
1069
- }
1070
- }
1071
- }
1072
- function readProjectConfigFile(directory) {
1073
- const configPath = join2(directory, ".engrm.json");
1074
- if (!existsSync2(configPath))
1075
- return null;
1076
- try {
1077
- const raw = readFileSync2(configPath, "utf-8");
1078
- const parsed = JSON.parse(raw);
1079
- if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
1080
- return null;
1081
- }
1082
- return {
1083
- project_id: parsed["project_id"],
1084
- name: typeof parsed["name"] === "string" ? parsed["name"] : undefined
1085
- };
1086
- } catch {
1087
- return null;
1088
- }
1089
- }
1090
- function detectProject(directory) {
1091
- const remoteUrl = getGitRemoteUrl(directory);
1092
- if (remoteUrl) {
1093
- const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1094
- return {
1095
- canonical_id: canonicalId,
1096
- name: projectNameFromCanonicalId(canonicalId),
1097
- remote_url: remoteUrl,
1098
- local_path: directory
1099
- };
1100
- }
1101
- const configFile = readProjectConfigFile(directory);
1102
- if (configFile) {
1103
- return {
1104
- canonical_id: configFile.project_id,
1105
- name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
1106
- remote_url: null,
1107
- local_path: directory
1108
- };
1461
+ class VectorApiError extends Error {
1462
+ status;
1463
+ body;
1464
+ path;
1465
+ constructor(status, body, path) {
1466
+ super(`Vector API error ${status} on ${path}: ${body}`);
1467
+ this.status = status;
1468
+ this.body = body;
1469
+ this.path = path;
1470
+ this.name = "VectorApiError";
1109
1471
  }
1110
- const dirName = basename(directory);
1111
- return {
1112
- canonical_id: `local/${dirName}`,
1113
- name: dirName,
1114
- remote_url: null,
1115
- local_path: directory
1116
- };
1117
1472
  }
1118
1473
 
1119
- // src/context/inject.ts
1120
- var RECENCY_WINDOW_SECONDS = 30 * 86400;
1121
- function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
1122
- const age = nowEpoch - createdAtEpoch;
1123
- const recencyNorm = Math.max(0, Math.min(1, 1 - age / RECENCY_WINDOW_SECONDS));
1124
- return quality * 0.6 + recencyNorm * 0.4;
1125
- }
1126
- function estimateTokens(text) {
1127
- if (!text)
1128
- return 0;
1129
- return Math.ceil(text.length / 4);
1130
- }
1131
- function buildSessionContext(db, cwd, options = {}) {
1132
- const opts = typeof options === "number" ? { maxCount: options } : options;
1133
- const tokenBudget = opts.tokenBudget ?? 3000;
1134
- const maxCount = opts.maxCount;
1135
- const detected = detectProject(cwd);
1136
- const project = db.getProjectByCanonicalId(detected.canonical_id);
1137
- if (!project) {
1138
- return {
1139
- project_name: detected.name,
1140
- canonical_id: detected.canonical_id,
1141
- observations: [],
1142
- session_count: 0,
1143
- total_active: 0
1144
- };
1145
- }
1146
- const totalActive = (db.db.query(`SELECT COUNT(*) as c FROM observations
1147
- WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
1148
- AND superseded_by IS NULL`).get(project.id) ?? { c: 0 }).c;
1149
- const MAX_PINNED = 5;
1150
- const pinned = db.db.query(`SELECT * FROM observations
1151
- WHERE project_id = ? AND lifecycle = 'pinned'
1152
- AND superseded_by IS NULL
1153
- ORDER BY quality DESC, created_at_epoch DESC
1154
- LIMIT ?`).all(project.id, MAX_PINNED);
1155
- const MAX_RECENT = 5;
1156
- const recent = db.db.query(`SELECT * FROM observations
1157
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1158
- AND superseded_by IS NULL
1159
- ORDER BY created_at_epoch DESC
1160
- LIMIT ?`).all(project.id, MAX_RECENT);
1161
- const candidateLimit = maxCount ?? 50;
1162
- const candidates = db.db.query(`SELECT * FROM observations
1163
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1164
- AND quality >= 0.3
1165
- AND superseded_by IS NULL
1166
- ORDER BY quality DESC, created_at_epoch DESC
1167
- LIMIT ?`).all(project.id, candidateLimit);
1168
- let crossProjectCandidates = [];
1169
- if (opts.scope === "all") {
1170
- const crossLimit = Math.max(10, Math.floor(candidateLimit / 3));
1171
- const rawCross = db.db.query(`SELECT * FROM observations
1172
- WHERE project_id != ? AND lifecycle IN ('active', 'aging')
1173
- AND quality >= 0.5
1174
- AND superseded_by IS NULL
1175
- ORDER BY quality DESC, created_at_epoch DESC
1176
- LIMIT ?`).all(project.id, crossLimit);
1177
- const projectNameCache = new Map;
1178
- crossProjectCandidates = rawCross.map((obs) => {
1179
- if (!projectNameCache.has(obs.project_id)) {
1180
- const proj = db.getProjectById(obs.project_id);
1181
- if (proj)
1182
- projectNameCache.set(obs.project_id, proj.name);
1183
- }
1184
- return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
1185
- });
1186
- }
1187
- const seenIds = new Set(pinned.map((o) => o.id));
1188
- const dedupedRecent = recent.filter((o) => {
1189
- if (seenIds.has(o.id))
1190
- return false;
1191
- seenIds.add(o.id);
1192
- return true;
1193
- });
1194
- const deduped = candidates.filter((o) => !seenIds.has(o.id));
1195
- for (const obs of crossProjectCandidates) {
1196
- if (!seenIds.has(obs.id)) {
1197
- seenIds.add(obs.id);
1198
- deduped.push(obs);
1199
- }
1200
- }
1201
- const nowEpoch = Math.floor(Date.now() / 1000);
1202
- const sorted = [...deduped].sort((a, b) => {
1203
- const boostA = a.type === "digest" ? 0.15 : 0;
1204
- const boostB = b.type === "digest" ? 0.15 : 0;
1205
- const scoreA = computeBlendedScore(a.quality, a.created_at_epoch, nowEpoch) + boostA;
1206
- const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
1207
- return scoreB - scoreA;
1208
- });
1209
- if (maxCount !== undefined) {
1210
- const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1211
- const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1212
- return {
1213
- project_name: project.name,
1214
- canonical_id: project.canonical_id,
1215
- observations: all.map(toContextObservation),
1216
- session_count: all.length,
1217
- total_active: totalActive
1218
- };
1219
- }
1220
- let remainingBudget = tokenBudget - 30;
1221
- const selected = [];
1222
- for (const obs of pinned) {
1223
- const cost = estimateObservationTokens(obs, selected.length);
1224
- remainingBudget -= cost;
1225
- selected.push(obs);
1226
- }
1227
- for (const obs of dedupedRecent) {
1228
- const cost = estimateObservationTokens(obs, selected.length);
1229
- remainingBudget -= cost;
1230
- selected.push(obs);
1231
- }
1232
- for (const obs of sorted) {
1233
- const cost = estimateObservationTokens(obs, selected.length);
1234
- if (remainingBudget - cost < 0 && selected.length > 0)
1235
- break;
1236
- remainingBudget -= cost;
1237
- selected.push(obs);
1238
- }
1239
- const summaries = db.getRecentSummaries(project.id, 5);
1240
- let securityFindings = [];
1241
- try {
1242
- const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
1243
- securityFindings = db.db.query(`SELECT * FROM security_findings
1244
- WHERE project_id = ? AND created_at_epoch > ?
1245
- ORDER BY severity DESC, created_at_epoch DESC
1246
- LIMIT ?`).all(project.id, weekAgo, 10);
1247
- } catch {}
1248
- return {
1249
- project_name: project.name,
1250
- canonical_id: project.canonical_id,
1251
- observations: selected.map(toContextObservation),
1252
- session_count: selected.length,
1253
- total_active: totalActive,
1254
- summaries: summaries.length > 0 ? summaries : undefined,
1255
- securityFindings: securityFindings.length > 0 ? securityFindings : undefined
1256
- };
1257
- }
1258
- function estimateObservationTokens(obs, index) {
1259
- const DETAILED_THRESHOLD = 5;
1260
- const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1261
- if (index >= DETAILED_THRESHOLD) {
1262
- return titleCost;
1263
- }
1264
- const detailText = formatObservationDetail(obs);
1265
- return titleCost + estimateTokens(detailText);
1266
- }
1267
- function formatContextForInjection(context) {
1268
- if (context.observations.length === 0) {
1269
- return `Project: ${context.project_name} (no prior observations)`;
1270
- }
1271
- const DETAILED_COUNT = 5;
1272
- const lines = [
1273
- `## Project Memory: ${context.project_name}`,
1274
- `${context.session_count} relevant observation(s) from prior sessions:`,
1275
- ""
1276
- ];
1277
- for (let i = 0;i < context.observations.length; i++) {
1278
- const obs = context.observations[i];
1279
- const date = obs.created_at.split("T")[0];
1280
- const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
1281
- lines.push(`- **[${obs.type}]** ${obs.title} (${date}, q=${obs.quality.toFixed(1)})${fromLabel}`);
1282
- if (i < DETAILED_COUNT) {
1283
- const detail = formatObservationDetailFromContext(obs);
1284
- if (detail) {
1285
- lines.push(detail);
1286
- }
1287
- }
1288
- }
1289
- if (context.summaries && context.summaries.length > 0) {
1290
- lines.push("");
1291
- lines.push("Lessons from recent sessions:");
1292
- for (const summary of context.summaries) {
1293
- if (summary.request) {
1294
- lines.push(`- Request: ${summary.request}`);
1295
- }
1296
- if (summary.learned) {
1297
- lines.push(` Learned: ${truncateText(summary.learned, 100)}`);
1298
- }
1299
- if (summary.next_steps) {
1300
- lines.push(` Next: ${truncateText(summary.next_steps, 80)}`);
1301
- }
1302
- }
1303
- }
1304
- if (context.securityFindings && context.securityFindings.length > 0) {
1305
- lines.push("");
1306
- lines.push("Security findings (recent):");
1307
- for (const finding of context.securityFindings) {
1308
- const date = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
1309
- const file = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
1310
- lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file} (${date})`);
1311
- }
1312
- }
1313
- const remaining = context.total_active - context.session_count;
1314
- if (remaining > 0) {
1315
- lines.push("");
1316
- lines.push(`${remaining} more observation(s) available via search tool.`);
1317
- }
1318
- return lines.join(`
1319
- `);
1320
- }
1321
- function truncateText(text, maxLen) {
1322
- if (text.length <= maxLen)
1323
- return text;
1324
- return text.slice(0, maxLen - 3) + "...";
1325
- }
1326
- function formatObservationDetailFromContext(obs) {
1327
- if (obs.facts) {
1328
- const bullets = parseFacts(obs.facts);
1329
- if (bullets.length > 0) {
1330
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
1331
- `);
1332
- }
1333
- }
1334
- if (obs.narrative) {
1335
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
1336
- return ` ${snippet}`;
1337
- }
1338
- return null;
1339
- }
1340
- function formatObservationDetail(obs) {
1341
- if (obs.facts) {
1342
- const bullets = parseFacts(obs.facts);
1343
- if (bullets.length > 0) {
1344
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
1345
- `);
1346
- }
1347
- }
1348
- if (obs.narrative) {
1349
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
1350
- return ` ${snippet}`;
1351
- }
1352
- return "";
1353
- }
1354
- function parseFacts(facts) {
1355
- if (!facts)
1356
- return [];
1357
- try {
1358
- const parsed = JSON.parse(facts);
1359
- if (Array.isArray(parsed)) {
1360
- return parsed.filter((f) => typeof f === "string" && f.length > 0);
1361
- }
1362
- } catch {
1363
- if (facts.trim().length > 0) {
1364
- return [facts.trim()];
1365
- }
1366
- }
1367
- return [];
1368
- }
1369
- function toContextObservation(obs) {
1370
- return {
1371
- id: obs.id,
1372
- type: obs.type,
1373
- title: obs.title,
1374
- narrative: obs.narrative,
1375
- facts: obs.facts,
1376
- quality: obs.quality,
1377
- created_at: obs.created_at,
1378
- ...obs._source_project ? { source_project: obs._source_project } : {}
1379
- };
1380
- }
1474
+ // src/storage/migrations.ts
1475
+ var MIGRATIONS = [
1476
+ {
1477
+ version: 1,
1478
+ description: "Initial schema: projects, observations, sessions, sync, FTS5",
1479
+ sql: `
1480
+ -- Projects (canonical identity across machines)
1481
+ CREATE TABLE projects (
1482
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1483
+ canonical_id TEXT UNIQUE NOT NULL,
1484
+ name TEXT NOT NULL,
1485
+ local_path TEXT,
1486
+ remote_url TEXT,
1487
+ first_seen_epoch INTEGER NOT NULL,
1488
+ last_active_epoch INTEGER NOT NULL
1489
+ );
1490
+
1491
+ -- Core observations table
1492
+ CREATE TABLE observations (
1493
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1494
+ session_id TEXT,
1495
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1496
+ type TEXT NOT NULL CHECK (type IN (
1497
+ 'bugfix', 'discovery', 'decision', 'pattern',
1498
+ 'change', 'feature', 'refactor', 'digest'
1499
+ )),
1500
+ title TEXT NOT NULL,
1501
+ narrative TEXT,
1502
+ facts TEXT,
1503
+ concepts TEXT,
1504
+ files_read TEXT,
1505
+ files_modified TEXT,
1506
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1507
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1508
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1509
+ )),
1510
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1511
+ 'shared', 'personal', 'secret'
1512
+ )),
1513
+ user_id TEXT NOT NULL,
1514
+ device_id TEXT NOT NULL,
1515
+ agent TEXT DEFAULT 'claude-code',
1516
+ created_at TEXT NOT NULL,
1517
+ created_at_epoch INTEGER NOT NULL,
1518
+ archived_at_epoch INTEGER,
1519
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
1520
+ );
1521
+
1522
+ -- Session tracking
1523
+ CREATE TABLE sessions (
1524
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1525
+ session_id TEXT UNIQUE NOT NULL,
1526
+ project_id INTEGER REFERENCES projects(id),
1527
+ user_id TEXT NOT NULL,
1528
+ device_id TEXT NOT NULL,
1529
+ agent TEXT DEFAULT 'claude-code',
1530
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
1531
+ observation_count INTEGER DEFAULT 0,
1532
+ started_at_epoch INTEGER,
1533
+ completed_at_epoch INTEGER
1534
+ );
1535
+
1536
+ -- Session summaries (generated on Stop hook)
1537
+ CREATE TABLE session_summaries (
1538
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1539
+ session_id TEXT UNIQUE NOT NULL,
1540
+ project_id INTEGER REFERENCES projects(id),
1541
+ user_id TEXT NOT NULL,
1542
+ request TEXT,
1543
+ investigated TEXT,
1544
+ learned TEXT,
1545
+ completed TEXT,
1546
+ next_steps TEXT,
1547
+ created_at_epoch INTEGER
1548
+ );
1549
+
1550
+ -- Sync outbox (offline-first queue)
1551
+ CREATE TABLE sync_outbox (
1552
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1553
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
1554
+ record_id INTEGER NOT NULL,
1555
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1556
+ 'pending', 'syncing', 'synced', 'failed'
1557
+ )),
1558
+ retry_count INTEGER DEFAULT 0,
1559
+ max_retries INTEGER DEFAULT 10,
1560
+ last_error TEXT,
1561
+ created_at_epoch INTEGER NOT NULL,
1562
+ synced_at_epoch INTEGER,
1563
+ next_retry_epoch INTEGER
1564
+ );
1565
+
1566
+ -- Sync high-water mark and lifecycle job tracking
1567
+ CREATE TABLE sync_state (
1568
+ key TEXT PRIMARY KEY,
1569
+ value TEXT NOT NULL
1570
+ );
1571
+
1572
+ -- FTS5 for local offline search (external content mode)
1573
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1574
+ title, narrative, facts, concepts,
1575
+ content=observations,
1576
+ content_rowid=id
1577
+ );
1381
1578
 
1382
- // src/telemetry/stack-detect.ts
1383
- import { existsSync as existsSync3 } from "node:fs";
1384
- import { join as join3, extname } from "node:path";
1385
- var EXTENSION_MAP = {
1386
- ".tsx": "react",
1387
- ".jsx": "react",
1388
- ".ts": "typescript",
1389
- ".js": "javascript",
1390
- ".py": "python",
1391
- ".rs": "rust",
1392
- ".go": "go",
1393
- ".java": "java",
1394
- ".kt": "kotlin",
1395
- ".swift": "swift",
1396
- ".rb": "ruby",
1397
- ".php": "php",
1398
- ".cs": "csharp",
1399
- ".cpp": "cpp",
1400
- ".c": "c",
1401
- ".zig": "zig",
1402
- ".ex": "elixir",
1403
- ".exs": "elixir",
1404
- ".erl": "erlang",
1405
- ".hs": "haskell",
1406
- ".lua": "lua",
1407
- ".dart": "dart",
1408
- ".scala": "scala",
1409
- ".clj": "clojure",
1410
- ".vue": "vue",
1411
- ".svelte": "svelte",
1412
- ".astro": "astro"
1413
- };
1414
- var CONFIG_FILE_STACKS = [
1415
- ["next.config.js", "nextjs"],
1416
- ["next.config.mjs", "nextjs"],
1417
- ["next.config.ts", "nextjs"],
1418
- ["nuxt.config.ts", "nuxt"],
1419
- ["nuxt.config.js", "nuxt"],
1420
- ["vite.config.ts", "vite"],
1421
- ["vite.config.js", "vite"],
1422
- ["vite.config.mjs", "vite"],
1423
- ["svelte.config.js", "svelte"],
1424
- ["astro.config.mjs", "astro"],
1425
- ["remix.config.js", "remix"],
1426
- ["angular.json", "angular"],
1427
- ["tailwind.config.js", "tailwind"],
1428
- ["tailwind.config.ts", "tailwind"],
1429
- ["postcss.config.js", "postcss"],
1430
- ["webpack.config.js", "webpack"],
1431
- ["tsconfig.json", "typescript"],
1432
- ["Cargo.toml", "rust"],
1433
- ["go.mod", "go"],
1434
- ["pyproject.toml", "python"],
1435
- ["setup.py", "python"],
1436
- ["requirements.txt", "python"],
1437
- ["Pipfile", "python"],
1438
- ["manage.py", "django"],
1439
- ["Gemfile", "ruby"],
1440
- ["composer.json", "php"],
1441
- ["pom.xml", "java"],
1442
- ["build.gradle", "gradle"],
1443
- ["build.gradle.kts", "gradle"],
1444
- ["Package.swift", "swift"],
1445
- ["pubspec.yaml", "flutter"],
1446
- ["mix.exs", "elixir"],
1447
- ["deno.json", "deno"],
1448
- ["bun.lock", "bun"],
1449
- ["bun.lockb", "bun"],
1450
- ["docker-compose.yml", "docker"],
1451
- ["docker-compose.yaml", "docker"],
1452
- ["Dockerfile", "docker"],
1453
- [".prisma/schema.prisma", "prisma"],
1454
- ["prisma/schema.prisma", "prisma"],
1455
- ["drizzle.config.ts", "drizzle"]
1456
- ];
1457
- var PATH_PATTERN_STACKS = [
1458
- ["/__tests__/", "jest"],
1459
- ["/.storybook/", "storybook"],
1460
- ["/cypress/", "cypress"],
1461
- ["/playwright/", "playwright"],
1462
- ["/terraform/", "terraform"],
1463
- ["/k8s/", "kubernetes"],
1464
- ["/helm/", "helm"]
1465
- ];
1466
- function detectStacks(filePaths) {
1467
- const stacks = new Set;
1468
- for (const fp of filePaths) {
1469
- const ext = extname(fp).toLowerCase();
1470
- if (ext && EXTENSION_MAP[ext]) {
1471
- stacks.add(EXTENSION_MAP[ext]);
1472
- }
1473
- for (const [pattern, stack] of PATH_PATTERN_STACKS) {
1474
- if (fp.includes(pattern)) {
1475
- stacks.add(stack);
1476
- }
1477
- }
1478
- }
1479
- return Array.from(stacks).sort();
1480
- }
1481
- function detectStacksFromProject(projectRoot, filePaths = []) {
1482
- const stacks = new Set(detectStacks(filePaths));
1483
- for (const [configFile, stack] of CONFIG_FILE_STACKS) {
1484
- try {
1485
- if (existsSync3(join3(projectRoot, configFile))) {
1486
- stacks.add(stack);
1487
- }
1488
- } catch {}
1489
- }
1490
- const sorted = Array.from(stacks).sort();
1491
- const frameworks = ["nextjs", "nuxt", "remix", "angular", "django", "flutter", "svelte", "astro"];
1492
- const primary = sorted.find((s) => frameworks.includes(s)) ?? sorted[0] ?? "unknown";
1493
- return { stacks: sorted, primary };
1494
- }
1579
+ -- Indexes: observations
1580
+ CREATE INDEX idx_observations_project ON observations(project_id);
1581
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1582
+ CREATE INDEX idx_observations_type ON observations(type);
1583
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1584
+ CREATE INDEX idx_observations_session ON observations(session_id);
1585
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1586
+ CREATE INDEX idx_observations_quality ON observations(quality);
1587
+ CREATE INDEX idx_observations_user ON observations(user_id);
1495
1588
 
1496
- // src/packs/recommender.ts
1497
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3 } from "node:fs";
1498
- import { join as join4, basename as basename3, dirname } from "node:path";
1499
- import { fileURLToPath } from "node:url";
1500
- var STACK_PACK_MAP = {
1501
- typescript: ["typescript-patterns"],
1502
- react: ["react-gotchas"],
1503
- nextjs: ["nextjs-patterns"],
1504
- python: ["python-django"],
1505
- django: ["python-django"],
1506
- javascript: ["node-security"],
1507
- bun: ["node-security"]
1508
- };
1509
- function getPacksDir() {
1510
- const thisDir = dirname(fileURLToPath(import.meta.url));
1511
- return join4(thisDir, "../../packs");
1512
- }
1513
- function listAvailablePacks() {
1514
- const dir = getPacksDir();
1515
- if (!existsSync4(dir))
1516
- return [];
1517
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => basename3(f, ".json"));
1518
- }
1519
- function loadPack(name) {
1520
- const packPath = join4(getPacksDir(), `${name}.json`);
1521
- if (!existsSync4(packPath))
1522
- return null;
1523
- try {
1524
- const raw = readFileSync3(packPath, "utf-8");
1525
- return JSON.parse(raw);
1526
- } catch {
1527
- return null;
1528
- }
1529
- }
1530
- function recommendPacks(stacks, installedPacks) {
1531
- const installed = new Set(installedPacks);
1532
- const available = listAvailablePacks();
1533
- const availableSet = new Set(available);
1534
- const seen = new Set;
1535
- const recommendations = [];
1536
- for (const stack of stacks) {
1537
- const packNames = STACK_PACK_MAP[stack] ?? [];
1538
- for (const packName of packNames) {
1539
- if (seen.has(packName) || installed.has(packName) || !availableSet.has(packName)) {
1540
- continue;
1541
- }
1542
- seen.add(packName);
1543
- const pack = loadPack(packName);
1544
- if (!pack)
1545
- continue;
1546
- const matchedStacks = stacks.filter((s) => STACK_PACK_MAP[s]?.includes(packName));
1547
- recommendations.push({
1548
- name: packName,
1549
- description: pack.description,
1550
- observationCount: pack.observations.length,
1551
- matchedStacks
1552
- });
1553
- }
1554
- }
1555
- return recommendations;
1556
- }
1589
+ -- Indexes: sessions
1590
+ CREATE INDEX idx_sessions_project ON sessions(project_id);
1591
+
1592
+ -- Indexes: sync outbox
1593
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1594
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1595
+ `
1596
+ },
1597
+ {
1598
+ version: 2,
1599
+ description: "Add superseded_by for knowledge supersession",
1600
+ sql: `
1601
+ ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
1602
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1603
+ `
1604
+ },
1605
+ {
1606
+ version: 3,
1607
+ description: "Add remote_source_id for pull deduplication",
1608
+ sql: `
1609
+ ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
1610
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1611
+ `
1612
+ },
1613
+ {
1614
+ version: 4,
1615
+ description: "Add sqlite-vec for local semantic search",
1616
+ sql: `
1617
+ CREATE VIRTUAL TABLE vec_observations USING vec0(
1618
+ observation_id INTEGER PRIMARY KEY,
1619
+ embedding float[384]
1620
+ );
1621
+ `,
1622
+ condition: (db) => isVecExtensionLoaded(db)
1623
+ },
1624
+ {
1625
+ version: 5,
1626
+ description: "Session metrics and security findings",
1627
+ sql: `
1628
+ ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
1629
+ ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
1630
+ ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
1631
+
1632
+ CREATE TABLE security_findings (
1633
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1634
+ session_id TEXT,
1635
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1636
+ finding_type TEXT NOT NULL,
1637
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
1638
+ pattern_name TEXT NOT NULL,
1639
+ file_path TEXT,
1640
+ snippet TEXT,
1641
+ tool_name TEXT,
1642
+ user_id TEXT NOT NULL,
1643
+ device_id TEXT NOT NULL,
1644
+ created_at_epoch INTEGER NOT NULL
1645
+ );
1646
+
1647
+ CREATE INDEX idx_security_findings_session ON security_findings(session_id);
1648
+ CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
1649
+ CREATE INDEX idx_security_findings_severity ON security_findings(severity);
1650
+ `
1651
+ },
1652
+ {
1653
+ version: 6,
1654
+ description: "Add risk_score, expand observation types to include standard",
1655
+ sql: `
1656
+ ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
1657
+
1658
+ -- Recreate observations table with expanded type CHECK to include 'standard'
1659
+ -- SQLite doesn't support ALTER CHECK, so we recreate the table
1660
+ CREATE TABLE observations_new (
1661
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1662
+ session_id TEXT,
1663
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1664
+ type TEXT NOT NULL CHECK (type IN (
1665
+ 'bugfix', 'discovery', 'decision', 'pattern',
1666
+ 'change', 'feature', 'refactor', 'digest', 'standard'
1667
+ )),
1668
+ title TEXT NOT NULL,
1669
+ narrative TEXT,
1670
+ facts TEXT,
1671
+ concepts TEXT,
1672
+ files_read TEXT,
1673
+ files_modified TEXT,
1674
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1675
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1676
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1677
+ )),
1678
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1679
+ 'shared', 'personal', 'secret'
1680
+ )),
1681
+ user_id TEXT NOT NULL,
1682
+ device_id TEXT NOT NULL,
1683
+ agent TEXT DEFAULT 'claude-code',
1684
+ created_at TEXT NOT NULL,
1685
+ created_at_epoch INTEGER NOT NULL,
1686
+ archived_at_epoch INTEGER,
1687
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1688
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1689
+ remote_source_id TEXT
1690
+ );
1557
1691
 
1558
- // src/config.ts
1559
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1560
- import { homedir as homedir2, hostname as hostname2, networkInterfaces as networkInterfaces2 } from "node:os";
1561
- import { join as join5 } from "node:path";
1562
- import { createHash as createHash2 } from "node:crypto";
1563
- var CONFIG_DIR2 = join5(homedir2(), ".engrm");
1564
- var SETTINGS_PATH2 = join5(CONFIG_DIR2, "settings.json");
1565
- var DB_PATH2 = join5(CONFIG_DIR2, "engrm.db");
1566
- function getDbPath2() {
1567
- return DB_PATH2;
1568
- }
1569
- function generateDeviceId2() {
1570
- const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
1571
- let mac = "";
1572
- const ifaces = networkInterfaces2();
1573
- for (const entries of Object.values(ifaces)) {
1574
- if (!entries)
1575
- continue;
1576
- for (const entry of entries) {
1577
- if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
1578
- mac = entry.mac;
1579
- break;
1580
- }
1581
- }
1582
- if (mac)
1583
- break;
1584
- }
1585
- const material = `${host}:${mac || "no-mac"}`;
1586
- const suffix = createHash2("sha256").update(material).digest("hex").slice(0, 8);
1587
- return `${host}-${suffix}`;
1588
- }
1589
- function createDefaultConfig2() {
1590
- return {
1591
- candengo_url: "",
1592
- candengo_api_key: "",
1593
- site_id: "",
1594
- namespace: "",
1595
- user_id: "",
1596
- user_email: "",
1597
- device_id: generateDeviceId2(),
1598
- teams: [],
1599
- sync: {
1600
- enabled: true,
1601
- interval_seconds: 30,
1602
- batch_size: 50
1603
- },
1604
- search: {
1605
- default_limit: 10,
1606
- local_boost: 1.2,
1607
- scope: "all"
1608
- },
1609
- scrubbing: {
1610
- enabled: true,
1611
- custom_patterns: [],
1612
- default_sensitivity: "shared"
1613
- },
1614
- sentinel: {
1615
- enabled: false,
1616
- mode: "advisory",
1617
- provider: "openai",
1618
- model: "gpt-4o-mini",
1619
- api_key: "",
1620
- base_url: "",
1621
- skip_patterns: [],
1622
- daily_limit: 100,
1623
- tier: "free"
1624
- },
1625
- observer: {
1626
- enabled: true,
1627
- mode: "per_event",
1628
- model: "sonnet"
1629
- },
1630
- transcript_analysis: {
1631
- enabled: false
1632
- }
1633
- };
1634
- }
1635
- function loadConfig2() {
1636
- if (!existsSync5(SETTINGS_PATH2)) {
1637
- throw new Error(`Config not found at ${SETTINGS_PATH2}. Run 'engrm init --manual' to configure.`);
1638
- }
1639
- const raw = readFileSync4(SETTINGS_PATH2, "utf-8");
1640
- let parsed;
1641
- try {
1642
- parsed = JSON.parse(raw);
1643
- } catch {
1644
- throw new Error(`Invalid JSON in ${SETTINGS_PATH2}`);
1645
- }
1646
- if (typeof parsed !== "object" || parsed === null) {
1647
- throw new Error(`Config at ${SETTINGS_PATH2} is not a JSON object`);
1648
- }
1649
- const config = parsed;
1650
- const defaults = createDefaultConfig2();
1651
- return {
1652
- candengo_url: asString2(config["candengo_url"], defaults.candengo_url),
1653
- candengo_api_key: asString2(config["candengo_api_key"], defaults.candengo_api_key),
1654
- site_id: asString2(config["site_id"], defaults.site_id),
1655
- namespace: asString2(config["namespace"], defaults.namespace),
1656
- user_id: asString2(config["user_id"], defaults.user_id),
1657
- user_email: asString2(config["user_email"], defaults.user_email),
1658
- device_id: asString2(config["device_id"], defaults.device_id),
1659
- teams: asTeams2(config["teams"], defaults.teams),
1660
- sync: {
1661
- enabled: asBool2(config["sync"]?.["enabled"], defaults.sync.enabled),
1662
- interval_seconds: asNumber2(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
1663
- batch_size: asNumber2(config["sync"]?.["batch_size"], defaults.sync.batch_size)
1664
- },
1665
- search: {
1666
- default_limit: asNumber2(config["search"]?.["default_limit"], defaults.search.default_limit),
1667
- local_boost: asNumber2(config["search"]?.["local_boost"], defaults.search.local_boost),
1668
- scope: asScope2(config["search"]?.["scope"], defaults.search.scope)
1669
- },
1670
- scrubbing: {
1671
- enabled: asBool2(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
1672
- custom_patterns: asStringArray2(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
1673
- default_sensitivity: asSensitivity2(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
1674
- },
1675
- sentinel: {
1676
- enabled: asBool2(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
1677
- mode: asSentinelMode2(config["sentinel"]?.["mode"], defaults.sentinel.mode),
1678
- provider: asLlmProvider2(config["sentinel"]?.["provider"], defaults.sentinel.provider),
1679
- model: asString2(config["sentinel"]?.["model"], defaults.sentinel.model),
1680
- api_key: asString2(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
1681
- base_url: asString2(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
1682
- skip_patterns: asStringArray2(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
1683
- daily_limit: asNumber2(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
1684
- tier: asTier2(config["sentinel"]?.["tier"], defaults.sentinel.tier)
1685
- },
1686
- observer: {
1687
- enabled: asBool2(config["observer"]?.["enabled"], defaults.observer.enabled),
1688
- mode: asObserverMode2(config["observer"]?.["mode"], defaults.observer.mode),
1689
- model: asString2(config["observer"]?.["model"], defaults.observer.model)
1690
- },
1691
- transcript_analysis: {
1692
- enabled: asBool2(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
1693
- }
1694
- };
1695
- }
1696
- function saveConfig(config) {
1697
- if (!existsSync5(CONFIG_DIR2)) {
1698
- mkdirSync2(CONFIG_DIR2, { recursive: true });
1699
- }
1700
- writeFileSync2(SETTINGS_PATH2, JSON.stringify(config, null, 2) + `
1701
- `, "utf-8");
1702
- }
1703
- function configExists2() {
1704
- return existsSync5(SETTINGS_PATH2);
1705
- }
1706
- function asString2(value, fallback) {
1707
- return typeof value === "string" ? value : fallback;
1708
- }
1709
- function asNumber2(value, fallback) {
1710
- return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
1711
- }
1712
- function asBool2(value, fallback) {
1713
- return typeof value === "boolean" ? value : fallback;
1714
- }
1715
- function asStringArray2(value, fallback) {
1716
- return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
1717
- }
1718
- function asScope2(value, fallback) {
1719
- if (value === "personal" || value === "team" || value === "all")
1720
- return value;
1721
- return fallback;
1722
- }
1723
- function asSensitivity2(value, fallback) {
1724
- if (value === "shared" || value === "personal" || value === "secret")
1725
- return value;
1726
- return fallback;
1727
- }
1728
- function asSentinelMode2(value, fallback) {
1729
- if (value === "advisory" || value === "blocking")
1730
- return value;
1731
- return fallback;
1732
- }
1733
- function asLlmProvider2(value, fallback) {
1734
- if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
1735
- return value;
1736
- return fallback;
1737
- }
1738
- function asTier2(value, fallback) {
1739
- if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
1740
- return value;
1741
- return fallback;
1742
- }
1743
- function asObserverMode2(value, fallback) {
1744
- if (value === "per_event" || value === "per_session")
1745
- return value;
1746
- return fallback;
1747
- }
1748
- function asTeams2(value, fallback) {
1749
- if (!Array.isArray(value))
1750
- return fallback;
1751
- return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
1752
- }
1692
+ INSERT INTO observations_new SELECT * FROM observations;
1753
1693
 
1754
- // src/sync/auth.ts
1755
- function getApiKey(config) {
1756
- const envKey = process.env.ENGRM_TOKEN;
1757
- if (envKey && envKey.startsWith("cvk_"))
1758
- return envKey;
1759
- if (config.candengo_api_key && config.candengo_api_key.length > 0) {
1760
- return config.candengo_api_key;
1761
- }
1762
- return null;
1763
- }
1764
- function getBaseUrl(config) {
1765
- if (config.candengo_url && config.candengo_url.length > 0) {
1766
- return config.candengo_url;
1694
+ DROP TABLE observations;
1695
+ ALTER TABLE observations_new RENAME TO observations;
1696
+
1697
+ -- Recreate indexes
1698
+ CREATE INDEX idx_observations_project ON observations(project_id);
1699
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1700
+ CREATE INDEX idx_observations_type ON observations(type);
1701
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1702
+ CREATE INDEX idx_observations_session ON observations(session_id);
1703
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1704
+ CREATE INDEX idx_observations_quality ON observations(quality);
1705
+ CREATE INDEX idx_observations_user ON observations(user_id);
1706
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1707
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1708
+
1709
+ -- Recreate FTS5 (external content mode — must rebuild after table recreation)
1710
+ DROP TABLE IF EXISTS observations_fts;
1711
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1712
+ title, narrative, facts, concepts,
1713
+ content=observations,
1714
+ content_rowid=id
1715
+ );
1716
+ -- Rebuild FTS index
1717
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
1718
+ `
1719
+ },
1720
+ {
1721
+ version: 7,
1722
+ description: "Add packs_installed table for help pack tracking",
1723
+ sql: `
1724
+ CREATE TABLE IF NOT EXISTS packs_installed (
1725
+ name TEXT PRIMARY KEY,
1726
+ installed_at INTEGER NOT NULL,
1727
+ observation_count INTEGER DEFAULT 0
1728
+ );
1729
+ `
1730
+ },
1731
+ {
1732
+ version: 8,
1733
+ description: "Add message type to observations CHECK constraint",
1734
+ sql: `
1735
+ CREATE TABLE IF NOT EXISTS observations_v8 (
1736
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1737
+ session_id TEXT,
1738
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1739
+ type TEXT NOT NULL CHECK (type IN (
1740
+ 'bugfix', 'discovery', 'decision', 'pattern',
1741
+ 'change', 'feature', 'refactor', 'digest', 'standard', 'message'
1742
+ )),
1743
+ title TEXT NOT NULL,
1744
+ narrative TEXT,
1745
+ facts TEXT,
1746
+ concepts TEXT,
1747
+ files_read TEXT,
1748
+ files_modified TEXT,
1749
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1750
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1751
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1752
+ )),
1753
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1754
+ 'shared', 'personal', 'secret'
1755
+ )),
1756
+ user_id TEXT NOT NULL,
1757
+ device_id TEXT NOT NULL,
1758
+ agent TEXT DEFAULT 'claude-code',
1759
+ created_at TEXT NOT NULL,
1760
+ created_at_epoch INTEGER NOT NULL,
1761
+ archived_at_epoch INTEGER,
1762
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1763
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1764
+ remote_source_id TEXT
1765
+ );
1766
+ INSERT INTO observations_v8 SELECT * FROM observations;
1767
+ DROP TABLE observations;
1768
+ ALTER TABLE observations_v8 RENAME TO observations;
1769
+ CREATE INDEX idx_observations_project ON observations(project_id);
1770
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1771
+ CREATE INDEX idx_observations_type ON observations(type);
1772
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1773
+ CREATE INDEX idx_observations_session ON observations(session_id);
1774
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1775
+ CREATE INDEX idx_observations_quality ON observations(quality);
1776
+ CREATE INDEX idx_observations_user ON observations(user_id);
1777
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1778
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1779
+ DROP TABLE IF EXISTS observations_fts;
1780
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1781
+ title, narrative, facts, concepts,
1782
+ content=observations,
1783
+ content_rowid=id
1784
+ );
1785
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
1786
+ `
1767
1787
  }
1768
- return null;
1769
- }
1770
- function buildSourceId(config, localId, type = "obs") {
1771
- return `${config.user_id}-${config.device_id}-${type}-${localId}`;
1772
- }
1773
- function parseSourceId(sourceId) {
1774
- const obsIndex = sourceId.lastIndexOf("-obs-");
1775
- if (obsIndex === -1)
1776
- return null;
1777
- const prefix = sourceId.slice(0, obsIndex);
1778
- const localIdStr = sourceId.slice(obsIndex + 5);
1779
- const localId = parseInt(localIdStr, 10);
1780
- if (isNaN(localId))
1781
- return null;
1782
- const firstDash = prefix.indexOf("-");
1783
- if (firstDash === -1)
1784
- return null;
1785
- return {
1786
- userId: prefix.slice(0, firstDash),
1787
- deviceId: prefix.slice(firstDash + 1),
1788
- localId
1789
- };
1790
- }
1791
-
1792
- // src/embeddings/embedder.ts
1793
- var _available = null;
1794
- var _pipeline = null;
1795
- var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
1796
- async function embedText(text) {
1797
- const pipe = await getPipeline();
1798
- if (!pipe)
1799
- return null;
1788
+ ];
1789
+ function isVecExtensionLoaded(db) {
1800
1790
  try {
1801
- const output = await pipe(text, { pooling: "mean", normalize: true });
1802
- return new Float32Array(output.data);
1791
+ db.exec("SELECT vec_version()");
1792
+ return true;
1803
1793
  } catch {
1804
- return null;
1794
+ return false;
1805
1795
  }
1806
1796
  }
1807
- function composeEmbeddingText(obs) {
1808
- const parts = [obs.title];
1809
- if (obs.narrative)
1810
- parts.push(obs.narrative);
1811
- if (obs.facts) {
1812
- try {
1813
- const facts = JSON.parse(obs.facts);
1814
- if (Array.isArray(facts) && facts.length > 0) {
1815
- parts.push(facts.map((f) => `- ${f}`).join(`
1816
- `));
1817
- }
1818
- } catch {
1819
- parts.push(obs.facts);
1797
+ function runMigrations(db) {
1798
+ const currentVersion = db.query("PRAGMA user_version").get();
1799
+ let version = currentVersion.user_version;
1800
+ for (const migration of MIGRATIONS) {
1801
+ if (migration.version <= version)
1802
+ continue;
1803
+ if (migration.condition && !migration.condition(db)) {
1804
+ continue;
1820
1805
  }
1821
- }
1822
- if (obs.concepts) {
1806
+ db.exec("BEGIN TRANSACTION");
1823
1807
  try {
1824
- const concepts = JSON.parse(obs.concepts);
1825
- if (Array.isArray(concepts) && concepts.length > 0) {
1826
- parts.push(concepts.join(", "));
1827
- }
1828
- } catch {}
1808
+ db.exec(migration.sql);
1809
+ db.exec(`PRAGMA user_version = ${migration.version}`);
1810
+ db.exec("COMMIT");
1811
+ version = migration.version;
1812
+ } catch (error) {
1813
+ db.exec("ROLLBACK");
1814
+ throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${error instanceof Error ? error.message : String(error)}`);
1815
+ }
1829
1816
  }
1830
- return parts.join(`
1831
-
1832
- `);
1833
1817
  }
1834
- async function getPipeline() {
1835
- if (_pipeline)
1836
- return _pipeline;
1837
- if (_available === false)
1838
- return null;
1818
+ function ensureObservationTypes(db) {
1839
1819
  try {
1840
- const { pipeline } = await import("@xenova/transformers");
1841
- _pipeline = await pipeline("feature-extraction", MODEL_NAME);
1842
- _available = true;
1843
- return _pipeline;
1844
- } catch (err) {
1845
- _available = false;
1846
- console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
1847
- return null;
1820
+ 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)");
1821
+ db.exec("DELETE FROM observations WHERE session_id = '_typecheck'");
1822
+ } catch {
1823
+ db.exec("BEGIN TRANSACTION");
1824
+ try {
1825
+ db.exec(`
1826
+ CREATE TABLE observations_repair (
1827
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
1828
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1829
+ type TEXT NOT NULL CHECK (type IN (
1830
+ 'bugfix','discovery','decision','pattern','change','feature',
1831
+ 'refactor','digest','standard','message')),
1832
+ title TEXT NOT NULL, narrative TEXT, facts TEXT, concepts TEXT,
1833
+ files_read TEXT, files_modified TEXT,
1834
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1835
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active','aging','archived','purged','pinned')),
1836
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN ('shared','personal','secret')),
1837
+ user_id TEXT NOT NULL, device_id TEXT NOT NULL, agent TEXT DEFAULT 'claude-code',
1838
+ created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL,
1839
+ archived_at_epoch INTEGER,
1840
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1841
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1842
+ remote_source_id TEXT
1843
+ );
1844
+ INSERT INTO observations_repair SELECT * FROM observations;
1845
+ DROP TABLE observations;
1846
+ ALTER TABLE observations_repair RENAME TO observations;
1847
+ CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
1848
+ CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
1849
+ CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
1850
+ CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
1851
+ CREATE INDEX IF NOT EXISTS idx_observations_lifecycle ON observations(lifecycle);
1852
+ CREATE INDEX IF NOT EXISTS idx_observations_quality ON observations(quality);
1853
+ CREATE INDEX IF NOT EXISTS idx_observations_user ON observations(user_id);
1854
+ CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(superseded_by);
1855
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1856
+ DROP TABLE IF EXISTS observations_fts;
1857
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1858
+ title, narrative, facts, concepts, content=observations, content_rowid=id
1859
+ );
1860
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
1861
+ `);
1862
+ db.exec("COMMIT");
1863
+ } catch (err) {
1864
+ db.exec("ROLLBACK");
1865
+ }
1848
1866
  }
1849
1867
  }
1868
+ var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
1850
1869
 
1851
- // src/sync/pull.ts
1852
- var PULL_CURSOR_KEY = "pull_cursor";
1853
- var MAX_PAGES = 20;
1854
- async function pullFromVector(db, client, config, limit = 50) {
1855
- let cursor = db.getSyncState(PULL_CURSOR_KEY) ?? undefined;
1856
- let totalReceived = 0;
1857
- let totalMerged = 0;
1858
- let totalSkipped = 0;
1859
- for (let page = 0;page < MAX_PAGES; page++) {
1860
- const response = await client.pullChanges(cursor, limit);
1861
- const { merged, skipped } = mergeChanges(db, config, response.changes);
1862
- totalReceived += response.changes.length;
1863
- totalMerged += merged;
1864
- totalSkipped += skipped;
1865
- if (response.cursor) {
1866
- db.setSyncState(PULL_CURSOR_KEY, response.cursor);
1867
- cursor = response.cursor;
1870
+ // src/storage/sqlite.ts
1871
+ var IS_BUN = typeof globalThis.Bun !== "undefined";
1872
+ function openDatabase(dbPath) {
1873
+ if (IS_BUN) {
1874
+ return openBunDatabase(dbPath);
1875
+ }
1876
+ return openNodeDatabase(dbPath);
1877
+ }
1878
+ function openBunDatabase(dbPath) {
1879
+ const { Database } = __require("bun:sqlite");
1880
+ if (process.platform === "darwin") {
1881
+ const { existsSync: existsSync6 } = __require("node:fs");
1882
+ const paths = [
1883
+ "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
1884
+ "/usr/local/opt/sqlite3/lib/libsqlite3.dylib"
1885
+ ];
1886
+ for (const p of paths) {
1887
+ if (existsSync6(p)) {
1888
+ try {
1889
+ Database.setCustomSQLite(p);
1890
+ } catch {}
1891
+ break;
1892
+ }
1868
1893
  }
1869
- if (!response.has_more || response.changes.length === 0)
1870
- break;
1871
1894
  }
1872
- return { received: totalReceived, merged: totalMerged, skipped: totalSkipped };
1895
+ const db = new Database(dbPath);
1896
+ return db;
1873
1897
  }
1874
- function mergeChanges(db, config, changes) {
1875
- let merged = 0;
1876
- let skipped = 0;
1877
- for (const change of changes) {
1878
- const parsed = parseSourceId(change.source_id);
1879
- if (parsed && parsed.deviceId === config.device_id) {
1880
- skipped++;
1881
- continue;
1898
+ function openNodeDatabase(dbPath) {
1899
+ const BetterSqlite3 = __require("better-sqlite3");
1900
+ const raw = new BetterSqlite3(dbPath);
1901
+ return {
1902
+ query(sql) {
1903
+ const stmt = raw.prepare(sql);
1904
+ return {
1905
+ get(...params) {
1906
+ return stmt.get(...params);
1907
+ },
1908
+ all(...params) {
1909
+ return stmt.all(...params);
1910
+ },
1911
+ run(...params) {
1912
+ return stmt.run(...params);
1913
+ }
1914
+ };
1915
+ },
1916
+ exec(sql) {
1917
+ raw.exec(sql);
1918
+ },
1919
+ close() {
1920
+ raw.close();
1882
1921
  }
1883
- const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
1884
- if (existing) {
1885
- skipped++;
1886
- continue;
1922
+ };
1923
+ }
1924
+
1925
+ class MemDatabase {
1926
+ db;
1927
+ vecAvailable;
1928
+ constructor(dbPath) {
1929
+ this.db = openDatabase(dbPath);
1930
+ this.db.exec("PRAGMA journal_mode = WAL");
1931
+ this.db.exec("PRAGMA foreign_keys = ON");
1932
+ this.vecAvailable = this.loadVecExtension();
1933
+ runMigrations(this.db);
1934
+ ensureObservationTypes(this.db);
1935
+ }
1936
+ loadVecExtension() {
1937
+ try {
1938
+ const sqliteVec = __require("sqlite-vec");
1939
+ sqliteVec.load(this.db);
1940
+ return true;
1941
+ } catch {
1942
+ return false;
1887
1943
  }
1888
- const projectCanonical = change.metadata?.project_canonical ?? null;
1889
- if (!projectCanonical) {
1890
- skipped++;
1891
- continue;
1944
+ }
1945
+ close() {
1946
+ this.db.close();
1947
+ }
1948
+ upsertProject(project) {
1949
+ const now = Math.floor(Date.now() / 1000);
1950
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
1951
+ if (existing) {
1952
+ this.db.query(`UPDATE projects SET
1953
+ local_path = COALESCE(?, local_path),
1954
+ remote_url = COALESCE(?, remote_url),
1955
+ last_active_epoch = ?
1956
+ WHERE id = ?`).run(project.local_path ?? null, project.remote_url ?? null, now, existing.id);
1957
+ return {
1958
+ ...existing,
1959
+ local_path: project.local_path ?? existing.local_path,
1960
+ remote_url: project.remote_url ?? existing.remote_url,
1961
+ last_active_epoch: now
1962
+ };
1892
1963
  }
1893
- let project = db.getProjectByCanonicalId(projectCanonical);
1894
- if (!project) {
1895
- project = db.upsertProject({
1896
- canonical_id: projectCanonical,
1897
- name: change.metadata?.project_name ?? projectCanonical.split("/").pop() ?? "unknown"
1898
- });
1964
+ const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
1965
+ VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
1966
+ return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
1967
+ }
1968
+ getProjectByCanonicalId(canonicalId) {
1969
+ return this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId) ?? null;
1970
+ }
1971
+ getProjectById(id) {
1972
+ return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
1973
+ }
1974
+ insertObservation(obs) {
1975
+ const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
1976
+ const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
1977
+ const result = this.db.query(`INSERT INTO observations (
1978
+ session_id, project_id, type, title, narrative, facts, concepts,
1979
+ files_read, files_modified, quality, lifecycle, sensitivity,
1980
+ user_id, device_id, agent, created_at, created_at_epoch
1981
+ ) 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);
1982
+ const id = Number(result.lastInsertRowid);
1983
+ const row = this.getObservationById(id);
1984
+ this.ftsInsert(row);
1985
+ if (obs.session_id) {
1986
+ this.db.query("UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?").run(obs.session_id);
1899
1987
  }
1900
- const obs = db.insertObservation({
1901
- session_id: change.metadata?.session_id ?? null,
1902
- project_id: project.id,
1903
- type: change.metadata?.type ?? "discovery",
1904
- title: change.metadata?.title ?? change.content.split(`
1905
- `)[0] ?? "Untitled",
1906
- narrative: extractNarrative(change.content),
1907
- facts: change.metadata?.facts ? JSON.stringify(change.metadata.facts) : null,
1908
- concepts: change.metadata?.concepts ? JSON.stringify(change.metadata.concepts) : null,
1909
- quality: change.metadata?.quality ?? 0.5,
1910
- lifecycle: "active",
1911
- sensitivity: "shared",
1912
- user_id: change.metadata?.user_id ?? "unknown",
1913
- device_id: change.metadata?.device_id ?? "unknown",
1914
- agent: change.metadata?.agent ?? "unknown"
1915
- });
1916
- db.db.query("UPDATE observations SET remote_source_id = ? WHERE id = ?").run(change.source_id, obs.id);
1917
- if (db.vecAvailable) {
1918
- embedAndInsert(db, obs).catch(() => {});
1988
+ return row;
1989
+ }
1990
+ getObservationById(id) {
1991
+ return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1992
+ }
1993
+ getObservationsByIds(ids, userId) {
1994
+ if (ids.length === 0)
1995
+ return [];
1996
+ const placeholders = ids.map(() => "?").join(",");
1997
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
1998
+ return this.db.query(`SELECT * FROM observations
1999
+ WHERE id IN (${placeholders})${visibilityClause}
2000
+ ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
2001
+ }
2002
+ getRecentObservations(projectId, sincEpoch, limit = 50) {
2003
+ return this.db.query(`SELECT * FROM observations
2004
+ WHERE project_id = ? AND created_at_epoch > ?
2005
+ ORDER BY created_at_epoch DESC
2006
+ LIMIT ?`).all(projectId, sincEpoch, limit);
2007
+ }
2008
+ searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
2009
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
2010
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
2011
+ if (projectId !== null) {
2012
+ return this.db.query(`SELECT o.id, observations_fts.rank
2013
+ FROM observations_fts
2014
+ JOIN observations o ON o.id = observations_fts.rowid
2015
+ WHERE observations_fts MATCH ?
2016
+ AND o.project_id = ?
2017
+ AND o.lifecycle IN (${lifecyclePlaceholders})
2018
+ ${visibilityClause}
2019
+ ORDER BY observations_fts.rank
2020
+ LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
1919
2021
  }
1920
- merged++;
2022
+ return this.db.query(`SELECT o.id, observations_fts.rank
2023
+ FROM observations_fts
2024
+ JOIN observations o ON o.id = observations_fts.rowid
2025
+ WHERE observations_fts MATCH ?
2026
+ AND o.lifecycle IN (${lifecyclePlaceholders})
2027
+ ${visibilityClause}
2028
+ ORDER BY observations_fts.rank
2029
+ LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
1921
2030
  }
1922
- return { merged, skipped };
1923
- }
1924
- async function embedAndInsert(db, obs) {
1925
- const text = composeEmbeddingText(obs);
1926
- const embedding = await embedText(text);
1927
- if (embedding)
1928
- db.vecInsert(obs.id, embedding);
1929
- }
1930
- function extractNarrative(content) {
1931
- const lines = content.split(`
1932
- `);
1933
- if (lines.length <= 1)
1934
- return null;
1935
- const narrative = lines.slice(1).join(`
1936
- `).trim();
1937
- return narrative.length > 0 ? narrative : null;
1938
- }
1939
- async function pullSettings(client, config) {
1940
- try {
1941
- const settings = await client.fetchSettings();
1942
- if (!settings)
2031
+ getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
2032
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
2033
+ const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
2034
+ if (!anchor)
2035
+ return [];
2036
+ const projectFilter = projectId !== null ? "AND project_id = ?" : "";
2037
+ const projectParams = projectId !== null ? [projectId] : [];
2038
+ const visibilityParams = userId ? [userId] : [];
2039
+ const before = this.db.query(`SELECT * FROM observations
2040
+ WHERE created_at_epoch < ? ${projectFilter}
2041
+ AND lifecycle IN ('active', 'aging', 'pinned')
2042
+ ${visibilityClause}
2043
+ ORDER BY created_at_epoch DESC
2044
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
2045
+ const after = this.db.query(`SELECT * FROM observations
2046
+ WHERE created_at_epoch > ? ${projectFilter}
2047
+ AND lifecycle IN ('active', 'aging', 'pinned')
2048
+ ${visibilityClause}
2049
+ ORDER BY created_at_epoch ASC
2050
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
2051
+ return [...before.reverse(), anchor, ...after];
2052
+ }
2053
+ pinObservation(id, pinned) {
2054
+ const obs = this.getObservationById(id);
2055
+ if (!obs)
1943
2056
  return false;
1944
- let changed = false;
1945
- if (settings.transcript_analysis !== undefined) {
1946
- const ta = settings.transcript_analysis;
1947
- if (typeof ta === "object" && ta !== null) {
1948
- const taObj = ta;
1949
- if (taObj.enabled !== undefined && taObj.enabled !== config.transcript_analysis.enabled) {
1950
- config.transcript_analysis.enabled = !!taObj.enabled;
1951
- changed = true;
1952
- }
1953
- }
1954
- }
1955
- if (settings.observer !== undefined) {
1956
- const obs = settings.observer;
1957
- if (typeof obs === "object" && obs !== null) {
1958
- const obsObj = obs;
1959
- if (obsObj.enabled !== undefined && obsObj.enabled !== config.observer.enabled) {
1960
- config.observer.enabled = !!obsObj.enabled;
1961
- changed = true;
1962
- }
1963
- if (obsObj.model !== undefined && typeof obsObj.model === "string" && obsObj.model !== config.observer.model) {
1964
- config.observer.model = obsObj.model;
1965
- changed = true;
1966
- }
1967
- }
2057
+ if (pinned) {
2058
+ if (obs.lifecycle !== "active" && obs.lifecycle !== "aging")
2059
+ return false;
2060
+ this.db.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?").run(id);
2061
+ } else {
2062
+ if (obs.lifecycle !== "pinned")
2063
+ return false;
2064
+ this.db.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?").run(id);
1968
2065
  }
1969
- if (changed) {
1970
- saveConfig(config);
2066
+ return true;
2067
+ }
2068
+ getActiveObservationCount(userId) {
2069
+ if (userId) {
2070
+ const result2 = this.db.query(`SELECT COUNT(*) as count FROM observations
2071
+ WHERE lifecycle IN ('active', 'aging')
2072
+ AND sensitivity != 'secret'
2073
+ AND user_id = ?`).get(userId);
2074
+ return result2?.count ?? 0;
1971
2075
  }
1972
- return changed;
1973
- } catch {
1974
- return false;
2076
+ const result = this.db.query(`SELECT COUNT(*) as count FROM observations
2077
+ WHERE lifecycle IN ('active', 'aging')
2078
+ AND sensitivity != 'secret'`).get();
2079
+ return result?.count ?? 0;
1975
2080
  }
1976
- }
1977
-
1978
- // src/sync/client.ts
1979
- class VectorClient {
1980
- baseUrl;
1981
- apiKey;
1982
- siteId;
1983
- namespace;
1984
- constructor(config) {
1985
- const baseUrl = getBaseUrl(config);
1986
- const apiKey = getApiKey(config);
1987
- if (!baseUrl || !apiKey) {
1988
- throw new Error("VectorClient requires candengo_url and candengo_api_key");
2081
+ supersedeObservation(oldId, newId) {
2082
+ if (oldId === newId)
2083
+ return false;
2084
+ const replacement = this.getObservationById(newId);
2085
+ if (!replacement)
2086
+ return false;
2087
+ let targetId = oldId;
2088
+ const visited = new Set;
2089
+ for (let depth = 0;depth < 10; depth++) {
2090
+ const target2 = this.getObservationById(targetId);
2091
+ if (!target2)
2092
+ return false;
2093
+ if (target2.superseded_by === null)
2094
+ break;
2095
+ if (target2.superseded_by === newId)
2096
+ return true;
2097
+ visited.add(targetId);
2098
+ targetId = target2.superseded_by;
2099
+ if (visited.has(targetId))
2100
+ return false;
1989
2101
  }
1990
- this.baseUrl = baseUrl.replace(/\/$/, "");
1991
- this.apiKey = apiKey;
1992
- this.siteId = config.site_id;
1993
- this.namespace = config.namespace;
2102
+ const target = this.getObservationById(targetId);
2103
+ if (!target)
2104
+ return false;
2105
+ if (target.superseded_by !== null)
2106
+ return false;
2107
+ if (targetId === newId)
2108
+ return false;
2109
+ const now = Math.floor(Date.now() / 1000);
2110
+ this.db.query(`UPDATE observations
2111
+ SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
2112
+ WHERE id = ?`).run(newId, now, targetId);
2113
+ this.ftsDelete(target);
2114
+ this.vecDelete(targetId);
2115
+ return true;
2116
+ }
2117
+ isSuperseded(id) {
2118
+ const obs = this.getObservationById(id);
2119
+ return obs !== null && obs.superseded_by !== null;
2120
+ }
2121
+ upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
2122
+ const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
2123
+ if (existing)
2124
+ return existing;
2125
+ const now = Math.floor(Date.now() / 1000);
2126
+ this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
2127
+ VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
2128
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
2129
+ }
2130
+ completeSession(sessionId) {
2131
+ const now = Math.floor(Date.now() / 1000);
2132
+ this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
2133
+ }
2134
+ addToOutbox(recordType, recordId) {
2135
+ const now = Math.floor(Date.now() / 1000);
2136
+ this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
2137
+ VALUES (?, ?, ?)`).run(recordType, recordId, now);
2138
+ }
2139
+ getSyncState(key) {
2140
+ const row = this.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
2141
+ return row?.value ?? null;
2142
+ }
2143
+ setSyncState(key, value) {
2144
+ this.db.query("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
1994
2145
  }
1995
- static isConfigured(config) {
1996
- return getApiKey(config) !== null && getBaseUrl(config) !== null;
2146
+ ftsInsert(obs) {
2147
+ this.db.query(`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
2148
+ VALUES (?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
1997
2149
  }
1998
- async ingest(doc) {
1999
- await this.request("POST", "/v1/ingest", doc);
2150
+ ftsDelete(obs) {
2151
+ this.db.query(`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
2152
+ VALUES ('delete', ?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
2000
2153
  }
2001
- async batchIngest(docs) {
2002
- if (docs.length === 0)
2154
+ vecInsert(observationId, embedding) {
2155
+ if (!this.vecAvailable)
2003
2156
  return;
2004
- await this.request("POST", "/v1/ingest/batch", { documents: docs });
2157
+ this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
2005
2158
  }
2006
- async search(query, metadataFilter, limit = 10) {
2007
- const body = { query, limit };
2008
- if (metadataFilter) {
2009
- body.metadata_filter = metadataFilter;
2159
+ vecDelete(observationId) {
2160
+ if (!this.vecAvailable)
2161
+ return;
2162
+ this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
2163
+ }
2164
+ searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
2165
+ if (!this.vecAvailable)
2166
+ return [];
2167
+ const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
2168
+ const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
2169
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
2170
+ if (projectId !== null) {
2171
+ return this.db.query(`SELECT v.observation_id, v.distance
2172
+ FROM vec_observations v
2173
+ JOIN observations o ON o.id = v.observation_id
2174
+ WHERE v.embedding MATCH ?
2175
+ AND k = ?
2176
+ AND o.project_id = ?
2177
+ AND o.lifecycle IN (${lifecyclePlaceholders})
2178
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
2010
2179
  }
2011
- return this.request("POST", "/v1/search", body);
2180
+ return this.db.query(`SELECT v.observation_id, v.distance
2181
+ FROM vec_observations v
2182
+ JOIN observations o ON o.id = v.observation_id
2183
+ WHERE v.embedding MATCH ?
2184
+ AND k = ?
2185
+ AND o.lifecycle IN (${lifecyclePlaceholders})
2186
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
2012
2187
  }
2013
- async deleteBySourceIds(sourceIds) {
2014
- if (sourceIds.length === 0)
2015
- return;
2016
- await this.request("POST", "/v1/documents/delete", {
2017
- source_ids: sourceIds
2018
- });
2188
+ getUnembeddedCount() {
2189
+ if (!this.vecAvailable)
2190
+ return 0;
2191
+ const result = this.db.query(`SELECT COUNT(*) as count FROM observations o
2192
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
2193
+ AND o.superseded_by IS NULL
2194
+ AND NOT EXISTS (
2195
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
2196
+ )`).get();
2197
+ return result?.count ?? 0;
2019
2198
  }
2020
- async pullChanges(cursor, limit = 50) {
2021
- const params = new URLSearchParams;
2022
- if (cursor)
2023
- params.set("cursor", cursor);
2024
- params.set("namespace", this.namespace);
2025
- params.set("limit", String(limit));
2026
- return this.request("GET", `/v1/sync/changes?${params.toString()}`);
2199
+ getUnembeddedObservations(limit = 100) {
2200
+ if (!this.vecAvailable)
2201
+ return [];
2202
+ return this.db.query(`SELECT o.* FROM observations o
2203
+ WHERE o.lifecycle IN ('active', 'aging', 'pinned')
2204
+ AND o.superseded_by IS NULL
2205
+ AND NOT EXISTS (
2206
+ SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
2207
+ )
2208
+ ORDER BY o.created_at_epoch DESC
2209
+ LIMIT ?`).all(limit);
2027
2210
  }
2028
- async sendTelemetry(beacon) {
2029
- await this.request("POST", "/v1/mem/telemetry", beacon);
2211
+ insertSessionSummary(summary) {
2212
+ const now = Math.floor(Date.now() / 1000);
2213
+ const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
2214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
2215
+ const id = Number(result.lastInsertRowid);
2216
+ return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
2030
2217
  }
2031
- async fetchSettings() {
2032
- try {
2033
- return await this.request("GET", "/v1/mem/user-settings");
2034
- } catch {
2035
- return null;
2218
+ getSessionSummary(sessionId) {
2219
+ return this.db.query("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId) ?? null;
2220
+ }
2221
+ getRecentSummaries(projectId, limit = 5) {
2222
+ return this.db.query(`SELECT * FROM session_summaries
2223
+ WHERE project_id = ?
2224
+ ORDER BY created_at_epoch DESC, id DESC
2225
+ LIMIT ?`).all(projectId, limit);
2226
+ }
2227
+ incrementSessionMetrics(sessionId, increments) {
2228
+ const sets = [];
2229
+ const params = [];
2230
+ if (increments.files) {
2231
+ sets.push("files_touched_count = files_touched_count + ?");
2232
+ params.push(increments.files);
2233
+ }
2234
+ if (increments.searches) {
2235
+ sets.push("searches_performed = searches_performed + ?");
2236
+ params.push(increments.searches);
2036
2237
  }
2238
+ if (increments.toolCalls) {
2239
+ sets.push("tool_calls_count = tool_calls_count + ?");
2240
+ params.push(increments.toolCalls);
2241
+ }
2242
+ if (sets.length === 0)
2243
+ return;
2244
+ params.push(sessionId);
2245
+ this.db.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`).run(...params);
2037
2246
  }
2038
- async health() {
2039
- try {
2040
- await this.request("GET", "/health");
2041
- return true;
2042
- } catch {
2043
- return false;
2247
+ getSessionMetrics(sessionId) {
2248
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
2249
+ }
2250
+ insertSecurityFinding(finding) {
2251
+ const now = Math.floor(Date.now() / 1000);
2252
+ 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)
2253
+ 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);
2254
+ const id = Number(result.lastInsertRowid);
2255
+ return this.db.query("SELECT * FROM security_findings WHERE id = ?").get(id);
2256
+ }
2257
+ getSecurityFindings(projectId, options = {}) {
2258
+ const limit = options.limit ?? 50;
2259
+ if (options.severity) {
2260
+ return this.db.query(`SELECT * FROM security_findings
2261
+ WHERE project_id = ? AND severity = ?
2262
+ ORDER BY created_at_epoch DESC
2263
+ LIMIT ?`).all(projectId, options.severity, limit);
2044
2264
  }
2265
+ return this.db.query(`SELECT * FROM security_findings
2266
+ WHERE project_id = ?
2267
+ ORDER BY created_at_epoch DESC
2268
+ LIMIT ?`).all(projectId, limit);
2045
2269
  }
2046
- async request(method, path, body) {
2047
- const url = `${this.baseUrl}${path}`;
2048
- const headers = {
2049
- Authorization: `Bearer ${this.apiKey}`,
2050
- "Content-Type": "application/json"
2270
+ getSecurityFindingsCount(projectId) {
2271
+ const rows = this.db.query(`SELECT severity, COUNT(*) as count FROM security_findings
2272
+ WHERE project_id = ?
2273
+ GROUP BY severity`).all(projectId);
2274
+ const counts = {
2275
+ critical: 0,
2276
+ high: 0,
2277
+ medium: 0,
2278
+ low: 0
2051
2279
  };
2052
- const init = { method, headers };
2053
- if (body && method !== "GET") {
2054
- init.body = JSON.stringify(body);
2055
- }
2056
- const response = await fetch(url, init);
2057
- if (!response.ok) {
2058
- const text = await response.text().catch(() => "");
2059
- throw new VectorApiError(response.status, text, path);
2280
+ for (const row of rows) {
2281
+ counts[row.severity] = row.count;
2060
2282
  }
2061
- if (response.status === 204 || response.headers.get("content-length") === "0") {
2062
- return;
2283
+ return counts;
2284
+ }
2285
+ setSessionRiskScore(sessionId, score) {
2286
+ this.db.query("UPDATE sessions SET risk_score = ? WHERE session_id = ?").run(score, sessionId);
2287
+ }
2288
+ getObservationsBySession(sessionId) {
2289
+ return this.db.query(`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`).all(sessionId);
2290
+ }
2291
+ getInstalledPacks() {
2292
+ try {
2293
+ const rows = this.db.query("SELECT name FROM packs_installed").all();
2294
+ return rows.map((r) => r.name);
2295
+ } catch {
2296
+ return [];
2063
2297
  }
2064
- return response.json();
2065
2298
  }
2066
- }
2067
-
2068
- class VectorApiError extends Error {
2069
- status;
2070
- body;
2071
- path;
2072
- constructor(status, body, path) {
2073
- super(`Vector API error ${status} on ${path}: ${body}`);
2074
- this.status = status;
2075
- this.body = body;
2076
- this.path = path;
2077
- this.name = "VectorApiError";
2299
+ markPackInstalled(name, observationCount) {
2300
+ const now = Math.floor(Date.now() / 1000);
2301
+ this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
2078
2302
  }
2079
2303
  }
2080
2304
 
2081
- // hooks/session-start.ts
2082
- async function main() {
2305
+ // src/hooks/common.ts
2306
+ var c = {
2307
+ dim: "\x1B[2m",
2308
+ yellow: "\x1B[33m",
2309
+ reset: "\x1B[0m"
2310
+ };
2311
+ async function readStdin() {
2083
2312
  const chunks = [];
2084
2313
  for await (const chunk of process.stdin) {
2085
2314
  chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
2086
2315
  }
2087
- const raw = chunks.join("");
2316
+ return chunks.join("");
2317
+ }
2318
+ async function parseStdinJson() {
2319
+ const raw = await readStdin();
2088
2320
  if (!raw.trim())
2089
- process.exit(0);
2090
- let event;
2321
+ return null;
2091
2322
  try {
2092
- event = JSON.parse(raw);
2323
+ return JSON.parse(raw);
2093
2324
  } catch {
2094
- process.exit(0);
2325
+ return null;
2326
+ }
2327
+ }
2328
+ function bootstrapHook(hookName) {
2329
+ if (!configExists()) {
2330
+ warnUser(hookName, "Engrm not configured. Run: npx engrm init");
2331
+ return null;
2095
2332
  }
2096
- if (!configExists())
2097
- process.exit(0);
2098
2333
  let config;
2099
- let db;
2100
2334
  try {
2101
2335
  config = loadConfig();
2336
+ } catch (err) {
2337
+ warnUser(hookName, `Config error: ${err instanceof Error ? err.message : String(err)}`);
2338
+ return null;
2339
+ }
2340
+ let db;
2341
+ try {
2102
2342
  db = new MemDatabase(getDbPath());
2103
- } catch {
2104
- process.exit(0);
2343
+ } catch (err) {
2344
+ warnUser(hookName, `Database error: ${err instanceof Error ? err.message : String(err)}`);
2345
+ return null;
2105
2346
  }
2347
+ return { config, db };
2348
+ }
2349
+ function warnUser(hookName, message) {
2350
+ console.error(`${c.yellow}engrm ${hookName}:${c.reset} ${c.dim}${message}${c.reset}`);
2351
+ }
2352
+ function runHook(hookName, fn) {
2353
+ fn().catch((err) => {
2354
+ warnUser(hookName, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
2355
+ process.exit(0);
2356
+ });
2357
+ }
2358
+
2359
+ // hooks/session-start.ts
2360
+ async function main() {
2361
+ const event = await parseStdinJson();
2362
+ if (!event)
2363
+ process.exit(0);
2364
+ const boot = bootstrapHook("session-start");
2365
+ if (!boot)
2366
+ process.exit(0);
2367
+ const { config, db } = boot;
2368
+ let syncedCount = 0;
2106
2369
  try {
2107
2370
  if (config.sync.enabled && config.candengo_api_key) {
2108
2371
  try {
2109
2372
  const client = new VectorClient(config);
2110
2373
  const pullResult = await pullFromVector(db, client, config, 50);
2111
- if (pullResult.merged > 0) {
2112
- console.error(`Engrm: synced ${pullResult.merged} observation(s) from server`);
2113
- }
2374
+ syncedCount = pullResult.merged;
2114
2375
  await pullSettings(client, config);
2115
2376
  } catch {}
2116
2377
  }
2378
+ try {
2379
+ computeAndSaveFingerprint(event.cwd);
2380
+ } catch {}
2117
2381
  const context = buildSessionContext(db, event.cwd, {
2118
2382
  tokenBudget: 800,
2119
- scope: config.search.scope
2383
+ scope: config.search.scope,
2384
+ userId: config.user_id
2120
2385
  });
2386
+ if (context) {
2387
+ try {
2388
+ const dir = join6(homedir3(), ".engrm");
2389
+ if (!existsSync6(dir))
2390
+ mkdirSync3(dir, { recursive: true });
2391
+ writeFileSync3(join6(dir, "hook-session-metrics.json"), JSON.stringify({
2392
+ contextObsInjected: context.observations.length,
2393
+ contextTotalAvailable: context.total_active
2394
+ }), "utf-8");
2395
+ } catch {}
2396
+ }
2121
2397
  if (context && context.observations.length > 0) {
2122
- console.log(formatContextForInjection(context));
2123
- const parts = [];
2124
- parts.push(`${context.session_count} observation(s)`);
2125
- if (context.securityFindings && context.securityFindings.length > 0) {
2126
- parts.push(`${context.securityFindings.length} security finding(s)`);
2127
- }
2128
2398
  const remaining = context.total_active - context.session_count;
2129
- if (remaining > 0) {
2130
- parts.push(`${remaining} more searchable`);
2131
- }
2399
+ let msgCount = 0;
2132
2400
  try {
2133
2401
  const readKey = `messages_read_${config.device_id}`;
2134
2402
  const lastReadId = parseInt(db.getSyncState(readKey) ?? "0", 10);
2135
- const msgCount = db.db.query("SELECT COUNT(*) as c FROM observations WHERE type = 'message' AND id > ? AND lifecycle IN ('active', 'pinned')").get(lastReadId)?.c ?? 0;
2136
- if (msgCount > 0) {
2137
- parts.push(`${msgCount} unread message(s)`);
2403
+ msgCount = db.db.query(`SELECT COUNT(*) as c FROM observations
2404
+ WHERE type = 'message'
2405
+ AND id > ?
2406
+ AND lifecycle IN ('active', 'pinned')
2407
+ AND device_id != ?
2408
+ AND (sensitivity != 'personal' OR user_id = ?)`).get(lastReadId, config.device_id, config.user_id)?.c ?? 0;
2409
+ } catch {}
2410
+ const splash = formatSplashScreen({
2411
+ projectName: context.project_name,
2412
+ loaded: context.session_count,
2413
+ available: remaining,
2414
+ securityFindings: context.securityFindings?.length ?? 0,
2415
+ unreadMessages: msgCount,
2416
+ synced: syncedCount
2417
+ });
2418
+ let packLine = "";
2419
+ try {
2420
+ const { stacks } = detectStacksFromProject(event.cwd);
2421
+ if (stacks.length > 0) {
2422
+ const installed = db.getInstalledPacks();
2423
+ const recs = recommendPacks(stacks, installed);
2424
+ if (recs.length > 0) {
2425
+ const names = recs.map((r) => `\`${r.name}\``).join(", ");
2426
+ packLine = `
2427
+ Help packs available for your stack: ${names}. ` + `Use the install_pack tool to load curated observations.`;
2428
+ }
2138
2429
  }
2139
2430
  } catch {}
2140
- console.error(`Engrm: ${parts.join(" \xB7 ")} \u2014 memory loaded`);
2431
+ console.log(JSON.stringify({
2432
+ hookSpecificOutput: {
2433
+ hookEventName: "SessionStart",
2434
+ additionalContext: formatContextForInjection(context) + packLine
2435
+ },
2436
+ systemMessage: splash
2437
+ }));
2141
2438
  }
2142
- try {
2143
- const { stacks } = detectStacksFromProject(event.cwd);
2144
- if (stacks.length > 0) {
2145
- const installed = db.getInstalledPacks();
2146
- const recs = recommendPacks(stacks, installed);
2147
- if (recs.length > 0) {
2148
- const names = recs.map((r) => `\`${r.name}\``).join(", ");
2149
- console.log(`
2150
- Help packs available for your stack: ${names}. ` + `Use the install_pack tool to load curated observations.`);
2151
- }
2152
- }
2153
- } catch {}
2154
2439
  } finally {
2155
2440
  db.close();
2156
2441
  }
2157
2442
  }
2158
- main().catch(() => {
2159
- process.exit(0);
2160
- });
2443
+ var c2 = {
2444
+ reset: "\x1B[0m",
2445
+ dim: "\x1B[2m",
2446
+ bold: "\x1B[1m",
2447
+ cyan: "\x1B[36m",
2448
+ green: "\x1B[32m",
2449
+ yellow: "\x1B[33m",
2450
+ magenta: "\x1B[35m",
2451
+ white: "\x1B[37m"
2452
+ };
2453
+ function formatSplashScreen(data) {
2454
+ const lines = [];
2455
+ lines.push("");
2456
+ lines.push(`${c2.cyan}${c2.bold} ______ ____ _ ______ _____ ____ __${c2.reset}`);
2457
+ lines.push(`${c2.cyan}${c2.bold} | ___|| \\ | || ___|| | | \\ / |${c2.reset}`);
2458
+ lines.push(`${c2.cyan}${c2.bold} | ___|| \\| || | || \\ | \\/ |${c2.reset}`);
2459
+ lines.push(`${c2.cyan}${c2.bold} |______||__/\\____||______||__|\\__\\|__/\\__/|__|${c2.reset}`);
2460
+ lines.push(`${c2.dim} memory layer for AI agents${c2.reset}`);
2461
+ lines.push("");
2462
+ const dot = `${c2.dim} \xB7 ${c2.reset}`;
2463
+ const statParts = [];
2464
+ statParts.push(`${c2.green}${data.loaded}${c2.reset} loaded`);
2465
+ if (data.available > 0) {
2466
+ statParts.push(`${c2.dim}${data.available.toLocaleString()} searchable${c2.reset}`);
2467
+ }
2468
+ if (data.synced > 0) {
2469
+ statParts.push(`${c2.cyan}${data.synced} synced${c2.reset}`);
2470
+ }
2471
+ lines.push(` ${c2.white}${c2.bold}engrm${c2.reset}${dot}${statParts.join(dot)}`);
2472
+ const alerts = [];
2473
+ if (data.securityFindings > 0) {
2474
+ alerts.push(`${c2.yellow}${data.securityFindings} security finding${data.securityFindings !== 1 ? "s" : ""}${c2.reset}`);
2475
+ }
2476
+ if (data.unreadMessages > 0) {
2477
+ alerts.push(`${c2.magenta}${data.unreadMessages} unread message${data.unreadMessages !== 1 ? "s" : ""}${c2.reset}`);
2478
+ }
2479
+ if (alerts.length > 0) {
2480
+ lines.push(` ${alerts.join(dot)}`);
2481
+ }
2482
+ lines.push("");
2483
+ lines.push(` ${c2.dim}Dashboard: https://engrm.dev/dashboard${c2.reset}`);
2484
+ lines.push("");
2485
+ return lines.join(`
2486
+ `);
2487
+ }
2488
+ runHook("session-start", main);