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.
- package/README.md +49 -5
- package/dist/cli.js +288 -28
- package/dist/hooks/codex-stop.js +62 -0
- package/dist/hooks/elicitation-result.js +1690 -1637
- package/dist/hooks/post-tool-use.js +326 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2311 -1983
- package/dist/hooks/stop.js +302 -147
- package/dist/server.js +634 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
|
@@ -3,2158 +3,2486 @@
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
import { existsSync, mkdirSync
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
|
|
35
|
-
return `${host}-${suffix}`;
|
|
30
|
+
return url;
|
|
36
31
|
}
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
67
|
+
}
|
|
68
|
+
function readProjectConfigFile(directory) {
|
|
69
|
+
const configPath = join(directory, ".engrm.json");
|
|
70
|
+
if (!existsSync(configPath))
|
|
71
|
+
return null;
|
|
89
72
|
try {
|
|
90
|
-
|
|
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
|
-
|
|
83
|
+
return null;
|
|
93
84
|
}
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
157
|
-
|
|
332
|
+
function estimateTokens(text) {
|
|
333
|
+
if (!text)
|
|
334
|
+
return 0;
|
|
335
|
+
return Math.ceil(text.length / 4);
|
|
158
336
|
}
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
170
|
-
if (
|
|
171
|
-
return
|
|
172
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
180
|
-
if (value
|
|
181
|
-
return
|
|
182
|
-
|
|
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
|
|
185
|
-
if (
|
|
186
|
-
return
|
|
187
|
-
|
|
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
|
|
190
|
-
if (
|
|
191
|
-
return
|
|
192
|
-
return
|
|
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/
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
910
|
+
function loadPack(name) {
|
|
911
|
+
const packPath = join4(getPacksDir(), `${name}.json`);
|
|
912
|
+
if (!existsSync4(packPath))
|
|
913
|
+
return null;
|
|
540
914
|
try {
|
|
541
|
-
|
|
542
|
-
|
|
915
|
+
const raw = readFileSync3(packPath, "utf-8");
|
|
916
|
+
return JSON.parse(raw);
|
|
543
917
|
} catch {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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/
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
617
|
-
|
|
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
|
|
620
|
-
const BetterSqlite3 = __require("better-sqlite3");
|
|
621
|
-
const raw = new BetterSqlite3(dbPath);
|
|
980
|
+
function createDefaultConfig() {
|
|
622
981
|
return {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
638
|
-
|
|
995
|
+
search: {
|
|
996
|
+
default_limit: 10,
|
|
997
|
+
local_boost: 1.2,
|
|
998
|
+
scope: "all"
|
|
639
999
|
},
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
667
|
-
|
|
1037
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1038
|
+
throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
|
|
668
1039
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
return
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
return
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
AND sensitivity != 'secret'`).get();
|
|
790
|
-
return result?.count ?? 0;
|
|
1260
|
+
if (!response.has_more || response.changes.length === 0)
|
|
1261
|
+
break;
|
|
791
1262
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
814
|
-
if (
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
return row?.value ?? null;
|
|
1388
|
+
static isConfigured(config) {
|
|
1389
|
+
return getApiKey(config) !== null && getBaseUrl(config) !== null;
|
|
853
1390
|
}
|
|
854
|
-
|
|
855
|
-
this.
|
|
1391
|
+
async ingest(doc) {
|
|
1392
|
+
await this.request("POST", "/v1/ingest", doc);
|
|
856
1393
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1394
|
+
async batchIngest(docs) {
|
|
1395
|
+
if (docs.length === 0)
|
|
1396
|
+
return;
|
|
1397
|
+
await this.request("POST", "/v1/ingest/batch", { documents: docs });
|
|
860
1398
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
866
|
-
if (
|
|
1406
|
+
async deleteBySourceIds(sourceIds) {
|
|
1407
|
+
if (sourceIds.length === 0)
|
|
867
1408
|
return;
|
|
868
|
-
this.
|
|
1409
|
+
await this.request("POST", "/v1/documents/delete", {
|
|
1410
|
+
source_ids: sourceIds
|
|
1411
|
+
});
|
|
869
1412
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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 (
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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/
|
|
1120
|
-
var
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1802
|
-
return
|
|
1791
|
+
db.exec("SELECT vec_version()");
|
|
1792
|
+
return true;
|
|
1803
1793
|
} catch {
|
|
1804
|
-
return
|
|
1794
|
+
return false;
|
|
1805
1795
|
}
|
|
1806
1796
|
}
|
|
1807
|
-
function
|
|
1808
|
-
const
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
|
|
1835
|
-
if (_pipeline)
|
|
1836
|
-
return _pipeline;
|
|
1837
|
-
if (_available === false)
|
|
1838
|
-
return null;
|
|
1818
|
+
function ensureObservationTypes(db) {
|
|
1839
1819
|
try {
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
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/
|
|
1852
|
-
var
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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
|
-
|
|
1895
|
+
const db = new Database(dbPath);
|
|
1896
|
+
return db;
|
|
1873
1897
|
}
|
|
1874
|
-
function
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1970
|
-
|
|
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
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
1996
|
-
|
|
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
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
2002
|
-
if (
|
|
2154
|
+
vecInsert(observationId, embedding) {
|
|
2155
|
+
if (!this.vecAvailable)
|
|
2003
2156
|
return;
|
|
2004
|
-
|
|
2157
|
+
this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
|
|
2005
2158
|
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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.
|
|
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
|
-
|
|
2014
|
-
if (
|
|
2015
|
-
return;
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
2029
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
|
|
2047
|
-
const
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
|
2053
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
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/
|
|
2082
|
-
|
|
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
|
-
|
|
2316
|
+
return chunks.join("");
|
|
2317
|
+
}
|
|
2318
|
+
async function parseStdinJson() {
|
|
2319
|
+
const raw = await readStdin();
|
|
2088
2320
|
if (!raw.trim())
|
|
2089
|
-
|
|
2090
|
-
let event;
|
|
2321
|
+
return null;
|
|
2091
2322
|
try {
|
|
2092
|
-
|
|
2323
|
+
return JSON.parse(raw);
|
|
2093
2324
|
} catch {
|
|
2094
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
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.
|
|
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
|
-
|
|
2159
|
-
|
|
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);
|