engrm 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -73
- package/bin/build.mjs +97 -0
- package/bin/engrm.mjs +13 -0
- package/dist/cli.js +2712 -0
- package/dist/hooks/elicitation-result.js +1786 -0
- package/dist/hooks/post-tool-use.js +2357 -0
- package/dist/hooks/pre-compact.js +1321 -0
- package/dist/hooks/sentinel.js +1168 -0
- package/dist/hooks/session-start.js +1473 -0
- package/dist/hooks/stop.js +1834 -0
- package/dist/server.js +16628 -0
- package/package.json +29 -4
- package/packs/api-best-practices.json +182 -0
- package/packs/nextjs-patterns.json +68 -0
- package/packs/node-security.json +68 -0
- package/packs/python-django.json +68 -0
- package/packs/react-gotchas.json +182 -0
- package/packs/typescript-patterns.json +67 -0
- package/packs/web-security.json +182 -0
- package/.mcp.json +0 -9
- package/AUTH-DESIGN.md +0 -436
- package/BRIEF.md +0 -197
- package/CLAUDE.md +0 -44
- package/COMPETITIVE.md +0 -174
- package/CONTEXT-OPTIMIZATION.md +0 -305
- package/INFRASTRUCTURE.md +0 -252
- package/MARKET.md +0 -230
- package/PLAN.md +0 -278
- package/SENTINEL.md +0 -293
- package/SERVER-API-PLAN.md +0 -553
- package/SPEC.md +0 -843
- package/SWOT.md +0 -148
- package/SYNC-ARCHITECTURE.md +0 -294
- package/VIBE-CODER-STRATEGY.md +0 -250
- package/bun.lock +0 -375
- package/hooks/post-tool-use.ts +0 -144
- package/hooks/session-start.ts +0 -64
- package/hooks/stop.ts +0 -131
- package/mem-page.html +0 -1305
- package/src/capture/dedup.test.ts +0 -103
- package/src/capture/dedup.ts +0 -76
- package/src/capture/extractor.test.ts +0 -245
- package/src/capture/extractor.ts +0 -330
- package/src/capture/quality.test.ts +0 -168
- package/src/capture/quality.ts +0 -104
- package/src/capture/retrospective.test.ts +0 -115
- package/src/capture/retrospective.ts +0 -121
- package/src/capture/scanner.test.ts +0 -131
- package/src/capture/scanner.ts +0 -100
- package/src/capture/scrubber.test.ts +0 -144
- package/src/capture/scrubber.ts +0 -181
- package/src/cli.ts +0 -517
- package/src/config.ts +0 -238
- package/src/context/inject.test.ts +0 -940
- package/src/context/inject.ts +0 -382
- package/src/embeddings/backfill.ts +0 -50
- package/src/embeddings/embedder.test.ts +0 -76
- package/src/embeddings/embedder.ts +0 -139
- package/src/lifecycle/aging.test.ts +0 -103
- package/src/lifecycle/aging.ts +0 -36
- package/src/lifecycle/compaction.test.ts +0 -264
- package/src/lifecycle/compaction.ts +0 -190
- package/src/lifecycle/purge.test.ts +0 -100
- package/src/lifecycle/purge.ts +0 -37
- package/src/lifecycle/scheduler.test.ts +0 -120
- package/src/lifecycle/scheduler.ts +0 -101
- package/src/provisioning/browser-auth.ts +0 -172
- package/src/provisioning/provision.test.ts +0 -198
- package/src/provisioning/provision.ts +0 -94
- package/src/register.test.ts +0 -167
- package/src/register.ts +0 -178
- package/src/server.ts +0 -436
- package/src/storage/migrations.test.ts +0 -244
- package/src/storage/migrations.ts +0 -261
- package/src/storage/outbox.test.ts +0 -229
- package/src/storage/outbox.ts +0 -131
- package/src/storage/projects.test.ts +0 -137
- package/src/storage/projects.ts +0 -184
- package/src/storage/sqlite.test.ts +0 -798
- package/src/storage/sqlite.ts +0 -934
- package/src/storage/vec.test.ts +0 -198
- package/src/sync/auth.test.ts +0 -76
- package/src/sync/auth.ts +0 -68
- package/src/sync/client.ts +0 -183
- package/src/sync/engine.test.ts +0 -94
- package/src/sync/engine.ts +0 -127
- package/src/sync/pull.test.ts +0 -279
- package/src/sync/pull.ts +0 -170
- package/src/sync/push.test.ts +0 -117
- package/src/sync/push.ts +0 -230
- package/src/tools/get.ts +0 -34
- package/src/tools/pin.ts +0 -47
- package/src/tools/save.test.ts +0 -301
- package/src/tools/save.ts +0 -231
- package/src/tools/search.test.ts +0 -69
- package/src/tools/search.ts +0 -181
- package/src/tools/timeline.ts +0 -64
- package/tsconfig.json +0 -22
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2712 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __returnValue = (v) => v;
|
|
6
|
+
function __exportSetter(name, newValue) {
|
|
7
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
8
|
+
}
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, {
|
|
12
|
+
get: all[name],
|
|
13
|
+
enumerable: true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
set: __exportSetter.bind(all, name)
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// src/cli.ts
|
|
21
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6 } from "fs";
|
|
22
|
+
import { hostname as hostname2, homedir as homedir3 } from "os";
|
|
23
|
+
import { join as join6 } from "path";
|
|
24
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
25
|
+
|
|
26
|
+
// src/config.ts
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir, hostname } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
var CONFIG_DIR = join(homedir(), ".engrm");
|
|
32
|
+
var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
|
|
33
|
+
var DB_PATH = join(CONFIG_DIR, "engrm.db");
|
|
34
|
+
function getConfigDir() {
|
|
35
|
+
return CONFIG_DIR;
|
|
36
|
+
}
|
|
37
|
+
function getSettingsPath() {
|
|
38
|
+
return SETTINGS_PATH;
|
|
39
|
+
}
|
|
40
|
+
function getDbPath() {
|
|
41
|
+
return DB_PATH;
|
|
42
|
+
}
|
|
43
|
+
function generateDeviceId() {
|
|
44
|
+
const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
45
|
+
const suffix = randomBytes(4).toString("hex");
|
|
46
|
+
return `${host}-${suffix}`;
|
|
47
|
+
}
|
|
48
|
+
function createDefaultConfig() {
|
|
49
|
+
return {
|
|
50
|
+
candengo_url: "",
|
|
51
|
+
candengo_api_key: "",
|
|
52
|
+
site_id: "",
|
|
53
|
+
namespace: "",
|
|
54
|
+
user_id: "",
|
|
55
|
+
user_email: "",
|
|
56
|
+
device_id: generateDeviceId(),
|
|
57
|
+
teams: [],
|
|
58
|
+
sync: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
interval_seconds: 30,
|
|
61
|
+
batch_size: 50
|
|
62
|
+
},
|
|
63
|
+
search: {
|
|
64
|
+
default_limit: 10,
|
|
65
|
+
local_boost: 1.2,
|
|
66
|
+
scope: "all"
|
|
67
|
+
},
|
|
68
|
+
scrubbing: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
custom_patterns: [],
|
|
71
|
+
default_sensitivity: "shared"
|
|
72
|
+
},
|
|
73
|
+
sentinel: {
|
|
74
|
+
enabled: false,
|
|
75
|
+
mode: "advisory",
|
|
76
|
+
provider: "openai",
|
|
77
|
+
model: "gpt-4o-mini",
|
|
78
|
+
api_key: "",
|
|
79
|
+
base_url: "",
|
|
80
|
+
skip_patterns: [],
|
|
81
|
+
daily_limit: 100,
|
|
82
|
+
tier: "free"
|
|
83
|
+
},
|
|
84
|
+
observer: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
mode: "per_event",
|
|
87
|
+
model: "haiku"
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function loadConfig() {
|
|
92
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
93
|
+
throw new Error(`Config not found at ${SETTINGS_PATH}. Run 'engrm init --manual' to configure.`);
|
|
94
|
+
}
|
|
95
|
+
const raw = readFileSync(SETTINGS_PATH, "utf-8");
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(raw);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`Invalid JSON in ${SETTINGS_PATH}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
103
|
+
throw new Error(`Config at ${SETTINGS_PATH} is not a JSON object`);
|
|
104
|
+
}
|
|
105
|
+
const config = parsed;
|
|
106
|
+
const defaults = createDefaultConfig();
|
|
107
|
+
return {
|
|
108
|
+
candengo_url: asString(config["candengo_url"], defaults.candengo_url),
|
|
109
|
+
candengo_api_key: asString(config["candengo_api_key"], defaults.candengo_api_key),
|
|
110
|
+
site_id: asString(config["site_id"], defaults.site_id),
|
|
111
|
+
namespace: asString(config["namespace"], defaults.namespace),
|
|
112
|
+
user_id: asString(config["user_id"], defaults.user_id),
|
|
113
|
+
user_email: asString(config["user_email"], defaults.user_email),
|
|
114
|
+
device_id: asString(config["device_id"], defaults.device_id),
|
|
115
|
+
teams: asTeams(config["teams"], defaults.teams),
|
|
116
|
+
sync: {
|
|
117
|
+
enabled: asBool(config["sync"]?.["enabled"], defaults.sync.enabled),
|
|
118
|
+
interval_seconds: asNumber(config["sync"]?.["interval_seconds"], defaults.sync.interval_seconds),
|
|
119
|
+
batch_size: asNumber(config["sync"]?.["batch_size"], defaults.sync.batch_size)
|
|
120
|
+
},
|
|
121
|
+
search: {
|
|
122
|
+
default_limit: asNumber(config["search"]?.["default_limit"], defaults.search.default_limit),
|
|
123
|
+
local_boost: asNumber(config["search"]?.["local_boost"], defaults.search.local_boost),
|
|
124
|
+
scope: asScope(config["search"]?.["scope"], defaults.search.scope)
|
|
125
|
+
},
|
|
126
|
+
scrubbing: {
|
|
127
|
+
enabled: asBool(config["scrubbing"]?.["enabled"], defaults.scrubbing.enabled),
|
|
128
|
+
custom_patterns: asStringArray(config["scrubbing"]?.["custom_patterns"], defaults.scrubbing.custom_patterns),
|
|
129
|
+
default_sensitivity: asSensitivity(config["scrubbing"]?.["default_sensitivity"], defaults.scrubbing.default_sensitivity)
|
|
130
|
+
},
|
|
131
|
+
sentinel: {
|
|
132
|
+
enabled: asBool(config["sentinel"]?.["enabled"], defaults.sentinel.enabled),
|
|
133
|
+
mode: asSentinelMode(config["sentinel"]?.["mode"], defaults.sentinel.mode),
|
|
134
|
+
provider: asLlmProvider(config["sentinel"]?.["provider"], defaults.sentinel.provider),
|
|
135
|
+
model: asString(config["sentinel"]?.["model"], defaults.sentinel.model),
|
|
136
|
+
api_key: asString(config["sentinel"]?.["api_key"], defaults.sentinel.api_key),
|
|
137
|
+
base_url: asString(config["sentinel"]?.["base_url"], defaults.sentinel.base_url),
|
|
138
|
+
skip_patterns: asStringArray(config["sentinel"]?.["skip_patterns"], defaults.sentinel.skip_patterns),
|
|
139
|
+
daily_limit: asNumber(config["sentinel"]?.["daily_limit"], defaults.sentinel.daily_limit),
|
|
140
|
+
tier: asTier(config["sentinel"]?.["tier"], defaults.sentinel.tier)
|
|
141
|
+
},
|
|
142
|
+
observer: {
|
|
143
|
+
enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
|
|
144
|
+
mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
|
|
145
|
+
model: asString(config["observer"]?.["model"], defaults.observer.model)
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function saveConfig(config) {
|
|
150
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
151
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(config, null, 2) + `
|
|
154
|
+
`, "utf-8");
|
|
155
|
+
}
|
|
156
|
+
function configExists() {
|
|
157
|
+
return existsSync(SETTINGS_PATH);
|
|
158
|
+
}
|
|
159
|
+
function asString(value, fallback) {
|
|
160
|
+
return typeof value === "string" ? value : fallback;
|
|
161
|
+
}
|
|
162
|
+
function asNumber(value, fallback) {
|
|
163
|
+
return typeof value === "number" && !Number.isNaN(value) ? value : fallback;
|
|
164
|
+
}
|
|
165
|
+
function asBool(value, fallback) {
|
|
166
|
+
return typeof value === "boolean" ? value : fallback;
|
|
167
|
+
}
|
|
168
|
+
function asStringArray(value, fallback) {
|
|
169
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : fallback;
|
|
170
|
+
}
|
|
171
|
+
function asScope(value, fallback) {
|
|
172
|
+
if (value === "personal" || value === "team" || value === "all")
|
|
173
|
+
return value;
|
|
174
|
+
return fallback;
|
|
175
|
+
}
|
|
176
|
+
function asSensitivity(value, fallback) {
|
|
177
|
+
if (value === "shared" || value === "personal" || value === "secret")
|
|
178
|
+
return value;
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
function asSentinelMode(value, fallback) {
|
|
182
|
+
if (value === "advisory" || value === "blocking")
|
|
183
|
+
return value;
|
|
184
|
+
return fallback;
|
|
185
|
+
}
|
|
186
|
+
function asLlmProvider(value, fallback) {
|
|
187
|
+
if (value === "openai" || value === "anthropic" || value === "ollama" || value === "custom")
|
|
188
|
+
return value;
|
|
189
|
+
return fallback;
|
|
190
|
+
}
|
|
191
|
+
function asTier(value, fallback) {
|
|
192
|
+
if (value === "free" || value === "vibe" || value === "solo" || value === "pro" || value === "team" || value === "enterprise")
|
|
193
|
+
return value;
|
|
194
|
+
return fallback;
|
|
195
|
+
}
|
|
196
|
+
function asObserverMode(value, fallback) {
|
|
197
|
+
if (value === "per_event" || value === "per_session")
|
|
198
|
+
return value;
|
|
199
|
+
return fallback;
|
|
200
|
+
}
|
|
201
|
+
function asTeams(value, fallback) {
|
|
202
|
+
if (!Array.isArray(value))
|
|
203
|
+
return fallback;
|
|
204
|
+
return value.filter((t) => typeof t === "object" && t !== null && typeof t.id === "string" && typeof t.name === "string" && typeof t.namespace === "string");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/storage/migrations.ts
|
|
208
|
+
var MIGRATIONS = [
|
|
209
|
+
{
|
|
210
|
+
version: 1,
|
|
211
|
+
description: "Initial schema: projects, observations, sessions, sync, FTS5",
|
|
212
|
+
sql: `
|
|
213
|
+
-- Projects (canonical identity across machines)
|
|
214
|
+
CREATE TABLE projects (
|
|
215
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
216
|
+
canonical_id TEXT UNIQUE NOT NULL,
|
|
217
|
+
name TEXT NOT NULL,
|
|
218
|
+
local_path TEXT,
|
|
219
|
+
remote_url TEXT,
|
|
220
|
+
first_seen_epoch INTEGER NOT NULL,
|
|
221
|
+
last_active_epoch INTEGER NOT NULL
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
-- Core observations table
|
|
225
|
+
CREATE TABLE observations (
|
|
226
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
227
|
+
session_id TEXT,
|
|
228
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
229
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
230
|
+
'bugfix', 'discovery', 'decision', 'pattern',
|
|
231
|
+
'change', 'feature', 'refactor', 'digest'
|
|
232
|
+
)),
|
|
233
|
+
title TEXT NOT NULL,
|
|
234
|
+
narrative TEXT,
|
|
235
|
+
facts TEXT,
|
|
236
|
+
concepts TEXT,
|
|
237
|
+
files_read TEXT,
|
|
238
|
+
files_modified TEXT,
|
|
239
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
240
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
|
|
241
|
+
'active', 'aging', 'archived', 'purged', 'pinned'
|
|
242
|
+
)),
|
|
243
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
|
|
244
|
+
'shared', 'personal', 'secret'
|
|
245
|
+
)),
|
|
246
|
+
user_id TEXT NOT NULL,
|
|
247
|
+
device_id TEXT NOT NULL,
|
|
248
|
+
agent TEXT DEFAULT 'claude-code',
|
|
249
|
+
created_at TEXT NOT NULL,
|
|
250
|
+
created_at_epoch INTEGER NOT NULL,
|
|
251
|
+
archived_at_epoch INTEGER,
|
|
252
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
-- Session tracking
|
|
256
|
+
CREATE TABLE sessions (
|
|
257
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
258
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
259
|
+
project_id INTEGER REFERENCES projects(id),
|
|
260
|
+
user_id TEXT NOT NULL,
|
|
261
|
+
device_id TEXT NOT NULL,
|
|
262
|
+
agent TEXT DEFAULT 'claude-code',
|
|
263
|
+
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
|
|
264
|
+
observation_count INTEGER DEFAULT 0,
|
|
265
|
+
started_at_epoch INTEGER,
|
|
266
|
+
completed_at_epoch INTEGER
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
-- Session summaries (generated on Stop hook)
|
|
270
|
+
CREATE TABLE session_summaries (
|
|
271
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
272
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
273
|
+
project_id INTEGER REFERENCES projects(id),
|
|
274
|
+
user_id TEXT NOT NULL,
|
|
275
|
+
request TEXT,
|
|
276
|
+
investigated TEXT,
|
|
277
|
+
learned TEXT,
|
|
278
|
+
completed TEXT,
|
|
279
|
+
next_steps TEXT,
|
|
280
|
+
created_at_epoch INTEGER
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
-- Sync outbox (offline-first queue)
|
|
284
|
+
CREATE TABLE sync_outbox (
|
|
285
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
286
|
+
record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
|
|
287
|
+
record_id INTEGER NOT NULL,
|
|
288
|
+
status TEXT DEFAULT 'pending' CHECK (status IN (
|
|
289
|
+
'pending', 'syncing', 'synced', 'failed'
|
|
290
|
+
)),
|
|
291
|
+
retry_count INTEGER DEFAULT 0,
|
|
292
|
+
max_retries INTEGER DEFAULT 10,
|
|
293
|
+
last_error TEXT,
|
|
294
|
+
created_at_epoch INTEGER NOT NULL,
|
|
295
|
+
synced_at_epoch INTEGER,
|
|
296
|
+
next_retry_epoch INTEGER
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
-- Sync high-water mark and lifecycle job tracking
|
|
300
|
+
CREATE TABLE sync_state (
|
|
301
|
+
key TEXT PRIMARY KEY,
|
|
302
|
+
value TEXT NOT NULL
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
-- FTS5 for local offline search (external content mode)
|
|
306
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
307
|
+
title, narrative, facts, concepts,
|
|
308
|
+
content=observations,
|
|
309
|
+
content_rowid=id
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
-- Indexes: observations
|
|
313
|
+
CREATE INDEX idx_observations_project ON observations(project_id);
|
|
314
|
+
CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
|
|
315
|
+
CREATE INDEX idx_observations_type ON observations(type);
|
|
316
|
+
CREATE INDEX idx_observations_created ON observations(created_at_epoch);
|
|
317
|
+
CREATE INDEX idx_observations_session ON observations(session_id);
|
|
318
|
+
CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
|
|
319
|
+
CREATE INDEX idx_observations_quality ON observations(quality);
|
|
320
|
+
CREATE INDEX idx_observations_user ON observations(user_id);
|
|
321
|
+
|
|
322
|
+
-- Indexes: sessions
|
|
323
|
+
CREATE INDEX idx_sessions_project ON sessions(project_id);
|
|
324
|
+
|
|
325
|
+
-- Indexes: sync outbox
|
|
326
|
+
CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
|
|
327
|
+
CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
|
|
328
|
+
`
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
version: 2,
|
|
332
|
+
description: "Add superseded_by for knowledge supersession",
|
|
333
|
+
sql: `
|
|
334
|
+
ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
|
|
335
|
+
CREATE INDEX idx_observations_superseded ON observations(superseded_by);
|
|
336
|
+
`
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
version: 3,
|
|
340
|
+
description: "Add remote_source_id for pull deduplication",
|
|
341
|
+
sql: `
|
|
342
|
+
ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
|
|
343
|
+
CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
344
|
+
`
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
version: 4,
|
|
348
|
+
description: "Add sqlite-vec for local semantic search",
|
|
349
|
+
sql: `
|
|
350
|
+
CREATE VIRTUAL TABLE vec_observations USING vec0(
|
|
351
|
+
observation_id INTEGER PRIMARY KEY,
|
|
352
|
+
embedding float[384]
|
|
353
|
+
);
|
|
354
|
+
`,
|
|
355
|
+
condition: (db) => isVecExtensionLoaded(db)
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
version: 5,
|
|
359
|
+
description: "Session metrics and security findings",
|
|
360
|
+
sql: `
|
|
361
|
+
ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
|
|
362
|
+
ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
|
|
363
|
+
ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
|
|
364
|
+
|
|
365
|
+
CREATE TABLE security_findings (
|
|
366
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
367
|
+
session_id TEXT,
|
|
368
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
369
|
+
finding_type TEXT NOT NULL,
|
|
370
|
+
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
|
|
371
|
+
pattern_name TEXT NOT NULL,
|
|
372
|
+
file_path TEXT,
|
|
373
|
+
snippet TEXT,
|
|
374
|
+
tool_name TEXT,
|
|
375
|
+
user_id TEXT NOT NULL,
|
|
376
|
+
device_id TEXT NOT NULL,
|
|
377
|
+
created_at_epoch INTEGER NOT NULL
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
CREATE INDEX idx_security_findings_session ON security_findings(session_id);
|
|
381
|
+
CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
|
|
382
|
+
CREATE INDEX idx_security_findings_severity ON security_findings(severity);
|
|
383
|
+
`
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
version: 6,
|
|
387
|
+
description: "Add risk_score, expand observation types to include standard",
|
|
388
|
+
sql: `
|
|
389
|
+
ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
|
|
390
|
+
|
|
391
|
+
-- Recreate observations table with expanded type CHECK to include 'standard'
|
|
392
|
+
-- SQLite doesn't support ALTER CHECK, so we recreate the table
|
|
393
|
+
CREATE TABLE observations_new (
|
|
394
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
395
|
+
session_id TEXT,
|
|
396
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
397
|
+
type TEXT NOT NULL CHECK (type IN (
|
|
398
|
+
'bugfix', 'discovery', 'decision', 'pattern',
|
|
399
|
+
'change', 'feature', 'refactor', 'digest', 'standard'
|
|
400
|
+
)),
|
|
401
|
+
title TEXT NOT NULL,
|
|
402
|
+
narrative TEXT,
|
|
403
|
+
facts TEXT,
|
|
404
|
+
concepts TEXT,
|
|
405
|
+
files_read TEXT,
|
|
406
|
+
files_modified TEXT,
|
|
407
|
+
quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
|
|
408
|
+
lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
|
|
409
|
+
'active', 'aging', 'archived', 'purged', 'pinned'
|
|
410
|
+
)),
|
|
411
|
+
sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
|
|
412
|
+
'shared', 'personal', 'secret'
|
|
413
|
+
)),
|
|
414
|
+
user_id TEXT NOT NULL,
|
|
415
|
+
device_id TEXT NOT NULL,
|
|
416
|
+
agent TEXT DEFAULT 'claude-code',
|
|
417
|
+
created_at TEXT NOT NULL,
|
|
418
|
+
created_at_epoch INTEGER NOT NULL,
|
|
419
|
+
archived_at_epoch INTEGER,
|
|
420
|
+
compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
421
|
+
superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
|
|
422
|
+
remote_source_id TEXT
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
INSERT INTO observations_new SELECT * FROM observations;
|
|
426
|
+
|
|
427
|
+
DROP TABLE observations;
|
|
428
|
+
ALTER TABLE observations_new RENAME TO observations;
|
|
429
|
+
|
|
430
|
+
-- Recreate indexes
|
|
431
|
+
CREATE INDEX idx_observations_project ON observations(project_id);
|
|
432
|
+
CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
|
|
433
|
+
CREATE INDEX idx_observations_type ON observations(type);
|
|
434
|
+
CREATE INDEX idx_observations_created ON observations(created_at_epoch);
|
|
435
|
+
CREATE INDEX idx_observations_session ON observations(session_id);
|
|
436
|
+
CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
|
|
437
|
+
CREATE INDEX idx_observations_quality ON observations(quality);
|
|
438
|
+
CREATE INDEX idx_observations_user ON observations(user_id);
|
|
439
|
+
CREATE INDEX idx_observations_superseded ON observations(superseded_by);
|
|
440
|
+
CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
|
|
441
|
+
|
|
442
|
+
-- Recreate FTS5 (external content mode — must rebuild after table recreation)
|
|
443
|
+
DROP TABLE IF EXISTS observations_fts;
|
|
444
|
+
CREATE VIRTUAL TABLE observations_fts USING fts5(
|
|
445
|
+
title, narrative, facts, concepts,
|
|
446
|
+
content=observations,
|
|
447
|
+
content_rowid=id
|
|
448
|
+
);
|
|
449
|
+
-- Rebuild FTS index
|
|
450
|
+
INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
|
|
451
|
+
`
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
version: 7,
|
|
455
|
+
description: "Add packs_installed table for help pack tracking",
|
|
456
|
+
sql: `
|
|
457
|
+
CREATE TABLE IF NOT EXISTS packs_installed (
|
|
458
|
+
name TEXT PRIMARY KEY,
|
|
459
|
+
installed_at INTEGER NOT NULL,
|
|
460
|
+
observation_count INTEGER DEFAULT 0
|
|
461
|
+
);
|
|
462
|
+
`
|
|
463
|
+
}
|
|
464
|
+
];
|
|
465
|
+
function isVecExtensionLoaded(db) {
|
|
466
|
+
try {
|
|
467
|
+
db.exec("SELECT vec_version()");
|
|
468
|
+
return true;
|
|
469
|
+
} catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function runMigrations(db) {
|
|
474
|
+
const currentVersion = db.query("PRAGMA user_version").get();
|
|
475
|
+
let version = currentVersion.user_version;
|
|
476
|
+
for (const migration of MIGRATIONS) {
|
|
477
|
+
if (migration.version <= version)
|
|
478
|
+
continue;
|
|
479
|
+
if (migration.condition && !migration.condition(db)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
db.exec("BEGIN TRANSACTION");
|
|
483
|
+
try {
|
|
484
|
+
db.exec(migration.sql);
|
|
485
|
+
db.exec(`PRAGMA user_version = ${migration.version}`);
|
|
486
|
+
db.exec("COMMIT");
|
|
487
|
+
version = migration.version;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
db.exec("ROLLBACK");
|
|
490
|
+
throw new Error(`Migration ${migration.version} (${migration.description}) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
|
|
495
|
+
|
|
496
|
+
// src/storage/sqlite.ts
|
|
497
|
+
var IS_BUN = typeof globalThis.Bun !== "undefined";
|
|
498
|
+
function openDatabase(dbPath) {
|
|
499
|
+
if (IS_BUN) {
|
|
500
|
+
return openBunDatabase(dbPath);
|
|
501
|
+
}
|
|
502
|
+
return openNodeDatabase(dbPath);
|
|
503
|
+
}
|
|
504
|
+
function openBunDatabase(dbPath) {
|
|
505
|
+
const { Database } = __require("bun:sqlite");
|
|
506
|
+
if (process.platform === "darwin") {
|
|
507
|
+
const { existsSync: existsSync2 } = __require("node:fs");
|
|
508
|
+
const paths = [
|
|
509
|
+
"/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib",
|
|
510
|
+
"/usr/local/opt/sqlite3/lib/libsqlite3.dylib"
|
|
511
|
+
];
|
|
512
|
+
for (const p of paths) {
|
|
513
|
+
if (existsSync2(p)) {
|
|
514
|
+
try {
|
|
515
|
+
Database.setCustomSQLite(p);
|
|
516
|
+
} catch {}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const db = new Database(dbPath);
|
|
522
|
+
return db;
|
|
523
|
+
}
|
|
524
|
+
function openNodeDatabase(dbPath) {
|
|
525
|
+
const BetterSqlite3 = __require("better-sqlite3");
|
|
526
|
+
const raw = new BetterSqlite3(dbPath);
|
|
527
|
+
return {
|
|
528
|
+
query(sql) {
|
|
529
|
+
const stmt = raw.prepare(sql);
|
|
530
|
+
return {
|
|
531
|
+
get(...params) {
|
|
532
|
+
return stmt.get(...params);
|
|
533
|
+
},
|
|
534
|
+
all(...params) {
|
|
535
|
+
return stmt.all(...params);
|
|
536
|
+
},
|
|
537
|
+
run(...params) {
|
|
538
|
+
return stmt.run(...params);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
},
|
|
542
|
+
exec(sql) {
|
|
543
|
+
raw.exec(sql);
|
|
544
|
+
},
|
|
545
|
+
close() {
|
|
546
|
+
raw.close();
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
class MemDatabase {
|
|
552
|
+
db;
|
|
553
|
+
vecAvailable;
|
|
554
|
+
constructor(dbPath) {
|
|
555
|
+
this.db = openDatabase(dbPath);
|
|
556
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
557
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
558
|
+
this.vecAvailable = this.loadVecExtension();
|
|
559
|
+
runMigrations(this.db);
|
|
560
|
+
}
|
|
561
|
+
loadVecExtension() {
|
|
562
|
+
try {
|
|
563
|
+
const sqliteVec = __require("sqlite-vec");
|
|
564
|
+
sqliteVec.load(this.db);
|
|
565
|
+
return true;
|
|
566
|
+
} catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
close() {
|
|
571
|
+
this.db.close();
|
|
572
|
+
}
|
|
573
|
+
upsertProject(project) {
|
|
574
|
+
const now = Math.floor(Date.now() / 1000);
|
|
575
|
+
const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
|
|
576
|
+
if (existing) {
|
|
577
|
+
this.db.query(`UPDATE projects SET
|
|
578
|
+
local_path = COALESCE(?, local_path),
|
|
579
|
+
remote_url = COALESCE(?, remote_url),
|
|
580
|
+
last_active_epoch = ?
|
|
581
|
+
WHERE id = ?`).run(project.local_path ?? null, project.remote_url ?? null, now, existing.id);
|
|
582
|
+
return {
|
|
583
|
+
...existing,
|
|
584
|
+
local_path: project.local_path ?? existing.local_path,
|
|
585
|
+
remote_url: project.remote_url ?? existing.remote_url,
|
|
586
|
+
last_active_epoch: now
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
|
|
590
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
|
|
591
|
+
return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
|
|
592
|
+
}
|
|
593
|
+
getProjectByCanonicalId(canonicalId) {
|
|
594
|
+
return this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId) ?? null;
|
|
595
|
+
}
|
|
596
|
+
getProjectById(id) {
|
|
597
|
+
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
|
|
598
|
+
}
|
|
599
|
+
insertObservation(obs) {
|
|
600
|
+
const now = Math.floor(Date.now() / 1000);
|
|
601
|
+
const createdAt = new Date().toISOString();
|
|
602
|
+
const result = this.db.query(`INSERT INTO observations (
|
|
603
|
+
session_id, project_id, type, title, narrative, facts, concepts,
|
|
604
|
+
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
605
|
+
user_id, device_id, agent, created_at, created_at_epoch
|
|
606
|
+
) 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);
|
|
607
|
+
const id = Number(result.lastInsertRowid);
|
|
608
|
+
const row = this.getObservationById(id);
|
|
609
|
+
this.ftsInsert(row);
|
|
610
|
+
if (obs.session_id) {
|
|
611
|
+
this.db.query("UPDATE sessions SET observation_count = observation_count + 1 WHERE session_id = ?").run(obs.session_id);
|
|
612
|
+
}
|
|
613
|
+
return row;
|
|
614
|
+
}
|
|
615
|
+
getObservationById(id) {
|
|
616
|
+
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
617
|
+
}
|
|
618
|
+
getObservationsByIds(ids) {
|
|
619
|
+
if (ids.length === 0)
|
|
620
|
+
return [];
|
|
621
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
622
|
+
return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
|
|
623
|
+
}
|
|
624
|
+
getRecentObservations(projectId, sincEpoch, limit = 50) {
|
|
625
|
+
return this.db.query(`SELECT * FROM observations
|
|
626
|
+
WHERE project_id = ? AND created_at_epoch > ?
|
|
627
|
+
ORDER BY created_at_epoch DESC
|
|
628
|
+
LIMIT ?`).all(projectId, sincEpoch, limit);
|
|
629
|
+
}
|
|
630
|
+
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
631
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
632
|
+
if (projectId !== null) {
|
|
633
|
+
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
634
|
+
FROM observations_fts
|
|
635
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
636
|
+
WHERE observations_fts MATCH ?
|
|
637
|
+
AND o.project_id = ?
|
|
638
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
639
|
+
ORDER BY observations_fts.rank
|
|
640
|
+
LIMIT ?`).all(query, projectId, ...lifecycles, limit);
|
|
641
|
+
}
|
|
642
|
+
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
643
|
+
FROM observations_fts
|
|
644
|
+
JOIN observations o ON o.id = observations_fts.rowid
|
|
645
|
+
WHERE observations_fts MATCH ?
|
|
646
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
647
|
+
ORDER BY observations_fts.rank
|
|
648
|
+
LIMIT ?`).all(query, ...lifecycles, limit);
|
|
649
|
+
}
|
|
650
|
+
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
|
|
651
|
+
const anchor = this.getObservationById(anchorId);
|
|
652
|
+
if (!anchor)
|
|
653
|
+
return [];
|
|
654
|
+
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
655
|
+
const projectParams = projectId !== null ? [projectId] : [];
|
|
656
|
+
const before = this.db.query(`SELECT * FROM observations
|
|
657
|
+
WHERE created_at_epoch < ? ${projectFilter}
|
|
658
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
659
|
+
ORDER BY created_at_epoch DESC
|
|
660
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
|
|
661
|
+
const after = this.db.query(`SELECT * FROM observations
|
|
662
|
+
WHERE created_at_epoch > ? ${projectFilter}
|
|
663
|
+
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
664
|
+
ORDER BY created_at_epoch ASC
|
|
665
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
|
|
666
|
+
return [...before.reverse(), anchor, ...after];
|
|
667
|
+
}
|
|
668
|
+
pinObservation(id, pinned) {
|
|
669
|
+
const obs = this.getObservationById(id);
|
|
670
|
+
if (!obs)
|
|
671
|
+
return false;
|
|
672
|
+
if (pinned) {
|
|
673
|
+
if (obs.lifecycle !== "active" && obs.lifecycle !== "aging")
|
|
674
|
+
return false;
|
|
675
|
+
this.db.query("UPDATE observations SET lifecycle = 'pinned' WHERE id = ?").run(id);
|
|
676
|
+
} else {
|
|
677
|
+
if (obs.lifecycle !== "pinned")
|
|
678
|
+
return false;
|
|
679
|
+
this.db.query("UPDATE observations SET lifecycle = 'active' WHERE id = ?").run(id);
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
getActiveObservationCount(userId) {
|
|
684
|
+
if (userId) {
|
|
685
|
+
const result2 = this.db.query(`SELECT COUNT(*) as count FROM observations
|
|
686
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
687
|
+
AND sensitivity != 'secret'
|
|
688
|
+
AND user_id = ?`).get(userId);
|
|
689
|
+
return result2?.count ?? 0;
|
|
690
|
+
}
|
|
691
|
+
const result = this.db.query(`SELECT COUNT(*) as count FROM observations
|
|
692
|
+
WHERE lifecycle IN ('active', 'aging')
|
|
693
|
+
AND sensitivity != 'secret'`).get();
|
|
694
|
+
return result?.count ?? 0;
|
|
695
|
+
}
|
|
696
|
+
supersedeObservation(oldId, newId) {
|
|
697
|
+
if (oldId === newId)
|
|
698
|
+
return false;
|
|
699
|
+
const replacement = this.getObservationById(newId);
|
|
700
|
+
if (!replacement)
|
|
701
|
+
return false;
|
|
702
|
+
let targetId = oldId;
|
|
703
|
+
const visited = new Set;
|
|
704
|
+
for (let depth = 0;depth < 10; depth++) {
|
|
705
|
+
const target2 = this.getObservationById(targetId);
|
|
706
|
+
if (!target2)
|
|
707
|
+
return false;
|
|
708
|
+
if (target2.superseded_by === null)
|
|
709
|
+
break;
|
|
710
|
+
if (target2.superseded_by === newId)
|
|
711
|
+
return true;
|
|
712
|
+
visited.add(targetId);
|
|
713
|
+
targetId = target2.superseded_by;
|
|
714
|
+
if (visited.has(targetId))
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
const target = this.getObservationById(targetId);
|
|
718
|
+
if (!target)
|
|
719
|
+
return false;
|
|
720
|
+
if (target.superseded_by !== null)
|
|
721
|
+
return false;
|
|
722
|
+
if (targetId === newId)
|
|
723
|
+
return false;
|
|
724
|
+
const now = Math.floor(Date.now() / 1000);
|
|
725
|
+
this.db.query(`UPDATE observations
|
|
726
|
+
SET superseded_by = ?, lifecycle = 'archived', archived_at_epoch = ?
|
|
727
|
+
WHERE id = ?`).run(newId, now, targetId);
|
|
728
|
+
this.ftsDelete(target);
|
|
729
|
+
this.vecDelete(targetId);
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
isSuperseded(id) {
|
|
733
|
+
const obs = this.getObservationById(id);
|
|
734
|
+
return obs !== null && obs.superseded_by !== null;
|
|
735
|
+
}
|
|
736
|
+
upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
|
|
737
|
+
const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
738
|
+
if (existing)
|
|
739
|
+
return existing;
|
|
740
|
+
const now = Math.floor(Date.now() / 1000);
|
|
741
|
+
this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
|
|
742
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
|
|
743
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
744
|
+
}
|
|
745
|
+
completeSession(sessionId) {
|
|
746
|
+
const now = Math.floor(Date.now() / 1000);
|
|
747
|
+
this.db.query("UPDATE sessions SET status = 'completed', completed_at_epoch = ? WHERE session_id = ?").run(now, sessionId);
|
|
748
|
+
}
|
|
749
|
+
addToOutbox(recordType, recordId) {
|
|
750
|
+
const now = Math.floor(Date.now() / 1000);
|
|
751
|
+
this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
|
|
752
|
+
VALUES (?, ?, ?)`).run(recordType, recordId, now);
|
|
753
|
+
}
|
|
754
|
+
getSyncState(key) {
|
|
755
|
+
const row = this.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
|
|
756
|
+
return row?.value ?? null;
|
|
757
|
+
}
|
|
758
|
+
setSyncState(key, value) {
|
|
759
|
+
this.db.query("INSERT INTO sync_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?").run(key, value, value);
|
|
760
|
+
}
|
|
761
|
+
ftsInsert(obs) {
|
|
762
|
+
this.db.query(`INSERT INTO observations_fts (rowid, title, narrative, facts, concepts)
|
|
763
|
+
VALUES (?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
764
|
+
}
|
|
765
|
+
ftsDelete(obs) {
|
|
766
|
+
this.db.query(`INSERT INTO observations_fts (observations_fts, rowid, title, narrative, facts, concepts)
|
|
767
|
+
VALUES ('delete', ?, ?, ?, ?, ?)`).run(obs.id, obs.title, obs.narrative, obs.facts, obs.concepts);
|
|
768
|
+
}
|
|
769
|
+
vecInsert(observationId, embedding) {
|
|
770
|
+
if (!this.vecAvailable)
|
|
771
|
+
return;
|
|
772
|
+
this.db.query("INSERT OR REPLACE INTO vec_observations (observation_id, embedding) VALUES (?, ?)").run(observationId, new Uint8Array(embedding.buffer));
|
|
773
|
+
}
|
|
774
|
+
vecDelete(observationId) {
|
|
775
|
+
if (!this.vecAvailable)
|
|
776
|
+
return;
|
|
777
|
+
this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
|
|
778
|
+
}
|
|
779
|
+
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
780
|
+
if (!this.vecAvailable)
|
|
781
|
+
return [];
|
|
782
|
+
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
783
|
+
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
784
|
+
if (projectId !== null) {
|
|
785
|
+
return this.db.query(`SELECT v.observation_id, v.distance
|
|
786
|
+
FROM vec_observations v
|
|
787
|
+
JOIN observations o ON o.id = v.observation_id
|
|
788
|
+
WHERE v.embedding MATCH ?
|
|
789
|
+
AND k = ?
|
|
790
|
+
AND o.project_id = ?
|
|
791
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
792
|
+
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
|
|
793
|
+
}
|
|
794
|
+
return this.db.query(`SELECT v.observation_id, v.distance
|
|
795
|
+
FROM vec_observations v
|
|
796
|
+
JOIN observations o ON o.id = v.observation_id
|
|
797
|
+
WHERE v.embedding MATCH ?
|
|
798
|
+
AND k = ?
|
|
799
|
+
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
800
|
+
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
|
|
801
|
+
}
|
|
802
|
+
getUnembeddedCount() {
|
|
803
|
+
if (!this.vecAvailable)
|
|
804
|
+
return 0;
|
|
805
|
+
const result = this.db.query(`SELECT COUNT(*) as count FROM observations o
|
|
806
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
807
|
+
AND o.superseded_by IS NULL
|
|
808
|
+
AND NOT EXISTS (
|
|
809
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
810
|
+
)`).get();
|
|
811
|
+
return result?.count ?? 0;
|
|
812
|
+
}
|
|
813
|
+
getUnembeddedObservations(limit = 100) {
|
|
814
|
+
if (!this.vecAvailable)
|
|
815
|
+
return [];
|
|
816
|
+
return this.db.query(`SELECT o.* FROM observations o
|
|
817
|
+
WHERE o.lifecycle IN ('active', 'aging', 'pinned')
|
|
818
|
+
AND o.superseded_by IS NULL
|
|
819
|
+
AND NOT EXISTS (
|
|
820
|
+
SELECT 1 FROM vec_observations v WHERE v.observation_id = o.id
|
|
821
|
+
)
|
|
822
|
+
ORDER BY o.created_at_epoch DESC
|
|
823
|
+
LIMIT ?`).all(limit);
|
|
824
|
+
}
|
|
825
|
+
insertSessionSummary(summary) {
|
|
826
|
+
const now = Math.floor(Date.now() / 1000);
|
|
827
|
+
const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
|
|
828
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
|
|
829
|
+
const id = Number(result.lastInsertRowid);
|
|
830
|
+
return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
|
|
831
|
+
}
|
|
832
|
+
getSessionSummary(sessionId) {
|
|
833
|
+
return this.db.query("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId) ?? null;
|
|
834
|
+
}
|
|
835
|
+
getRecentSummaries(projectId, limit = 5) {
|
|
836
|
+
return this.db.query(`SELECT * FROM session_summaries
|
|
837
|
+
WHERE project_id = ?
|
|
838
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
839
|
+
LIMIT ?`).all(projectId, limit);
|
|
840
|
+
}
|
|
841
|
+
incrementSessionMetrics(sessionId, increments) {
|
|
842
|
+
const sets = [];
|
|
843
|
+
const params = [];
|
|
844
|
+
if (increments.files) {
|
|
845
|
+
sets.push("files_touched_count = files_touched_count + ?");
|
|
846
|
+
params.push(increments.files);
|
|
847
|
+
}
|
|
848
|
+
if (increments.searches) {
|
|
849
|
+
sets.push("searches_performed = searches_performed + ?");
|
|
850
|
+
params.push(increments.searches);
|
|
851
|
+
}
|
|
852
|
+
if (increments.toolCalls) {
|
|
853
|
+
sets.push("tool_calls_count = tool_calls_count + ?");
|
|
854
|
+
params.push(increments.toolCalls);
|
|
855
|
+
}
|
|
856
|
+
if (sets.length === 0)
|
|
857
|
+
return;
|
|
858
|
+
params.push(sessionId);
|
|
859
|
+
this.db.query(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`).run(...params);
|
|
860
|
+
}
|
|
861
|
+
getSessionMetrics(sessionId) {
|
|
862
|
+
return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId) ?? null;
|
|
863
|
+
}
|
|
864
|
+
insertSecurityFinding(finding) {
|
|
865
|
+
const now = Math.floor(Date.now() / 1000);
|
|
866
|
+
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)
|
|
867
|
+
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);
|
|
868
|
+
const id = Number(result.lastInsertRowid);
|
|
869
|
+
return this.db.query("SELECT * FROM security_findings WHERE id = ?").get(id);
|
|
870
|
+
}
|
|
871
|
+
getSecurityFindings(projectId, options = {}) {
|
|
872
|
+
const limit = options.limit ?? 50;
|
|
873
|
+
if (options.severity) {
|
|
874
|
+
return this.db.query(`SELECT * FROM security_findings
|
|
875
|
+
WHERE project_id = ? AND severity = ?
|
|
876
|
+
ORDER BY created_at_epoch DESC
|
|
877
|
+
LIMIT ?`).all(projectId, options.severity, limit);
|
|
878
|
+
}
|
|
879
|
+
return this.db.query(`SELECT * FROM security_findings
|
|
880
|
+
WHERE project_id = ?
|
|
881
|
+
ORDER BY created_at_epoch DESC
|
|
882
|
+
LIMIT ?`).all(projectId, limit);
|
|
883
|
+
}
|
|
884
|
+
getSecurityFindingsCount(projectId) {
|
|
885
|
+
const rows = this.db.query(`SELECT severity, COUNT(*) as count FROM security_findings
|
|
886
|
+
WHERE project_id = ?
|
|
887
|
+
GROUP BY severity`).all(projectId);
|
|
888
|
+
const counts = {
|
|
889
|
+
critical: 0,
|
|
890
|
+
high: 0,
|
|
891
|
+
medium: 0,
|
|
892
|
+
low: 0
|
|
893
|
+
};
|
|
894
|
+
for (const row of rows) {
|
|
895
|
+
counts[row.severity] = row.count;
|
|
896
|
+
}
|
|
897
|
+
return counts;
|
|
898
|
+
}
|
|
899
|
+
setSessionRiskScore(sessionId, score) {
|
|
900
|
+
this.db.query("UPDATE sessions SET risk_score = ? WHERE session_id = ?").run(score, sessionId);
|
|
901
|
+
}
|
|
902
|
+
getObservationsBySession(sessionId) {
|
|
903
|
+
return this.db.query(`SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC`).all(sessionId);
|
|
904
|
+
}
|
|
905
|
+
getInstalledPacks() {
|
|
906
|
+
try {
|
|
907
|
+
const rows = this.db.query("SELECT name FROM packs_installed").all();
|
|
908
|
+
return rows.map((r) => r.name);
|
|
909
|
+
} catch {
|
|
910
|
+
return [];
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
markPackInstalled(name, observationCount) {
|
|
914
|
+
const now = Math.floor(Date.now() / 1000);
|
|
915
|
+
this.db.query("INSERT OR REPLACE INTO packs_installed (name, installed_at, observation_count) VALUES (?, ?, ?)").run(name, now, observationCount);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/storage/outbox.ts
|
|
920
|
+
function getOutboxStats(db) {
|
|
921
|
+
const rows = db.db.query("SELECT status, COUNT(*) as count FROM sync_outbox GROUP BY status").all();
|
|
922
|
+
const stats = {
|
|
923
|
+
pending: 0,
|
|
924
|
+
syncing: 0,
|
|
925
|
+
synced: 0,
|
|
926
|
+
failed: 0
|
|
927
|
+
};
|
|
928
|
+
for (const row of rows) {
|
|
929
|
+
stats[row.status] = row.count;
|
|
930
|
+
}
|
|
931
|
+
return stats;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/provisioning/provision.ts
|
|
935
|
+
var DEFAULT_CANDENGO_URL = "https://www.candengo.com";
|
|
936
|
+
|
|
937
|
+
class ProvisionError extends Error {
|
|
938
|
+
status;
|
|
939
|
+
detail;
|
|
940
|
+
constructor(status, detail) {
|
|
941
|
+
super(detail);
|
|
942
|
+
this.status = status;
|
|
943
|
+
this.detail = detail;
|
|
944
|
+
this.name = "ProvisionError";
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async function provision(baseUrl, request) {
|
|
948
|
+
const url = `${baseUrl.replace(/\/$/, "")}/v1/mem/provision`;
|
|
949
|
+
const response = await fetch(url, {
|
|
950
|
+
method: "POST",
|
|
951
|
+
headers: { "Content-Type": "application/json" },
|
|
952
|
+
body: JSON.stringify(request)
|
|
953
|
+
});
|
|
954
|
+
if (!response.ok) {
|
|
955
|
+
let detail;
|
|
956
|
+
try {
|
|
957
|
+
const body = await response.json();
|
|
958
|
+
detail = body.detail ?? `HTTP ${response.status}`;
|
|
959
|
+
} catch {
|
|
960
|
+
detail = `HTTP ${response.status}`;
|
|
961
|
+
}
|
|
962
|
+
if (response.status === 401 || response.status === 403) {
|
|
963
|
+
throw new ProvisionError(response.status, "Invalid or expired provisioning token");
|
|
964
|
+
}
|
|
965
|
+
if (response.status === 409) {
|
|
966
|
+
throw new ProvisionError(response.status, "Token has already been used");
|
|
967
|
+
}
|
|
968
|
+
throw new ProvisionError(response.status, detail);
|
|
969
|
+
}
|
|
970
|
+
const data = await response.json();
|
|
971
|
+
if (!data.api_key?.startsWith("cvk_")) {
|
|
972
|
+
throw new ProvisionError(0, "Server returned invalid API key format");
|
|
973
|
+
}
|
|
974
|
+
if (!data.site_id || !data.namespace || !data.user_id) {
|
|
975
|
+
throw new ProvisionError(0, "Server returned incomplete credentials");
|
|
976
|
+
}
|
|
977
|
+
return data;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/provisioning/browser-auth.ts
|
|
981
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
982
|
+
import { createServer } from "node:http";
|
|
983
|
+
import { execFile } from "node:child_process";
|
|
984
|
+
var CALLBACK_TIMEOUT_MS = 120000;
|
|
985
|
+
async function runBrowserAuth(candengoUrl) {
|
|
986
|
+
const state = randomBytes2(16).toString("hex");
|
|
987
|
+
const { port, waitForCallback, stop } = await startCallbackServer(state);
|
|
988
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
989
|
+
const authUrl = new URL("/connect/mem", candengoUrl);
|
|
990
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
991
|
+
authUrl.searchParams.set("state", state);
|
|
992
|
+
console.log(`
|
|
993
|
+
Opening browser to authorize Engrm...`);
|
|
994
|
+
console.log(`If the browser doesn't open, visit:
|
|
995
|
+
${authUrl.toString()}
|
|
996
|
+
`);
|
|
997
|
+
const opened = await openBrowser(authUrl.toString());
|
|
998
|
+
if (!opened) {
|
|
999
|
+
console.log("Could not open browser. Use --token or --no-browser instead.");
|
|
1000
|
+
stop();
|
|
1001
|
+
throw new Error("Browser launch failed");
|
|
1002
|
+
}
|
|
1003
|
+
console.log("Waiting for authorization...");
|
|
1004
|
+
try {
|
|
1005
|
+
const result = await waitForCallback;
|
|
1006
|
+
return result;
|
|
1007
|
+
} finally {
|
|
1008
|
+
stop();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async function startCallbackServer(expectedState) {
|
|
1012
|
+
let resolveCallback;
|
|
1013
|
+
let rejectCallback;
|
|
1014
|
+
const waitForCallback = new Promise((resolve, reject) => {
|
|
1015
|
+
resolveCallback = resolve;
|
|
1016
|
+
rejectCallback = reject;
|
|
1017
|
+
});
|
|
1018
|
+
const server = createServer((req, res) => {
|
|
1019
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
1020
|
+
if (url.pathname === "/callback") {
|
|
1021
|
+
const code = url.searchParams.get("code");
|
|
1022
|
+
const state = url.searchParams.get("state");
|
|
1023
|
+
const error = url.searchParams.get("error");
|
|
1024
|
+
if (error) {
|
|
1025
|
+
const desc = url.searchParams.get("error_description") ?? "Authorization denied";
|
|
1026
|
+
rejectCallback(new Error(desc));
|
|
1027
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1028
|
+
res.end(errorPage(desc));
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (!code || !state) {
|
|
1032
|
+
rejectCallback(new Error("Missing code or state in callback"));
|
|
1033
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1034
|
+
res.end(errorPage("Missing authorization parameters"));
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (state !== expectedState) {
|
|
1038
|
+
rejectCallback(new Error("State mismatch — possible CSRF"));
|
|
1039
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1040
|
+
res.end(errorPage("Security error: state mismatch"));
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
resolveCallback({ code, state });
|
|
1044
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1045
|
+
res.end(successPage());
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
res.writeHead(404);
|
|
1049
|
+
res.end("Not found");
|
|
1050
|
+
});
|
|
1051
|
+
await new Promise((resolve) => {
|
|
1052
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
1053
|
+
});
|
|
1054
|
+
const addr = server.address();
|
|
1055
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1056
|
+
const timeout = setTimeout(() => {
|
|
1057
|
+
rejectCallback(new Error("Authorization timed out. Please try again."));
|
|
1058
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
1059
|
+
const stop = () => {
|
|
1060
|
+
clearTimeout(timeout);
|
|
1061
|
+
server.close();
|
|
1062
|
+
};
|
|
1063
|
+
return { port, waitForCallback, stop };
|
|
1064
|
+
}
|
|
1065
|
+
async function openBrowser(url) {
|
|
1066
|
+
try {
|
|
1067
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
1068
|
+
const args = process.platform === "win32" ? ["/c", "start", url] : [url];
|
|
1069
|
+
return new Promise((resolve) => {
|
|
1070
|
+
execFile(cmd, args, (error) => {
|
|
1071
|
+
resolve(!error);
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
} catch {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function successPage() {
|
|
1079
|
+
return `<!DOCTYPE html>
|
|
1080
|
+
<html><head><title>Engrm</title>
|
|
1081
|
+
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
|
|
1082
|
+
.card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
|
|
1083
|
+
h1{color:#10b981;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
|
|
1084
|
+
<body><div class="card"><h1>Connected!</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`;
|
|
1085
|
+
}
|
|
1086
|
+
function errorPage(message) {
|
|
1087
|
+
return `<!DOCTYPE html>
|
|
1088
|
+
<html><head><title>Engrm</title>
|
|
1089
|
+
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
|
|
1090
|
+
.card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
|
|
1091
|
+
h1{color:#ef4444;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
|
|
1092
|
+
<body><div class="card"><h1>Authorization Failed</h1><p>${message}</p></div></body></html>`;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/register.ts
|
|
1096
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1097
|
+
import { homedir as homedir2 } from "node:os";
|
|
1098
|
+
import { join as join2, dirname } from "node:path";
|
|
1099
|
+
import { fileURLToPath } from "node:url";
|
|
1100
|
+
var CLAUDE_JSON = join2(homedir2(), ".claude.json");
|
|
1101
|
+
var CLAUDE_SETTINGS = join2(homedir2(), ".claude", "settings.json");
|
|
1102
|
+
function isBuiltDist() {
|
|
1103
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
1104
|
+
return thisDir.endsWith("/dist") || thisDir.endsWith("\\dist");
|
|
1105
|
+
}
|
|
1106
|
+
function findRuntime() {
|
|
1107
|
+
if (isBuiltDist()) {
|
|
1108
|
+
return process.execPath;
|
|
1109
|
+
}
|
|
1110
|
+
const candidates = [
|
|
1111
|
+
join2(homedir2(), ".bun", "bin", "bun"),
|
|
1112
|
+
"/usr/local/bin/bun",
|
|
1113
|
+
"/opt/homebrew/bin/bun"
|
|
1114
|
+
];
|
|
1115
|
+
for (const p of candidates) {
|
|
1116
|
+
if (existsSync2(p))
|
|
1117
|
+
return p;
|
|
1118
|
+
}
|
|
1119
|
+
if (process.execPath && process.execPath.endsWith("bun")) {
|
|
1120
|
+
return process.execPath;
|
|
1121
|
+
}
|
|
1122
|
+
return "bun";
|
|
1123
|
+
}
|
|
1124
|
+
function findPackageRoot() {
|
|
1125
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
1126
|
+
return join2(thisDir, "..");
|
|
1127
|
+
}
|
|
1128
|
+
function readJsonFile(path) {
|
|
1129
|
+
if (!existsSync2(path))
|
|
1130
|
+
return {};
|
|
1131
|
+
try {
|
|
1132
|
+
const raw = readFileSync2(path, "utf-8");
|
|
1133
|
+
const parsed = JSON.parse(raw);
|
|
1134
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1135
|
+
return parsed;
|
|
1136
|
+
}
|
|
1137
|
+
} catch {}
|
|
1138
|
+
return {};
|
|
1139
|
+
}
|
|
1140
|
+
function writeJsonFile(path, data) {
|
|
1141
|
+
const dir = dirname(path);
|
|
1142
|
+
if (!existsSync2(dir)) {
|
|
1143
|
+
mkdirSync2(dir, { recursive: true });
|
|
1144
|
+
}
|
|
1145
|
+
writeFileSync2(path, JSON.stringify(data, null, 2) + `
|
|
1146
|
+
`, "utf-8");
|
|
1147
|
+
}
|
|
1148
|
+
function registerMcpServer() {
|
|
1149
|
+
const runtime = findRuntime();
|
|
1150
|
+
const root = findPackageRoot();
|
|
1151
|
+
const dist = isBuiltDist();
|
|
1152
|
+
const serverPath = dist ? join2(root, "dist", "server.js") : join2(root, "src", "server.ts");
|
|
1153
|
+
const config = readJsonFile(CLAUDE_JSON);
|
|
1154
|
+
const servers = config["mcpServers"] ?? {};
|
|
1155
|
+
servers["engrm"] = {
|
|
1156
|
+
type: "stdio",
|
|
1157
|
+
command: runtime,
|
|
1158
|
+
args: dist ? [serverPath] : ["run", serverPath]
|
|
1159
|
+
};
|
|
1160
|
+
config["mcpServers"] = servers;
|
|
1161
|
+
writeJsonFile(CLAUDE_JSON, config);
|
|
1162
|
+
return { path: CLAUDE_JSON, added: true };
|
|
1163
|
+
}
|
|
1164
|
+
function registerHooks() {
|
|
1165
|
+
const runtime = findRuntime();
|
|
1166
|
+
const root = findPackageRoot();
|
|
1167
|
+
const dist = isBuiltDist();
|
|
1168
|
+
const hooksDir = dist ? join2(root, "dist", "hooks") : join2(root, "hooks");
|
|
1169
|
+
const ext = dist ? ".js" : ".ts";
|
|
1170
|
+
const runArg = dist ? [] : ["run"];
|
|
1171
|
+
function hookCmd(name) {
|
|
1172
|
+
return [runtime, ...runArg, join2(hooksDir, `${name}${ext}`)].join(" ");
|
|
1173
|
+
}
|
|
1174
|
+
const sessionStartCmd = hookCmd("session-start");
|
|
1175
|
+
const preCompactCmd = hookCmd("pre-compact");
|
|
1176
|
+
const preToolUseCmd = hookCmd("sentinel");
|
|
1177
|
+
const postToolUseCmd = hookCmd("post-tool-use");
|
|
1178
|
+
const elicitationResultCmd = hookCmd("elicitation-result");
|
|
1179
|
+
const stopCmd = hookCmd("stop");
|
|
1180
|
+
const settings = readJsonFile(CLAUDE_SETTINGS);
|
|
1181
|
+
const hooks = settings["hooks"] ?? {};
|
|
1182
|
+
hooks["SessionStart"] = replaceEngrmHook(hooks["SessionStart"], { hooks: [{ type: "command", command: sessionStartCmd }] }, "session-start");
|
|
1183
|
+
hooks["PreCompact"] = replaceEngrmHook(hooks["PreCompact"], { hooks: [{ type: "command", command: preCompactCmd }] }, "pre-compact");
|
|
1184
|
+
hooks["PreToolUse"] = replaceEngrmHook(hooks["PreToolUse"], {
|
|
1185
|
+
matcher: "Edit|Write",
|
|
1186
|
+
hooks: [{ type: "command", command: preToolUseCmd }]
|
|
1187
|
+
}, "sentinel");
|
|
1188
|
+
hooks["PostToolUse"] = replaceEngrmHook(hooks["PostToolUse"], {
|
|
1189
|
+
matcher: "Edit|Write|Bash|Read|mcp__.*",
|
|
1190
|
+
hooks: [{ type: "command", command: postToolUseCmd }]
|
|
1191
|
+
}, "post-tool-use");
|
|
1192
|
+
hooks["ElicitationResult"] = replaceEngrmHook(hooks["ElicitationResult"], {
|
|
1193
|
+
hooks: [{ type: "command", command: elicitationResultCmd }]
|
|
1194
|
+
}, "elicitation-result");
|
|
1195
|
+
hooks["Stop"] = replaceEngrmHook(hooks["Stop"], { hooks: [{ type: "command", command: stopCmd }] }, "stop");
|
|
1196
|
+
settings["hooks"] = hooks;
|
|
1197
|
+
writeJsonFile(CLAUDE_SETTINGS, settings);
|
|
1198
|
+
return { path: CLAUDE_SETTINGS, added: true };
|
|
1199
|
+
}
|
|
1200
|
+
function replaceEngrmHook(existing, newEntry, hookFilename) {
|
|
1201
|
+
if (!existing || !Array.isArray(existing))
|
|
1202
|
+
return [newEntry];
|
|
1203
|
+
const isEngrmHook = (entry) => entry.hooks?.some((h) => h.command?.includes("engrm") || h.command?.includes(hookFilename)) ?? false;
|
|
1204
|
+
const others = existing.filter((e) => !isEngrmHook(e));
|
|
1205
|
+
return [...others, newEntry];
|
|
1206
|
+
}
|
|
1207
|
+
function registerAll() {
|
|
1208
|
+
return {
|
|
1209
|
+
mcp: registerMcpServer(),
|
|
1210
|
+
hooks: registerHooks()
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// src/packs/loader.ts
|
|
1215
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync } from "node:fs";
|
|
1216
|
+
import { join as join4, dirname as dirname2 } from "node:path";
|
|
1217
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1218
|
+
|
|
1219
|
+
// src/tools/save.ts
|
|
1220
|
+
import { relative, isAbsolute } from "node:path";
|
|
1221
|
+
|
|
1222
|
+
// src/capture/scrubber.ts
|
|
1223
|
+
var DEFAULT_PATTERNS = [
|
|
1224
|
+
{
|
|
1225
|
+
source: "sk-[a-zA-Z0-9]{20,}",
|
|
1226
|
+
flags: "g",
|
|
1227
|
+
replacement: "[REDACTED_API_KEY]",
|
|
1228
|
+
description: "OpenAI API keys",
|
|
1229
|
+
category: "api_key",
|
|
1230
|
+
severity: "critical"
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
source: "Bearer [a-zA-Z0-9\\-._~+/]+=*",
|
|
1234
|
+
flags: "g",
|
|
1235
|
+
replacement: "[REDACTED_BEARER]",
|
|
1236
|
+
description: "Bearer auth tokens",
|
|
1237
|
+
category: "token",
|
|
1238
|
+
severity: "medium"
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
source: "password[=:]\\s*\\S+",
|
|
1242
|
+
flags: "gi",
|
|
1243
|
+
replacement: "password=[REDACTED]",
|
|
1244
|
+
description: "Passwords in config",
|
|
1245
|
+
category: "password",
|
|
1246
|
+
severity: "high"
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
source: "postgresql://[^\\s]+",
|
|
1250
|
+
flags: "g",
|
|
1251
|
+
replacement: "[REDACTED_DB_URL]",
|
|
1252
|
+
description: "PostgreSQL connection strings",
|
|
1253
|
+
category: "db_url",
|
|
1254
|
+
severity: "high"
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
source: "mongodb://[^\\s]+",
|
|
1258
|
+
flags: "g",
|
|
1259
|
+
replacement: "[REDACTED_DB_URL]",
|
|
1260
|
+
description: "MongoDB connection strings",
|
|
1261
|
+
category: "db_url",
|
|
1262
|
+
severity: "high"
|
|
1263
|
+
},
|
|
1264
|
+
{
|
|
1265
|
+
source: "mysql://[^\\s]+",
|
|
1266
|
+
flags: "g",
|
|
1267
|
+
replacement: "[REDACTED_DB_URL]",
|
|
1268
|
+
description: "MySQL connection strings",
|
|
1269
|
+
category: "db_url",
|
|
1270
|
+
severity: "high"
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
source: "AKIA[A-Z0-9]{16}",
|
|
1274
|
+
flags: "g",
|
|
1275
|
+
replacement: "[REDACTED_AWS_KEY]",
|
|
1276
|
+
description: "AWS access keys",
|
|
1277
|
+
category: "api_key",
|
|
1278
|
+
severity: "critical"
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
source: "ghp_[a-zA-Z0-9]{36}",
|
|
1282
|
+
flags: "g",
|
|
1283
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
1284
|
+
description: "GitHub personal access tokens",
|
|
1285
|
+
category: "token",
|
|
1286
|
+
severity: "high"
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
source: "gho_[a-zA-Z0-9]{36}",
|
|
1290
|
+
flags: "g",
|
|
1291
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
1292
|
+
description: "GitHub OAuth tokens",
|
|
1293
|
+
category: "token",
|
|
1294
|
+
severity: "high"
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
source: "github_pat_[a-zA-Z0-9_]{22,}",
|
|
1298
|
+
flags: "g",
|
|
1299
|
+
replacement: "[REDACTED_GH_TOKEN]",
|
|
1300
|
+
description: "GitHub fine-grained PATs",
|
|
1301
|
+
category: "token",
|
|
1302
|
+
severity: "high"
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
source: "cvk_[a-f0-9]{64}",
|
|
1306
|
+
flags: "g",
|
|
1307
|
+
replacement: "[REDACTED_CANDENGO_KEY]",
|
|
1308
|
+
description: "Candengo API keys",
|
|
1309
|
+
category: "api_key",
|
|
1310
|
+
severity: "critical"
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
source: "xox[bpras]-[a-zA-Z0-9\\-]+",
|
|
1314
|
+
flags: "g",
|
|
1315
|
+
replacement: "[REDACTED_SLACK_TOKEN]",
|
|
1316
|
+
description: "Slack tokens",
|
|
1317
|
+
category: "token",
|
|
1318
|
+
severity: "high"
|
|
1319
|
+
}
|
|
1320
|
+
];
|
|
1321
|
+
function compileCustomPatterns(patterns) {
|
|
1322
|
+
const compiled = [];
|
|
1323
|
+
for (const pattern of patterns) {
|
|
1324
|
+
try {
|
|
1325
|
+
new RegExp(pattern);
|
|
1326
|
+
compiled.push({
|
|
1327
|
+
source: pattern,
|
|
1328
|
+
flags: "g",
|
|
1329
|
+
replacement: "[REDACTED_CUSTOM]",
|
|
1330
|
+
description: `Custom pattern: ${pattern}`,
|
|
1331
|
+
category: "custom",
|
|
1332
|
+
severity: "medium"
|
|
1333
|
+
});
|
|
1334
|
+
} catch {}
|
|
1335
|
+
}
|
|
1336
|
+
return compiled;
|
|
1337
|
+
}
|
|
1338
|
+
function scrubSecrets(text, customPatterns = []) {
|
|
1339
|
+
let result = text;
|
|
1340
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
1341
|
+
for (const pattern of allPatterns) {
|
|
1342
|
+
result = result.replace(new RegExp(pattern.source, pattern.flags), pattern.replacement);
|
|
1343
|
+
}
|
|
1344
|
+
return result;
|
|
1345
|
+
}
|
|
1346
|
+
function containsSecrets(text, customPatterns = []) {
|
|
1347
|
+
const allPatterns = [...DEFAULT_PATTERNS, ...compileCustomPatterns(customPatterns)];
|
|
1348
|
+
for (const pattern of allPatterns) {
|
|
1349
|
+
if (new RegExp(pattern.source, pattern.flags).test(text))
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
return false;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/capture/quality.ts
|
|
1356
|
+
var QUALITY_THRESHOLD = 0.1;
|
|
1357
|
+
function scoreQuality(input) {
|
|
1358
|
+
let score = 0;
|
|
1359
|
+
switch (input.type) {
|
|
1360
|
+
case "bugfix":
|
|
1361
|
+
score += 0.3;
|
|
1362
|
+
break;
|
|
1363
|
+
case "decision":
|
|
1364
|
+
score += 0.3;
|
|
1365
|
+
break;
|
|
1366
|
+
case "discovery":
|
|
1367
|
+
score += 0.2;
|
|
1368
|
+
break;
|
|
1369
|
+
case "pattern":
|
|
1370
|
+
score += 0.2;
|
|
1371
|
+
break;
|
|
1372
|
+
case "feature":
|
|
1373
|
+
score += 0.15;
|
|
1374
|
+
break;
|
|
1375
|
+
case "refactor":
|
|
1376
|
+
score += 0.15;
|
|
1377
|
+
break;
|
|
1378
|
+
case "change":
|
|
1379
|
+
score += 0.05;
|
|
1380
|
+
break;
|
|
1381
|
+
case "digest":
|
|
1382
|
+
score += 0.3;
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
if (input.narrative && input.narrative.length > 50) {
|
|
1386
|
+
score += 0.15;
|
|
1387
|
+
}
|
|
1388
|
+
if (input.facts) {
|
|
1389
|
+
try {
|
|
1390
|
+
const factsArray = JSON.parse(input.facts);
|
|
1391
|
+
if (factsArray.length >= 2)
|
|
1392
|
+
score += 0.15;
|
|
1393
|
+
else if (factsArray.length === 1)
|
|
1394
|
+
score += 0.05;
|
|
1395
|
+
} catch {
|
|
1396
|
+
if (input.facts.length > 20)
|
|
1397
|
+
score += 0.05;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (input.concepts) {
|
|
1401
|
+
try {
|
|
1402
|
+
const conceptsArray = JSON.parse(input.concepts);
|
|
1403
|
+
if (conceptsArray.length >= 1)
|
|
1404
|
+
score += 0.1;
|
|
1405
|
+
} catch {
|
|
1406
|
+
if (input.concepts.length > 10)
|
|
1407
|
+
score += 0.05;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const modifiedCount = input.filesModified?.length ?? 0;
|
|
1411
|
+
if (modifiedCount >= 3)
|
|
1412
|
+
score += 0.2;
|
|
1413
|
+
else if (modifiedCount >= 1)
|
|
1414
|
+
score += 0.1;
|
|
1415
|
+
if (input.isDuplicate) {
|
|
1416
|
+
score -= 0.3;
|
|
1417
|
+
}
|
|
1418
|
+
return Math.max(0, Math.min(1, score));
|
|
1419
|
+
}
|
|
1420
|
+
function meetsQualityThreshold(input) {
|
|
1421
|
+
return scoreQuality(input) >= QUALITY_THRESHOLD;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// src/capture/dedup.ts
|
|
1425
|
+
function tokenise(text) {
|
|
1426
|
+
const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
|
|
1427
|
+
const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
|
|
1428
|
+
return new Set(tokens);
|
|
1429
|
+
}
|
|
1430
|
+
function jaccardSimilarity(a, b) {
|
|
1431
|
+
const tokensA = tokenise(a);
|
|
1432
|
+
const tokensB = tokenise(b);
|
|
1433
|
+
if (tokensA.size === 0 && tokensB.size === 0)
|
|
1434
|
+
return 1;
|
|
1435
|
+
if (tokensA.size === 0 || tokensB.size === 0)
|
|
1436
|
+
return 0;
|
|
1437
|
+
let intersectionSize = 0;
|
|
1438
|
+
for (const token of tokensA) {
|
|
1439
|
+
if (tokensB.has(token))
|
|
1440
|
+
intersectionSize++;
|
|
1441
|
+
}
|
|
1442
|
+
const unionSize = tokensA.size + tokensB.size - intersectionSize;
|
|
1443
|
+
if (unionSize === 0)
|
|
1444
|
+
return 0;
|
|
1445
|
+
return intersectionSize / unionSize;
|
|
1446
|
+
}
|
|
1447
|
+
var DEDUP_THRESHOLD = 0.8;
|
|
1448
|
+
function findDuplicate(newTitle, candidates) {
|
|
1449
|
+
let bestMatch = null;
|
|
1450
|
+
let bestScore = 0;
|
|
1451
|
+
for (const candidate of candidates) {
|
|
1452
|
+
const similarity = jaccardSimilarity(newTitle, candidate.title);
|
|
1453
|
+
if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
|
|
1454
|
+
bestScore = similarity;
|
|
1455
|
+
bestMatch = candidate;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return bestMatch;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// src/storage/projects.ts
|
|
1462
|
+
import { execSync } from "node:child_process";
|
|
1463
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
1464
|
+
import { basename, join as join3 } from "node:path";
|
|
1465
|
+
function normaliseGitRemoteUrl(remoteUrl) {
|
|
1466
|
+
let url = remoteUrl.trim();
|
|
1467
|
+
url = url.replace(/^(?:https?|ssh|git):\/\//, "");
|
|
1468
|
+
url = url.replace(/^[^@]+@/, "");
|
|
1469
|
+
url = url.replace(/^([^/:]+):(?!\d)/, "$1/");
|
|
1470
|
+
url = url.replace(/\.git$/, "");
|
|
1471
|
+
url = url.replace(/\/+$/, "");
|
|
1472
|
+
const slashIndex = url.indexOf("/");
|
|
1473
|
+
if (slashIndex !== -1) {
|
|
1474
|
+
const host = url.substring(0, slashIndex).toLowerCase();
|
|
1475
|
+
const path = url.substring(slashIndex);
|
|
1476
|
+
url = host + path;
|
|
1477
|
+
} else {
|
|
1478
|
+
url = url.toLowerCase();
|
|
1479
|
+
}
|
|
1480
|
+
return url;
|
|
1481
|
+
}
|
|
1482
|
+
function projectNameFromCanonicalId(canonicalId) {
|
|
1483
|
+
const parts = canonicalId.split("/");
|
|
1484
|
+
return parts[parts.length - 1] ?? canonicalId;
|
|
1485
|
+
}
|
|
1486
|
+
function getGitRemoteUrl(directory) {
|
|
1487
|
+
try {
|
|
1488
|
+
const url = execSync("git remote get-url origin", {
|
|
1489
|
+
cwd: directory,
|
|
1490
|
+
encoding: "utf-8",
|
|
1491
|
+
timeout: 5000,
|
|
1492
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1493
|
+
}).trim();
|
|
1494
|
+
return url || null;
|
|
1495
|
+
} catch {
|
|
1496
|
+
try {
|
|
1497
|
+
const remotes = execSync("git remote", {
|
|
1498
|
+
cwd: directory,
|
|
1499
|
+
encoding: "utf-8",
|
|
1500
|
+
timeout: 5000,
|
|
1501
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1502
|
+
}).trim().split(`
|
|
1503
|
+
`).filter(Boolean);
|
|
1504
|
+
if (remotes.length === 0)
|
|
1505
|
+
return null;
|
|
1506
|
+
const url = execSync(`git remote get-url ${remotes[0]}`, {
|
|
1507
|
+
cwd: directory,
|
|
1508
|
+
encoding: "utf-8",
|
|
1509
|
+
timeout: 5000,
|
|
1510
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1511
|
+
}).trim();
|
|
1512
|
+
return url || null;
|
|
1513
|
+
} catch {
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function readProjectConfigFile(directory) {
|
|
1519
|
+
const configPath = join3(directory, ".engrm.json");
|
|
1520
|
+
if (!existsSync3(configPath))
|
|
1521
|
+
return null;
|
|
1522
|
+
try {
|
|
1523
|
+
const raw = readFileSync3(configPath, "utf-8");
|
|
1524
|
+
const parsed = JSON.parse(raw);
|
|
1525
|
+
if (typeof parsed["project_id"] !== "string" || !parsed["project_id"]) {
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
project_id: parsed["project_id"],
|
|
1530
|
+
name: typeof parsed["name"] === "string" ? parsed["name"] : undefined
|
|
1531
|
+
};
|
|
1532
|
+
} catch {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
function detectProject(directory) {
|
|
1537
|
+
const remoteUrl = getGitRemoteUrl(directory);
|
|
1538
|
+
if (remoteUrl) {
|
|
1539
|
+
const canonicalId = normaliseGitRemoteUrl(remoteUrl);
|
|
1540
|
+
return {
|
|
1541
|
+
canonical_id: canonicalId,
|
|
1542
|
+
name: projectNameFromCanonicalId(canonicalId),
|
|
1543
|
+
remote_url: remoteUrl,
|
|
1544
|
+
local_path: directory
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
const configFile = readProjectConfigFile(directory);
|
|
1548
|
+
if (configFile) {
|
|
1549
|
+
return {
|
|
1550
|
+
canonical_id: configFile.project_id,
|
|
1551
|
+
name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
|
|
1552
|
+
remote_url: null,
|
|
1553
|
+
local_path: directory
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
const dirName = basename(directory);
|
|
1557
|
+
return {
|
|
1558
|
+
canonical_id: `local/${dirName}`,
|
|
1559
|
+
name: dirName,
|
|
1560
|
+
remote_url: null,
|
|
1561
|
+
local_path: directory
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/embeddings/embedder.ts
|
|
1566
|
+
var _available = null;
|
|
1567
|
+
var _pipeline = null;
|
|
1568
|
+
var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
1569
|
+
async function isEmbeddingAvailable() {
|
|
1570
|
+
if (_available !== null)
|
|
1571
|
+
return _available;
|
|
1572
|
+
try {
|
|
1573
|
+
await getPipeline();
|
|
1574
|
+
return _available;
|
|
1575
|
+
} catch {
|
|
1576
|
+
_available = false;
|
|
1577
|
+
return false;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
async function embedText(text) {
|
|
1581
|
+
const pipe = await getPipeline();
|
|
1582
|
+
if (!pipe)
|
|
1583
|
+
return null;
|
|
1584
|
+
try {
|
|
1585
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
1586
|
+
return new Float32Array(output.data);
|
|
1587
|
+
} catch {
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function composeEmbeddingText(obs) {
|
|
1592
|
+
const parts = [obs.title];
|
|
1593
|
+
if (obs.narrative)
|
|
1594
|
+
parts.push(obs.narrative);
|
|
1595
|
+
if (obs.facts) {
|
|
1596
|
+
try {
|
|
1597
|
+
const facts = JSON.parse(obs.facts);
|
|
1598
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
1599
|
+
parts.push(facts.map((f) => `- ${f}`).join(`
|
|
1600
|
+
`));
|
|
1601
|
+
}
|
|
1602
|
+
} catch {
|
|
1603
|
+
parts.push(obs.facts);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (obs.concepts) {
|
|
1607
|
+
try {
|
|
1608
|
+
const concepts = JSON.parse(obs.concepts);
|
|
1609
|
+
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
1610
|
+
parts.push(concepts.join(", "));
|
|
1611
|
+
}
|
|
1612
|
+
} catch {}
|
|
1613
|
+
}
|
|
1614
|
+
return parts.join(`
|
|
1615
|
+
|
|
1616
|
+
`);
|
|
1617
|
+
}
|
|
1618
|
+
async function getPipeline() {
|
|
1619
|
+
if (_pipeline)
|
|
1620
|
+
return _pipeline;
|
|
1621
|
+
if (_available === false)
|
|
1622
|
+
return null;
|
|
1623
|
+
try {
|
|
1624
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
1625
|
+
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
1626
|
+
_available = true;
|
|
1627
|
+
return _pipeline;
|
|
1628
|
+
} catch (err) {
|
|
1629
|
+
_available = false;
|
|
1630
|
+
console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// src/capture/recurrence.ts
|
|
1636
|
+
var DISTANCE_THRESHOLD = 0.15;
|
|
1637
|
+
async function detectRecurrence(db, config, observation) {
|
|
1638
|
+
if (observation.type !== "bugfix") {
|
|
1639
|
+
return { patternCreated: false };
|
|
1640
|
+
}
|
|
1641
|
+
if (!db.vecAvailable) {
|
|
1642
|
+
return { patternCreated: false };
|
|
1643
|
+
}
|
|
1644
|
+
const text = composeEmbeddingText(observation);
|
|
1645
|
+
const embedding = await embedText(text);
|
|
1646
|
+
if (!embedding) {
|
|
1647
|
+
return { patternCreated: false };
|
|
1648
|
+
}
|
|
1649
|
+
const vecResults = db.searchVec(embedding, null, ["active", "aging", "pinned"], 10);
|
|
1650
|
+
for (const match of vecResults) {
|
|
1651
|
+
if (match.observation_id === observation.id)
|
|
1652
|
+
continue;
|
|
1653
|
+
if (match.distance > DISTANCE_THRESHOLD)
|
|
1654
|
+
continue;
|
|
1655
|
+
const matched = db.getObservationById(match.observation_id);
|
|
1656
|
+
if (!matched)
|
|
1657
|
+
continue;
|
|
1658
|
+
if (matched.type !== "bugfix")
|
|
1659
|
+
continue;
|
|
1660
|
+
if (matched.session_id === observation.session_id)
|
|
1661
|
+
continue;
|
|
1662
|
+
if (await patternAlreadyExists(db, observation, matched))
|
|
1663
|
+
continue;
|
|
1664
|
+
let matchedProjectName;
|
|
1665
|
+
if (matched.project_id !== observation.project_id) {
|
|
1666
|
+
const proj = db.getProjectById(matched.project_id);
|
|
1667
|
+
if (proj)
|
|
1668
|
+
matchedProjectName = proj.name;
|
|
1669
|
+
}
|
|
1670
|
+
const similarity = 1 - match.distance;
|
|
1671
|
+
const result = await saveObservation(db, config, {
|
|
1672
|
+
type: "pattern",
|
|
1673
|
+
title: `Recurring bugfix: ${observation.title}`,
|
|
1674
|
+
narrative: `This bug pattern has appeared in multiple sessions. Original: "${matched.title}" (session ${matched.session_id?.slice(0, 8) ?? "unknown"}). Latest: "${observation.title}". Similarity: ${(similarity * 100).toFixed(0)}%. Consider addressing the root cause.`,
|
|
1675
|
+
facts: [
|
|
1676
|
+
`First seen: ${matched.created_at.split("T")[0]}`,
|
|
1677
|
+
`Recurred: ${observation.created_at.split("T")[0]}`,
|
|
1678
|
+
`Similarity: ${(similarity * 100).toFixed(0)}%`
|
|
1679
|
+
],
|
|
1680
|
+
concepts: mergeConceptsFromBoth(observation, matched),
|
|
1681
|
+
cwd: process.cwd(),
|
|
1682
|
+
session_id: observation.session_id ?? undefined
|
|
1683
|
+
});
|
|
1684
|
+
if (result.success && result.observation_id) {
|
|
1685
|
+
return {
|
|
1686
|
+
patternCreated: true,
|
|
1687
|
+
patternId: result.observation_id,
|
|
1688
|
+
matchedObservationId: matched.id,
|
|
1689
|
+
matchedProjectName,
|
|
1690
|
+
matchedTitle: matched.title,
|
|
1691
|
+
similarity
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return { patternCreated: false };
|
|
1696
|
+
}
|
|
1697
|
+
async function patternAlreadyExists(db, obs1, obs2) {
|
|
1698
|
+
const recentPatterns = db.db.query(`SELECT * FROM observations
|
|
1699
|
+
WHERE type = 'pattern' AND lifecycle IN ('active', 'aging', 'pinned')
|
|
1700
|
+
AND title LIKE ?
|
|
1701
|
+
ORDER BY created_at_epoch DESC LIMIT 5`).all(`%${obs1.title.slice(0, 30)}%`);
|
|
1702
|
+
for (const p of recentPatterns) {
|
|
1703
|
+
if (p.narrative?.includes(obs2.title.slice(0, 30)))
|
|
1704
|
+
return true;
|
|
1705
|
+
}
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
function mergeConceptsFromBoth(obs1, obs2) {
|
|
1709
|
+
const concepts = new Set;
|
|
1710
|
+
for (const obs of [obs1, obs2]) {
|
|
1711
|
+
if (obs.concepts) {
|
|
1712
|
+
try {
|
|
1713
|
+
const parsed = JSON.parse(obs.concepts);
|
|
1714
|
+
if (Array.isArray(parsed)) {
|
|
1715
|
+
for (const c of parsed) {
|
|
1716
|
+
if (typeof c === "string")
|
|
1717
|
+
concepts.add(c);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
} catch {}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return [...concepts];
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/capture/conflict.ts
|
|
1727
|
+
var SIMILARITY_THRESHOLD = 0.25;
|
|
1728
|
+
async function detectDecisionConflict(db, observation) {
|
|
1729
|
+
if (observation.type !== "decision") {
|
|
1730
|
+
return { hasConflict: false };
|
|
1731
|
+
}
|
|
1732
|
+
if (!observation.narrative || observation.narrative.trim().length < 20) {
|
|
1733
|
+
return { hasConflict: false };
|
|
1734
|
+
}
|
|
1735
|
+
if (db.vecAvailable) {
|
|
1736
|
+
return detectViaVec(db, observation);
|
|
1737
|
+
}
|
|
1738
|
+
return detectViaFts(db, observation);
|
|
1739
|
+
}
|
|
1740
|
+
async function detectViaVec(db, observation) {
|
|
1741
|
+
const text = composeEmbeddingText(observation);
|
|
1742
|
+
const embedding = await embedText(text);
|
|
1743
|
+
if (!embedding)
|
|
1744
|
+
return { hasConflict: false };
|
|
1745
|
+
const results = db.searchVec(embedding, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
1746
|
+
for (const match of results) {
|
|
1747
|
+
if (match.observation_id === observation.id)
|
|
1748
|
+
continue;
|
|
1749
|
+
if (match.distance > SIMILARITY_THRESHOLD)
|
|
1750
|
+
continue;
|
|
1751
|
+
const existing = db.getObservationById(match.observation_id);
|
|
1752
|
+
if (!existing)
|
|
1753
|
+
continue;
|
|
1754
|
+
if (existing.type !== "decision")
|
|
1755
|
+
continue;
|
|
1756
|
+
if (!existing.narrative)
|
|
1757
|
+
continue;
|
|
1758
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
1759
|
+
if (conflict) {
|
|
1760
|
+
return {
|
|
1761
|
+
hasConflict: true,
|
|
1762
|
+
conflictingId: existing.id,
|
|
1763
|
+
conflictingTitle: existing.title,
|
|
1764
|
+
reason: conflict
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return { hasConflict: false };
|
|
1769
|
+
}
|
|
1770
|
+
async function detectViaFts(db, observation) {
|
|
1771
|
+
const keywords = observation.title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5).join(" ");
|
|
1772
|
+
if (!keywords)
|
|
1773
|
+
return { hasConflict: false };
|
|
1774
|
+
const ftsResults = db.searchFts(keywords, observation.project_id, ["active", "aging", "pinned"], 10);
|
|
1775
|
+
for (const match of ftsResults) {
|
|
1776
|
+
if (match.id === observation.id)
|
|
1777
|
+
continue;
|
|
1778
|
+
const existing = db.getObservationById(match.id);
|
|
1779
|
+
if (!existing)
|
|
1780
|
+
continue;
|
|
1781
|
+
if (existing.type !== "decision")
|
|
1782
|
+
continue;
|
|
1783
|
+
if (!existing.narrative)
|
|
1784
|
+
continue;
|
|
1785
|
+
const conflict = narrativesConflict(observation.narrative, existing.narrative);
|
|
1786
|
+
if (conflict) {
|
|
1787
|
+
return {
|
|
1788
|
+
hasConflict: true,
|
|
1789
|
+
conflictingId: existing.id,
|
|
1790
|
+
conflictingTitle: existing.title,
|
|
1791
|
+
reason: conflict
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return { hasConflict: false };
|
|
1796
|
+
}
|
|
1797
|
+
function narrativesConflict(narrative1, narrative2) {
|
|
1798
|
+
const n1 = narrative1.toLowerCase();
|
|
1799
|
+
const n2 = narrative2.toLowerCase();
|
|
1800
|
+
const opposingPairs = [
|
|
1801
|
+
[["should use", "decided to use", "chose", "prefer", "went with"], ["should not", "decided against", "avoid", "rejected", "don't use"]],
|
|
1802
|
+
[["enable", "turn on", "activate", "add"], ["disable", "turn off", "deactivate", "remove"]],
|
|
1803
|
+
[["increase", "more", "higher", "scale up"], ["decrease", "less", "lower", "scale down"]],
|
|
1804
|
+
[["keep", "maintain", "preserve"], ["replace", "migrate", "switch from", "deprecate"]]
|
|
1805
|
+
];
|
|
1806
|
+
for (const [positive, negative] of opposingPairs) {
|
|
1807
|
+
const n1HasPositive = positive.some((w) => n1.includes(w));
|
|
1808
|
+
const n1HasNegative = negative.some((w) => n1.includes(w));
|
|
1809
|
+
const n2HasPositive = positive.some((w) => n2.includes(w));
|
|
1810
|
+
const n2HasNegative = negative.some((w) => n2.includes(w));
|
|
1811
|
+
if (n1HasPositive && n2HasNegative || n1HasNegative && n2HasPositive) {
|
|
1812
|
+
return "Narratives suggest opposing conclusions on a similar topic";
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
return null;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/tools/save.ts
|
|
1819
|
+
var VALID_TYPES = [
|
|
1820
|
+
"bugfix",
|
|
1821
|
+
"discovery",
|
|
1822
|
+
"decision",
|
|
1823
|
+
"pattern",
|
|
1824
|
+
"change",
|
|
1825
|
+
"feature",
|
|
1826
|
+
"refactor",
|
|
1827
|
+
"digest",
|
|
1828
|
+
"standard"
|
|
1829
|
+
];
|
|
1830
|
+
async function saveObservation(db, config, input) {
|
|
1831
|
+
if (!VALID_TYPES.includes(input.type)) {
|
|
1832
|
+
return {
|
|
1833
|
+
success: false,
|
|
1834
|
+
reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
if (!input.title || input.title.trim().length === 0) {
|
|
1838
|
+
return { success: false, reason: "Title is required" };
|
|
1839
|
+
}
|
|
1840
|
+
const cwd = input.cwd ?? process.cwd();
|
|
1841
|
+
const detected = detectProject(cwd);
|
|
1842
|
+
const project = db.upsertProject({
|
|
1843
|
+
canonical_id: detected.canonical_id,
|
|
1844
|
+
name: detected.name,
|
|
1845
|
+
local_path: detected.local_path,
|
|
1846
|
+
remote_url: detected.remote_url
|
|
1847
|
+
});
|
|
1848
|
+
const customPatterns = config.scrubbing.enabled ? config.scrubbing.custom_patterns : [];
|
|
1849
|
+
const title = config.scrubbing.enabled ? scrubSecrets(input.title, customPatterns) : input.title;
|
|
1850
|
+
const narrative = input.narrative ? config.scrubbing.enabled ? scrubSecrets(input.narrative, customPatterns) : input.narrative : null;
|
|
1851
|
+
const factsJson = input.facts ? config.scrubbing.enabled ? scrubSecrets(JSON.stringify(input.facts), customPatterns) : JSON.stringify(input.facts) : null;
|
|
1852
|
+
const conceptsJson = input.concepts ? JSON.stringify(input.concepts) : null;
|
|
1853
|
+
const filesRead = input.files_read ? input.files_read.map((f) => toRelativePath(f, cwd)) : null;
|
|
1854
|
+
const filesModified = input.files_modified ? input.files_modified.map((f) => toRelativePath(f, cwd)) : null;
|
|
1855
|
+
const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
|
|
1856
|
+
const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
|
|
1857
|
+
let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
|
|
1858
|
+
if (config.scrubbing.enabled && containsSecrets([input.title, input.narrative, JSON.stringify(input.facts)].filter(Boolean).join(" "), customPatterns)) {
|
|
1859
|
+
if (sensitivity === "shared") {
|
|
1860
|
+
sensitivity = "personal";
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
1864
|
+
const recentObs = db.getRecentObservations(project.id, oneDayAgo);
|
|
1865
|
+
const candidates = recentObs.map((o) => ({
|
|
1866
|
+
id: o.id,
|
|
1867
|
+
title: o.title
|
|
1868
|
+
}));
|
|
1869
|
+
const duplicate = findDuplicate(title, candidates);
|
|
1870
|
+
const qualityInput = {
|
|
1871
|
+
type: input.type,
|
|
1872
|
+
title,
|
|
1873
|
+
narrative,
|
|
1874
|
+
facts: factsJson,
|
|
1875
|
+
concepts: conceptsJson,
|
|
1876
|
+
filesRead,
|
|
1877
|
+
filesModified,
|
|
1878
|
+
isDuplicate: duplicate !== null
|
|
1879
|
+
};
|
|
1880
|
+
const qualityScore = scoreQuality(qualityInput);
|
|
1881
|
+
if (!meetsQualityThreshold(qualityInput)) {
|
|
1882
|
+
return {
|
|
1883
|
+
success: false,
|
|
1884
|
+
quality_score: qualityScore,
|
|
1885
|
+
reason: `Quality score ${qualityScore.toFixed(2)} below threshold`
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
if (duplicate) {
|
|
1889
|
+
return {
|
|
1890
|
+
success: true,
|
|
1891
|
+
merged_into: duplicate.id,
|
|
1892
|
+
quality_score: qualityScore,
|
|
1893
|
+
reason: `Merged into existing observation #${duplicate.id}`
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
const obs = db.insertObservation({
|
|
1897
|
+
session_id: input.session_id ?? null,
|
|
1898
|
+
project_id: project.id,
|
|
1899
|
+
type: input.type,
|
|
1900
|
+
title,
|
|
1901
|
+
narrative,
|
|
1902
|
+
facts: factsJson,
|
|
1903
|
+
concepts: conceptsJson,
|
|
1904
|
+
files_read: filesReadJson,
|
|
1905
|
+
files_modified: filesModifiedJson,
|
|
1906
|
+
quality: qualityScore,
|
|
1907
|
+
lifecycle: "active",
|
|
1908
|
+
sensitivity,
|
|
1909
|
+
user_id: config.user_id,
|
|
1910
|
+
device_id: config.device_id,
|
|
1911
|
+
agent: input.agent ?? "claude-code"
|
|
1912
|
+
});
|
|
1913
|
+
db.addToOutbox("observation", obs.id);
|
|
1914
|
+
if (db.vecAvailable) {
|
|
1915
|
+
try {
|
|
1916
|
+
const text = composeEmbeddingText(obs);
|
|
1917
|
+
const embedding = await embedText(text);
|
|
1918
|
+
if (embedding) {
|
|
1919
|
+
db.vecInsert(obs.id, embedding);
|
|
1920
|
+
}
|
|
1921
|
+
} catch {}
|
|
1922
|
+
}
|
|
1923
|
+
let recallHint;
|
|
1924
|
+
if (input.type === "bugfix") {
|
|
1925
|
+
try {
|
|
1926
|
+
const recurrence = await detectRecurrence(db, config, obs);
|
|
1927
|
+
if (recurrence.patternCreated && recurrence.matchedTitle) {
|
|
1928
|
+
const projectLabel = recurrence.matchedProjectName ? ` in ${recurrence.matchedProjectName}` : "";
|
|
1929
|
+
recallHint = `You solved a similar issue${projectLabel}: "${recurrence.matchedTitle}"`;
|
|
1930
|
+
}
|
|
1931
|
+
} catch {}
|
|
1932
|
+
}
|
|
1933
|
+
let conflictWarning;
|
|
1934
|
+
if (input.type === "decision") {
|
|
1935
|
+
try {
|
|
1936
|
+
const conflict = await detectDecisionConflict(db, obs);
|
|
1937
|
+
if (conflict.hasConflict && conflict.conflictingTitle) {
|
|
1938
|
+
conflictWarning = `Potential conflict with existing decision: "${conflict.conflictingTitle}" — ${conflict.reason}`;
|
|
1939
|
+
}
|
|
1940
|
+
} catch {}
|
|
1941
|
+
}
|
|
1942
|
+
return {
|
|
1943
|
+
success: true,
|
|
1944
|
+
observation_id: obs.id,
|
|
1945
|
+
quality_score: qualityScore,
|
|
1946
|
+
recall_hint: recallHint,
|
|
1947
|
+
conflict_warning: conflictWarning
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
function toRelativePath(filePath, projectRoot) {
|
|
1951
|
+
if (!isAbsolute(filePath))
|
|
1952
|
+
return filePath;
|
|
1953
|
+
const rel = relative(projectRoot, filePath);
|
|
1954
|
+
if (rel.startsWith(".."))
|
|
1955
|
+
return filePath;
|
|
1956
|
+
return rel;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/packs/loader.ts
|
|
1960
|
+
function getPacksDir() {
|
|
1961
|
+
const thisDir = dirname2(fileURLToPath2(import.meta.url));
|
|
1962
|
+
return join4(thisDir, "..", "..", "packs");
|
|
1963
|
+
}
|
|
1964
|
+
function listPacks() {
|
|
1965
|
+
const dir = getPacksDir();
|
|
1966
|
+
if (!existsSync4(dir))
|
|
1967
|
+
return [];
|
|
1968
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
|
|
1969
|
+
}
|
|
1970
|
+
function loadPack(name) {
|
|
1971
|
+
const filePath = join4(getPacksDir(), `${name}.json`);
|
|
1972
|
+
if (!existsSync4(filePath))
|
|
1973
|
+
return null;
|
|
1974
|
+
try {
|
|
1975
|
+
const raw = readFileSync4(filePath, "utf-8");
|
|
1976
|
+
const parsed = JSON.parse(raw);
|
|
1977
|
+
if (!parsed.name || !Array.isArray(parsed.observations))
|
|
1978
|
+
return null;
|
|
1979
|
+
return parsed;
|
|
1980
|
+
} catch {
|
|
1981
|
+
return null;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
async function installPack(db, config, packName, cwd) {
|
|
1985
|
+
const pack = loadPack(packName);
|
|
1986
|
+
if (!pack) {
|
|
1987
|
+
throw new Error(`Pack '${packName}' not found. Available: ${listPacks().join(", ") || "none"}`);
|
|
1988
|
+
}
|
|
1989
|
+
let installed = 0;
|
|
1990
|
+
let skipped = 0;
|
|
1991
|
+
for (const obs of pack.observations) {
|
|
1992
|
+
const result = await saveObservation(db, config, {
|
|
1993
|
+
type: obs.type,
|
|
1994
|
+
title: obs.title,
|
|
1995
|
+
narrative: obs.narrative,
|
|
1996
|
+
facts: obs.facts,
|
|
1997
|
+
concepts: obs.concepts,
|
|
1998
|
+
cwd
|
|
1999
|
+
});
|
|
2000
|
+
if (result.success && result.observation_id) {
|
|
2001
|
+
installed++;
|
|
2002
|
+
} else {
|
|
2003
|
+
skipped++;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return { installed, skipped, total: pack.observations.length };
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// src/sentinel/rules.ts
|
|
2010
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "node:fs";
|
|
2011
|
+
import { join as join5, dirname as dirname3 } from "node:path";
|
|
2012
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
2013
|
+
function getRulePacksDir() {
|
|
2014
|
+
const thisDir = dirname3(fileURLToPath3(import.meta.url));
|
|
2015
|
+
return join5(thisDir, "rule-packs");
|
|
2016
|
+
}
|
|
2017
|
+
function listRulePacks() {
|
|
2018
|
+
const dir = getRulePacksDir();
|
|
2019
|
+
if (!existsSync5(dir))
|
|
2020
|
+
return [];
|
|
2021
|
+
return readdirSync2(dir).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, ""));
|
|
2022
|
+
}
|
|
2023
|
+
function loadRulePack(name) {
|
|
2024
|
+
const filePath = join5(getRulePacksDir(), `${name}.json`);
|
|
2025
|
+
if (!existsSync5(filePath))
|
|
2026
|
+
return null;
|
|
2027
|
+
try {
|
|
2028
|
+
const raw = readFileSync5(filePath, "utf-8");
|
|
2029
|
+
return JSON.parse(raw);
|
|
2030
|
+
} catch {
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
async function installRulePacks(db, config, packNames) {
|
|
2035
|
+
const names = packNames ?? listRulePacks();
|
|
2036
|
+
let installed = 0;
|
|
2037
|
+
let skipped = 0;
|
|
2038
|
+
for (const name of names) {
|
|
2039
|
+
const pack = loadRulePack(name);
|
|
2040
|
+
if (!pack) {
|
|
2041
|
+
skipped++;
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
for (const obs of pack.observations) {
|
|
2045
|
+
const result = await saveObservation(db, config, {
|
|
2046
|
+
type: obs.type,
|
|
2047
|
+
title: obs.title,
|
|
2048
|
+
narrative: obs.narrative,
|
|
2049
|
+
facts: obs.facts,
|
|
2050
|
+
concepts: obs.concepts ?? [name, "sentinel-standard"],
|
|
2051
|
+
cwd: process.cwd()
|
|
2052
|
+
});
|
|
2053
|
+
if (result.success && result.observation_id) {
|
|
2054
|
+
installed++;
|
|
2055
|
+
} else {
|
|
2056
|
+
skipped++;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
return { installed, skipped };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// src/cli.ts
|
|
2064
|
+
var args = process.argv.slice(2);
|
|
2065
|
+
var command = args[0];
|
|
2066
|
+
switch (command) {
|
|
2067
|
+
case "init":
|
|
2068
|
+
await handleInit(args.slice(1));
|
|
2069
|
+
break;
|
|
2070
|
+
case "status":
|
|
2071
|
+
handleStatus();
|
|
2072
|
+
break;
|
|
2073
|
+
case "install-pack":
|
|
2074
|
+
await handleInstallPack(args.slice(1));
|
|
2075
|
+
break;
|
|
2076
|
+
case "packs":
|
|
2077
|
+
handleListPacks();
|
|
2078
|
+
break;
|
|
2079
|
+
case "sentinel":
|
|
2080
|
+
await handleSentinel(args.slice(1));
|
|
2081
|
+
break;
|
|
2082
|
+
default:
|
|
2083
|
+
printUsage();
|
|
2084
|
+
break;
|
|
2085
|
+
}
|
|
2086
|
+
async function handleInit(flags) {
|
|
2087
|
+
const tokenFlag = flags.find((f) => f.startsWith("--token"));
|
|
2088
|
+
if (tokenFlag) {
|
|
2089
|
+
let token;
|
|
2090
|
+
if (tokenFlag.includes("=")) {
|
|
2091
|
+
token = tokenFlag.split("=")[1];
|
|
2092
|
+
} else {
|
|
2093
|
+
const idx = flags.indexOf("--token");
|
|
2094
|
+
token = flags[idx + 1] ?? "";
|
|
2095
|
+
}
|
|
2096
|
+
if (!token || !token.startsWith("cmt_")) {
|
|
2097
|
+
console.error("Error: --token requires a cmt_ provisioning token");
|
|
2098
|
+
process.exit(1);
|
|
2099
|
+
}
|
|
2100
|
+
const url2 = extractUrlFlag(flags) ?? DEFAULT_CANDENGO_URL;
|
|
2101
|
+
await initWithToken(url2, token);
|
|
2102
|
+
await maybeInstallPack(flags);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
if (flags.includes("--config")) {
|
|
2106
|
+
const configIndex = flags.indexOf("--config");
|
|
2107
|
+
const configPath = flags[configIndex + 1];
|
|
2108
|
+
if (!configPath) {
|
|
2109
|
+
console.error("Error: --config requires a file path");
|
|
2110
|
+
process.exit(1);
|
|
2111
|
+
}
|
|
2112
|
+
initFromFile(configPath);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
if (flags.includes("--manual")) {
|
|
2116
|
+
await initManual();
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
if (flags.includes("--no-browser")) {
|
|
2120
|
+
console.error("Device code flow is not yet implemented.");
|
|
2121
|
+
console.error("Use: engrm init --token=cmt_xxx");
|
|
2122
|
+
process.exit(1);
|
|
2123
|
+
}
|
|
2124
|
+
const url = extractUrlFlag(flags) ?? DEFAULT_CANDENGO_URL;
|
|
2125
|
+
await initWithBrowser(url);
|
|
2126
|
+
await maybeInstallPack(flags);
|
|
2127
|
+
}
|
|
2128
|
+
async function maybeInstallPack(flags) {
|
|
2129
|
+
const packFlag = flags.find((f) => f.startsWith("--pack"));
|
|
2130
|
+
if (!packFlag)
|
|
2131
|
+
return;
|
|
2132
|
+
let packName;
|
|
2133
|
+
if (packFlag.includes("=")) {
|
|
2134
|
+
packName = packFlag.split("=")[1];
|
|
2135
|
+
} else {
|
|
2136
|
+
const idx = flags.indexOf("--pack");
|
|
2137
|
+
packName = flags[idx + 1] ?? "";
|
|
2138
|
+
}
|
|
2139
|
+
if (!packName) {
|
|
2140
|
+
console.error("--pack requires a pack name. Available: " + listPacks().join(", "));
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const config = loadConfig();
|
|
2144
|
+
const db = new MemDatabase(getDbPath());
|
|
2145
|
+
try {
|
|
2146
|
+
console.log(`
|
|
2147
|
+
Installing starter pack: ${packName}...`);
|
|
2148
|
+
const result = await installPack(db, config, packName, process.cwd());
|
|
2149
|
+
console.log(`Loaded ${result.installed} observations from '${packName}' pack`);
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
console.error(`Pack install failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2152
|
+
} finally {
|
|
2153
|
+
db.close();
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
function extractUrlFlag(flags) {
|
|
2157
|
+
const urlFlag = flags.find((f) => f.startsWith("--url"));
|
|
2158
|
+
if (!urlFlag)
|
|
2159
|
+
return;
|
|
2160
|
+
if (urlFlag.includes("="))
|
|
2161
|
+
return urlFlag.split("=")[1];
|
|
2162
|
+
const idx = flags.indexOf("--url");
|
|
2163
|
+
return flags[idx + 1];
|
|
2164
|
+
}
|
|
2165
|
+
async function initWithToken(baseUrl, token) {
|
|
2166
|
+
if (configExists()) {
|
|
2167
|
+
console.log(`Existing configuration found. Overwriting...
|
|
2168
|
+
`);
|
|
2169
|
+
}
|
|
2170
|
+
console.log("Exchanging provisioning token...");
|
|
2171
|
+
try {
|
|
2172
|
+
const result = await provision(baseUrl, {
|
|
2173
|
+
token,
|
|
2174
|
+
device_name: hostname2()
|
|
2175
|
+
});
|
|
2176
|
+
writeConfigFromProvision(baseUrl, result);
|
|
2177
|
+
console.log(`
|
|
2178
|
+
Connected as ${result.user_email}`);
|
|
2179
|
+
printPostInit();
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
if (error instanceof ProvisionError) {
|
|
2182
|
+
console.error(`
|
|
2183
|
+
Provisioning failed: ${error.detail}`);
|
|
2184
|
+
process.exit(1);
|
|
2185
|
+
}
|
|
2186
|
+
throw error;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
async function initWithBrowser(baseUrl) {
|
|
2190
|
+
if (configExists()) {
|
|
2191
|
+
console.log(`Existing configuration found. Overwriting...
|
|
2192
|
+
`);
|
|
2193
|
+
}
|
|
2194
|
+
try {
|
|
2195
|
+
const { code } = await runBrowserAuth(baseUrl);
|
|
2196
|
+
console.log("Exchanging authorization code...");
|
|
2197
|
+
const result = await provision(baseUrl, {
|
|
2198
|
+
code,
|
|
2199
|
+
device_name: hostname2()
|
|
2200
|
+
});
|
|
2201
|
+
writeConfigFromProvision(baseUrl, result);
|
|
2202
|
+
console.log(`
|
|
2203
|
+
Connected as ${result.user_email}`);
|
|
2204
|
+
printPostInit();
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
if (error instanceof ProvisionError) {
|
|
2207
|
+
console.error(`
|
|
2208
|
+
Provisioning failed: ${error.detail}`);
|
|
2209
|
+
process.exit(1);
|
|
2210
|
+
}
|
|
2211
|
+
console.error(`
|
|
2212
|
+
Authorization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2213
|
+
console.error("Try: engrm init --token=cmt_xxx");
|
|
2214
|
+
process.exit(1);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
function writeConfigFromProvision(baseUrl, result) {
|
|
2218
|
+
ensureConfigDir();
|
|
2219
|
+
const config = {
|
|
2220
|
+
candengo_url: baseUrl,
|
|
2221
|
+
candengo_api_key: result.api_key,
|
|
2222
|
+
site_id: result.site_id,
|
|
2223
|
+
namespace: result.namespace,
|
|
2224
|
+
user_id: result.user_id,
|
|
2225
|
+
user_email: result.user_email,
|
|
2226
|
+
device_id: generateDeviceId2(),
|
|
2227
|
+
teams: result.teams ?? [],
|
|
2228
|
+
sync: {
|
|
2229
|
+
enabled: true,
|
|
2230
|
+
interval_seconds: 30,
|
|
2231
|
+
batch_size: 50
|
|
2232
|
+
},
|
|
2233
|
+
search: {
|
|
2234
|
+
default_limit: 10,
|
|
2235
|
+
local_boost: 1.2,
|
|
2236
|
+
scope: "all"
|
|
2237
|
+
},
|
|
2238
|
+
scrubbing: {
|
|
2239
|
+
enabled: true,
|
|
2240
|
+
custom_patterns: [],
|
|
2241
|
+
default_sensitivity: "shared"
|
|
2242
|
+
},
|
|
2243
|
+
sentinel: {
|
|
2244
|
+
enabled: false,
|
|
2245
|
+
mode: "advisory",
|
|
2246
|
+
provider: "openai",
|
|
2247
|
+
model: "gpt-4o-mini",
|
|
2248
|
+
api_key: "",
|
|
2249
|
+
base_url: "",
|
|
2250
|
+
skip_patterns: [],
|
|
2251
|
+
daily_limit: 100,
|
|
2252
|
+
tier: "free"
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
saveConfig(config);
|
|
2256
|
+
const db = new MemDatabase(getDbPath());
|
|
2257
|
+
db.close();
|
|
2258
|
+
console.log(`Configuration saved to ${getSettingsPath()}`);
|
|
2259
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
2260
|
+
}
|
|
2261
|
+
function initFromFile(configPath) {
|
|
2262
|
+
if (!existsSync6(configPath)) {
|
|
2263
|
+
console.error(`Config file not found: ${configPath}`);
|
|
2264
|
+
process.exit(1);
|
|
2265
|
+
}
|
|
2266
|
+
let parsed;
|
|
2267
|
+
try {
|
|
2268
|
+
const raw = readFileSync6(configPath, "utf-8");
|
|
2269
|
+
parsed = JSON.parse(raw);
|
|
2270
|
+
} catch {
|
|
2271
|
+
console.error(`Invalid JSON in ${configPath}`);
|
|
2272
|
+
process.exit(1);
|
|
2273
|
+
}
|
|
2274
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
2275
|
+
console.error("Config file must contain a JSON object");
|
|
2276
|
+
process.exit(1);
|
|
2277
|
+
}
|
|
2278
|
+
const input = parsed;
|
|
2279
|
+
const required = [
|
|
2280
|
+
"candengo_url",
|
|
2281
|
+
"candengo_api_key",
|
|
2282
|
+
"site_id",
|
|
2283
|
+
"namespace",
|
|
2284
|
+
"user_id"
|
|
2285
|
+
];
|
|
2286
|
+
for (const field of required) {
|
|
2287
|
+
if (typeof input[field] !== "string" || !input[field].trim()) {
|
|
2288
|
+
console.error(`Missing required field: ${field}`);
|
|
2289
|
+
process.exit(1);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
ensureConfigDir();
|
|
2293
|
+
const config = {
|
|
2294
|
+
candengo_url: input["candengo_url"].trim(),
|
|
2295
|
+
candengo_api_key: input["candengo_api_key"].trim(),
|
|
2296
|
+
site_id: input["site_id"].trim(),
|
|
2297
|
+
namespace: input["namespace"].trim(),
|
|
2298
|
+
user_id: input["user_id"].trim(),
|
|
2299
|
+
user_email: typeof input["user_email"] === "string" ? input["user_email"].trim() : "",
|
|
2300
|
+
device_id: typeof input["device_id"] === "string" ? input["device_id"] : generateDeviceId2(),
|
|
2301
|
+
teams: [],
|
|
2302
|
+
sync: {
|
|
2303
|
+
enabled: true,
|
|
2304
|
+
interval_seconds: 30,
|
|
2305
|
+
batch_size: 50
|
|
2306
|
+
},
|
|
2307
|
+
search: {
|
|
2308
|
+
default_limit: 10,
|
|
2309
|
+
local_boost: 1.2,
|
|
2310
|
+
scope: "all"
|
|
2311
|
+
},
|
|
2312
|
+
scrubbing: {
|
|
2313
|
+
enabled: true,
|
|
2314
|
+
custom_patterns: [],
|
|
2315
|
+
default_sensitivity: "shared"
|
|
2316
|
+
},
|
|
2317
|
+
sentinel: {
|
|
2318
|
+
enabled: false,
|
|
2319
|
+
mode: "advisory",
|
|
2320
|
+
provider: "openai",
|
|
2321
|
+
model: "gpt-4o-mini",
|
|
2322
|
+
api_key: "",
|
|
2323
|
+
base_url: "",
|
|
2324
|
+
skip_patterns: [],
|
|
2325
|
+
daily_limit: 100,
|
|
2326
|
+
tier: "free"
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
saveConfig(config);
|
|
2330
|
+
const db = new MemDatabase(getDbPath());
|
|
2331
|
+
db.close();
|
|
2332
|
+
console.log(`Configuration saved to ${getSettingsPath()}`);
|
|
2333
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
2334
|
+
printPostInit();
|
|
2335
|
+
}
|
|
2336
|
+
async function initManual() {
|
|
2337
|
+
const prompt = createPrompter();
|
|
2338
|
+
console.log(`Engrm \u2014 Interactive Setup
|
|
2339
|
+
`);
|
|
2340
|
+
if (configExists()) {
|
|
2341
|
+
const overwrite = await prompt("Config already exists. Overwrite? [y/N]: ");
|
|
2342
|
+
if (overwrite.toLowerCase() !== "y") {
|
|
2343
|
+
console.log("Aborted.");
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
const candengoUrl = await prompt("Candengo Vector URL (e.g. https://www.candengo.com): ");
|
|
2348
|
+
const apiKey = await prompt("API key (cvk_...): ");
|
|
2349
|
+
const siteId = await prompt("Site ID: ");
|
|
2350
|
+
const namespace = await prompt("Namespace: ");
|
|
2351
|
+
const userId = await prompt("User ID: ");
|
|
2352
|
+
const userEmail = await prompt("Email (optional): ");
|
|
2353
|
+
if (!candengoUrl || !apiKey || !siteId || !namespace || !userId) {
|
|
2354
|
+
console.error("All fields (except email) are required.");
|
|
2355
|
+
process.exit(1);
|
|
2356
|
+
}
|
|
2357
|
+
ensureConfigDir();
|
|
2358
|
+
const config = {
|
|
2359
|
+
candengo_url: candengoUrl.trim(),
|
|
2360
|
+
candengo_api_key: apiKey.trim(),
|
|
2361
|
+
site_id: siteId.trim(),
|
|
2362
|
+
namespace: namespace.trim(),
|
|
2363
|
+
user_id: userId.trim(),
|
|
2364
|
+
user_email: userEmail.trim(),
|
|
2365
|
+
device_id: generateDeviceId2(),
|
|
2366
|
+
teams: [],
|
|
2367
|
+
sync: {
|
|
2368
|
+
enabled: true,
|
|
2369
|
+
interval_seconds: 30,
|
|
2370
|
+
batch_size: 50
|
|
2371
|
+
},
|
|
2372
|
+
search: {
|
|
2373
|
+
default_limit: 10,
|
|
2374
|
+
local_boost: 1.2,
|
|
2375
|
+
scope: "all"
|
|
2376
|
+
},
|
|
2377
|
+
scrubbing: {
|
|
2378
|
+
enabled: true,
|
|
2379
|
+
custom_patterns: [],
|
|
2380
|
+
default_sensitivity: "shared"
|
|
2381
|
+
},
|
|
2382
|
+
sentinel: {
|
|
2383
|
+
enabled: false,
|
|
2384
|
+
mode: "advisory",
|
|
2385
|
+
provider: "openai",
|
|
2386
|
+
model: "gpt-4o-mini",
|
|
2387
|
+
api_key: "",
|
|
2388
|
+
base_url: "",
|
|
2389
|
+
skip_patterns: [],
|
|
2390
|
+
daily_limit: 100,
|
|
2391
|
+
tier: "free"
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
saveConfig(config);
|
|
2395
|
+
const db = new MemDatabase(getDbPath());
|
|
2396
|
+
db.close();
|
|
2397
|
+
console.log(`
|
|
2398
|
+
Configuration saved to ${getSettingsPath()}`);
|
|
2399
|
+
console.log(`Database initialised at ${getDbPath()}`);
|
|
2400
|
+
printPostInit();
|
|
2401
|
+
}
|
|
2402
|
+
function handleStatus() {
|
|
2403
|
+
if (!configExists()) {
|
|
2404
|
+
console.log("Engrm is not configured.");
|
|
2405
|
+
console.log("Run: npx engrm init");
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
const config = loadConfig();
|
|
2409
|
+
console.log(`Engrm Status
|
|
2410
|
+
`);
|
|
2411
|
+
console.log(" Account");
|
|
2412
|
+
console.log(` User: ${config.user_id}`);
|
|
2413
|
+
if (config.user_email) {
|
|
2414
|
+
console.log(` Email: ${config.user_email}`);
|
|
2415
|
+
}
|
|
2416
|
+
console.log(` Device: ${config.device_id}`);
|
|
2417
|
+
if (config.teams.length > 0) {
|
|
2418
|
+
console.log(` Teams: ${config.teams.map((t) => t.name).join(", ")}`);
|
|
2419
|
+
}
|
|
2420
|
+
const tierLabels = {
|
|
2421
|
+
free: "Free",
|
|
2422
|
+
vibe: "Vibe ($9/mo)",
|
|
2423
|
+
solo: "Vibe ($9/mo)",
|
|
2424
|
+
pro: "Pro ($15/mo)",
|
|
2425
|
+
team: "Team",
|
|
2426
|
+
enterprise: "Enterprise"
|
|
2427
|
+
};
|
|
2428
|
+
const tier = config.sentinel?.tier ?? "free";
|
|
2429
|
+
console.log(` Plan: ${tierLabels[tier] ?? tier}`);
|
|
2430
|
+
console.log(`
|
|
2431
|
+
Integration`);
|
|
2432
|
+
console.log(` Candengo: ${config.candengo_url || "(not set)"}`);
|
|
2433
|
+
console.log(` Sync: ${config.sync.enabled ? "enabled" : "disabled"}`);
|
|
2434
|
+
const claudeJson = join6(homedir3(), ".claude.json");
|
|
2435
|
+
const claudeSettings = join6(homedir3(), ".claude", "settings.json");
|
|
2436
|
+
const mcpRegistered = existsSync6(claudeJson) && readFileSync6(claudeJson, "utf-8").includes('"engrm"');
|
|
2437
|
+
const settingsContent = existsSync6(claudeSettings) ? readFileSync6(claudeSettings, "utf-8") : "";
|
|
2438
|
+
const hooksRegistered = settingsContent.includes("engrm") || settingsContent.includes("session-start");
|
|
2439
|
+
let hookCount = 0;
|
|
2440
|
+
if (hooksRegistered) {
|
|
2441
|
+
try {
|
|
2442
|
+
const settings = JSON.parse(settingsContent);
|
|
2443
|
+
const hooks = settings?.hooks ?? {};
|
|
2444
|
+
for (const entries of Object.values(hooks)) {
|
|
2445
|
+
if (Array.isArray(entries)) {
|
|
2446
|
+
for (const entry of entries) {
|
|
2447
|
+
const e = entry;
|
|
2448
|
+
if (e.hooks?.some((h) => h.command?.includes("engrm") || h.command?.includes("session-start") || h.command?.includes("sentinel") || h.command?.includes("post-tool-use") || h.command?.includes("pre-compact") || h.command?.includes("stop") || h.command?.includes("elicitation"))) {
|
|
2449
|
+
hookCount++;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
} catch {}
|
|
2455
|
+
}
|
|
2456
|
+
console.log(` MCP server: ${mcpRegistered ? "registered" : "not registered"}`);
|
|
2457
|
+
console.log(` Hooks: ${hooksRegistered ? `registered (${hookCount || "?"} hooks)` : "not registered"}`);
|
|
2458
|
+
if (config.sentinel?.enabled) {
|
|
2459
|
+
console.log(`
|
|
2460
|
+
Sentinel`);
|
|
2461
|
+
console.log(` Mode: ${config.sentinel.mode}`);
|
|
2462
|
+
console.log(` Daily limit: ${config.sentinel.daily_limit}`);
|
|
2463
|
+
if (config.sentinel.provider) {
|
|
2464
|
+
console.log(` Provider: ${config.sentinel.provider}${config.sentinel.model ? ` (${config.sentinel.model})` : ""}`);
|
|
2465
|
+
}
|
|
2466
|
+
if (existsSync6(getDbPath())) {
|
|
2467
|
+
try {
|
|
2468
|
+
const db = new MemDatabase(getDbPath());
|
|
2469
|
+
const todayStart = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
|
|
2470
|
+
const todayAudits = db.db.query("SELECT COUNT(*) as count FROM security_findings WHERE finding_type LIKE 'sentinel_%' AND created_at_epoch >= ?").get(todayStart)?.count ?? 0;
|
|
2471
|
+
console.log(` Today: ${todayAudits}/${config.sentinel.daily_limit} audits`);
|
|
2472
|
+
db.close();
|
|
2473
|
+
} catch {}
|
|
2474
|
+
}
|
|
2475
|
+
} else {
|
|
2476
|
+
console.log(`
|
|
2477
|
+
Sentinel: disabled`);
|
|
2478
|
+
}
|
|
2479
|
+
if (existsSync6(getDbPath())) {
|
|
2480
|
+
try {
|
|
2481
|
+
const db = new MemDatabase(getDbPath());
|
|
2482
|
+
const obsCount = db.getActiveObservationCount();
|
|
2483
|
+
const outbox = getOutboxStats(db);
|
|
2484
|
+
console.log(`
|
|
2485
|
+
Memory`);
|
|
2486
|
+
console.log(` Observations: ${obsCount.toLocaleString()} active`);
|
|
2487
|
+
try {
|
|
2488
|
+
const byType = db.db.query(`SELECT type, COUNT(*) as count FROM observations
|
|
2489
|
+
WHERE lifecycle IN ('active', 'aging', 'pinned') AND superseded_by IS NULL
|
|
2490
|
+
GROUP BY type ORDER BY count DESC`).all();
|
|
2491
|
+
if (byType.length > 0) {
|
|
2492
|
+
const typeParts = byType.map((t) => `${t.type}: ${t.count}`);
|
|
2493
|
+
console.log(` By type: ${typeParts.join(", ")}`);
|
|
2494
|
+
}
|
|
2495
|
+
} catch {}
|
|
2496
|
+
const summaryCount = db.db.query("SELECT COUNT(*) as count FROM session_summaries").get()?.count ?? 0;
|
|
2497
|
+
console.log(` Sessions: ${summaryCount} summarised`);
|
|
2498
|
+
try {
|
|
2499
|
+
const lastSummary = db.db.query(`SELECT request, created_at_epoch FROM session_summaries
|
|
2500
|
+
ORDER BY created_at_epoch DESC LIMIT 1`).get();
|
|
2501
|
+
if (lastSummary) {
|
|
2502
|
+
const label = lastSummary.request ? lastSummary.request.length > 50 ? lastSummary.request.slice(0, 47) + "..." : lastSummary.request : "(no request recorded)";
|
|
2503
|
+
const ago = formatTimeAgo(lastSummary.created_at_epoch);
|
|
2504
|
+
console.log(` Last session: ${label} (${ago})`);
|
|
2505
|
+
}
|
|
2506
|
+
} catch {}
|
|
2507
|
+
try {
|
|
2508
|
+
const packs = db.getInstalledPacks();
|
|
2509
|
+
if (packs.length > 0) {
|
|
2510
|
+
console.log(` Packs: ${packs.join(", ")}`);
|
|
2511
|
+
}
|
|
2512
|
+
} catch {}
|
|
2513
|
+
console.log(`
|
|
2514
|
+
Sync`);
|
|
2515
|
+
console.log(` Outbox: ${outbox["pending"] ?? 0} pending, ${outbox["failed"] ?? 0} failed, ${outbox["synced"] ?? 0} synced`);
|
|
2516
|
+
try {
|
|
2517
|
+
const lastPush = db.db.query("SELECT value FROM sync_state WHERE key = ?").get("last_push_epoch");
|
|
2518
|
+
const lastPull = db.db.query("SELECT value FROM sync_state WHERE key = ?").get("last_pull_epoch");
|
|
2519
|
+
console.log(` Last push: ${formatSyncTime(lastPush?.value)}`);
|
|
2520
|
+
console.log(` Last pull: ${formatSyncTime(lastPull?.value)}`);
|
|
2521
|
+
} catch {}
|
|
2522
|
+
try {
|
|
2523
|
+
const findings = db.db.query("SELECT severity, COUNT(*) as count FROM security_findings GROUP BY severity").all();
|
|
2524
|
+
if (findings.length > 0) {
|
|
2525
|
+
const bySeverity = Object.fromEntries(findings.map((f) => [f.severity, f.count]));
|
|
2526
|
+
const total = findings.reduce((s, f) => s + f.count, 0);
|
|
2527
|
+
const parts = [];
|
|
2528
|
+
if (bySeverity["critical"])
|
|
2529
|
+
parts.push(`${bySeverity["critical"]} critical`);
|
|
2530
|
+
if (bySeverity["high"])
|
|
2531
|
+
parts.push(`${bySeverity["high"]} high`);
|
|
2532
|
+
if (bySeverity["medium"])
|
|
2533
|
+
parts.push(`${bySeverity["medium"]} medium`);
|
|
2534
|
+
if (bySeverity["low"])
|
|
2535
|
+
parts.push(`${bySeverity["low"]} low`);
|
|
2536
|
+
console.log(`
|
|
2537
|
+
Security: ${total} finding${total === 1 ? "" : "s"} (${parts.join(", ")})`);
|
|
2538
|
+
}
|
|
2539
|
+
} catch {}
|
|
2540
|
+
db.close();
|
|
2541
|
+
} catch (error) {
|
|
2542
|
+
console.log(`
|
|
2543
|
+
Database error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
console.log(`
|
|
2547
|
+
Files`);
|
|
2548
|
+
console.log(` Config: ${getSettingsPath()}`);
|
|
2549
|
+
console.log(` Database: ${getDbPath()}`);
|
|
2550
|
+
}
|
|
2551
|
+
function formatTimeAgo(epoch) {
|
|
2552
|
+
const ago = Math.floor(Date.now() / 1000) - epoch;
|
|
2553
|
+
if (ago < 60)
|
|
2554
|
+
return `${ago}s ago`;
|
|
2555
|
+
if (ago < 3600)
|
|
2556
|
+
return `${Math.floor(ago / 60)}m ago`;
|
|
2557
|
+
if (ago < 86400)
|
|
2558
|
+
return `${Math.floor(ago / 3600)}h ago`;
|
|
2559
|
+
return `${Math.floor(ago / 86400)}d ago`;
|
|
2560
|
+
}
|
|
2561
|
+
function formatSyncTime(epochStr) {
|
|
2562
|
+
if (!epochStr)
|
|
2563
|
+
return "never";
|
|
2564
|
+
const epoch = parseInt(epochStr, 10);
|
|
2565
|
+
if (isNaN(epoch) || epoch === 0)
|
|
2566
|
+
return "never";
|
|
2567
|
+
return formatTimeAgo(epoch);
|
|
2568
|
+
}
|
|
2569
|
+
function ensureConfigDir() {
|
|
2570
|
+
const dir = getConfigDir();
|
|
2571
|
+
if (!existsSync6(dir)) {
|
|
2572
|
+
mkdirSync3(dir, { recursive: true });
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
function generateDeviceId2() {
|
|
2576
|
+
const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
2577
|
+
const suffix = randomBytes3(4).toString("hex");
|
|
2578
|
+
return `${host}-${suffix}`;
|
|
2579
|
+
}
|
|
2580
|
+
async function handleInstallPack(flags) {
|
|
2581
|
+
const packName = flags[0];
|
|
2582
|
+
if (!packName) {
|
|
2583
|
+
console.error("Usage: engrm install-pack <name>");
|
|
2584
|
+
console.error(`Available: ${listPacks().join(", ") || "none"}`);
|
|
2585
|
+
process.exit(1);
|
|
2586
|
+
}
|
|
2587
|
+
if (!configExists()) {
|
|
2588
|
+
console.error("Engrm is not configured. Run: engrm init");
|
|
2589
|
+
process.exit(1);
|
|
2590
|
+
}
|
|
2591
|
+
const config = loadConfig();
|
|
2592
|
+
const db = new MemDatabase(getDbPath());
|
|
2593
|
+
try {
|
|
2594
|
+
console.log(`Installing pack: ${packName}...`);
|
|
2595
|
+
const result = await installPack(db, config, packName, process.cwd());
|
|
2596
|
+
console.log(`Installed ${result.installed}/${result.total} observations (${result.skipped} skipped)`);
|
|
2597
|
+
} catch (error) {
|
|
2598
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2599
|
+
process.exit(1);
|
|
2600
|
+
} finally {
|
|
2601
|
+
db.close();
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
async function handleSentinel(flags) {
|
|
2605
|
+
const subcommand = flags[0];
|
|
2606
|
+
if (subcommand === "init-rules") {
|
|
2607
|
+
if (!configExists()) {
|
|
2608
|
+
console.error("Engrm is not configured. Run: engrm init");
|
|
2609
|
+
process.exit(1);
|
|
2610
|
+
}
|
|
2611
|
+
const config = loadConfig();
|
|
2612
|
+
const db = new MemDatabase(getDbPath());
|
|
2613
|
+
try {
|
|
2614
|
+
const packNames = flags.slice(1);
|
|
2615
|
+
const names = packNames.length > 0 ? packNames : undefined;
|
|
2616
|
+
console.log(`Installing Sentinel rule packs: ${(names ?? listRulePacks()).join(", ")}...`);
|
|
2617
|
+
const result = await installRulePacks(db, config, names);
|
|
2618
|
+
console.log(`Installed ${result.installed} standards (${result.skipped} skipped)`);
|
|
2619
|
+
} catch (error) {
|
|
2620
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2621
|
+
process.exit(1);
|
|
2622
|
+
} finally {
|
|
2623
|
+
db.close();
|
|
2624
|
+
}
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if (subcommand === "rules") {
|
|
2628
|
+
const packs = listRulePacks();
|
|
2629
|
+
console.log(`Available Sentinel rule packs:
|
|
2630
|
+
`);
|
|
2631
|
+
for (const name of packs) {
|
|
2632
|
+
console.log(` ${name}`);
|
|
2633
|
+
}
|
|
2634
|
+
console.log(`
|
|
2635
|
+
Install all: engrm sentinel init-rules`);
|
|
2636
|
+
console.log(`Install one: engrm sentinel init-rules <name>`);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
console.log(`Sentinel \u2014 Real-time AI code audit
|
|
2640
|
+
`);
|
|
2641
|
+
console.log("Commands:");
|
|
2642
|
+
console.log(" engrm sentinel init-rules Install all rule packs");
|
|
2643
|
+
console.log(" engrm sentinel init-rules <name> Install specific pack");
|
|
2644
|
+
console.log(" engrm sentinel rules List available rule packs");
|
|
2645
|
+
}
|
|
2646
|
+
function handleListPacks() {
|
|
2647
|
+
const packs = listPacks();
|
|
2648
|
+
if (packs.length === 0) {
|
|
2649
|
+
console.log("No starter packs available.");
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
console.log(`Available starter packs:
|
|
2653
|
+
`);
|
|
2654
|
+
for (const name of packs) {
|
|
2655
|
+
console.log(` ${name}`);
|
|
2656
|
+
}
|
|
2657
|
+
console.log(`
|
|
2658
|
+
Install with: engrm install-pack <name>`);
|
|
2659
|
+
}
|
|
2660
|
+
function printPostInit() {
|
|
2661
|
+
console.log(`
|
|
2662
|
+
Registering with Claude Code...`);
|
|
2663
|
+
try {
|
|
2664
|
+
const result = registerAll();
|
|
2665
|
+
console.log(` MCP server registered \u2192 ${result.mcp.path}`);
|
|
2666
|
+
console.log(` Hooks registered \u2192 ${result.hooks.path}`);
|
|
2667
|
+
console.log(`
|
|
2668
|
+
Engrm is ready! Start a new Claude Code session to use memory.`);
|
|
2669
|
+
} catch (error) {
|
|
2670
|
+
console.log(`
|
|
2671
|
+
Could not auto-register with Claude Code.`);
|
|
2672
|
+
console.log(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2673
|
+
console.log(`
|
|
2674
|
+
Manual setup \u2014 add to ~/.claude.json:`);
|
|
2675
|
+
console.log(`
|
|
2676
|
+
{
|
|
2677
|
+
"mcpServers": {
|
|
2678
|
+
"engrm": {
|
|
2679
|
+
"type": "stdio",
|
|
2680
|
+
"command": "bun",
|
|
2681
|
+
"args": ["run", "${process.cwd()}/src/server.ts"]
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}`);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function printUsage() {
|
|
2688
|
+
console.log(`Engrm \u2014 Memory layer for AI coding agents
|
|
2689
|
+
`);
|
|
2690
|
+
console.log("Usage:");
|
|
2691
|
+
console.log(" engrm init Setup via browser (recommended)");
|
|
2692
|
+
console.log(" engrm init --token=cmt_xxx Setup from provisioning token");
|
|
2693
|
+
console.log(" engrm init --pack=<name> Setup + install a starter pack");
|
|
2694
|
+
console.log(" engrm init --no-browser Setup via device code (SSH/headless)");
|
|
2695
|
+
console.log(" engrm init --manual Manual setup (enter all values)");
|
|
2696
|
+
console.log(" engrm init --config <file> Setup from JSON file");
|
|
2697
|
+
console.log(" engrm status Show status");
|
|
2698
|
+
console.log(" engrm packs List available starter packs");
|
|
2699
|
+
console.log(" engrm install-pack <name> Install a starter pack");
|
|
2700
|
+
console.log(" engrm sentinel Sentinel code audit commands");
|
|
2701
|
+
console.log(" engrm sentinel init-rules Install Sentinel rule packs");
|
|
2702
|
+
}
|
|
2703
|
+
function createPrompter() {
|
|
2704
|
+
return async (question) => {
|
|
2705
|
+
process.stdout.write(question);
|
|
2706
|
+
for await (const chunk of process.stdin) {
|
|
2707
|
+
const line = (typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()).trim();
|
|
2708
|
+
return line;
|
|
2709
|
+
}
|
|
2710
|
+
return "";
|
|
2711
|
+
};
|
|
2712
|
+
}
|