engrm 0.3.4 → 0.4.1

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/dist/cli.js CHANGED
@@ -18,16 +18,16 @@ var __export = (target, all) => {
18
18
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
19
 
20
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";
21
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, statSync } from "fs";
22
+ import { hostname as hostname2, homedir as homedir3, networkInterfaces as networkInterfaces2 } from "os";
23
23
  import { join as join6 } from "path";
24
- import { randomBytes as randomBytes3 } from "crypto";
24
+ import { createHash as createHash2 } from "crypto";
25
25
 
26
26
  // src/config.ts
27
27
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
- import { homedir, hostname } from "node:os";
28
+ import { homedir, hostname, networkInterfaces } from "node:os";
29
29
  import { join } from "node:path";
30
- import { randomBytes } from "node:crypto";
30
+ import { createHash } from "node:crypto";
31
31
  var CONFIG_DIR = join(homedir(), ".engrm");
32
32
  var SETTINGS_PATH = join(CONFIG_DIR, "settings.json");
33
33
  var DB_PATH = join(CONFIG_DIR, "engrm.db");
@@ -42,7 +42,22 @@ function getDbPath() {
42
42
  }
43
43
  function generateDeviceId() {
44
44
  const host = hostname().toLowerCase().replace(/[^a-z0-9-]/g, "");
45
- const suffix = randomBytes(4).toString("hex");
45
+ let mac = "";
46
+ const ifaces = networkInterfaces();
47
+ for (const entries of Object.values(ifaces)) {
48
+ if (!entries)
49
+ continue;
50
+ for (const entry of entries) {
51
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
52
+ mac = entry.mac;
53
+ break;
54
+ }
55
+ }
56
+ if (mac)
57
+ break;
58
+ }
59
+ const material = `${host}:${mac || "no-mac"}`;
60
+ const suffix = createHash("sha256").update(material).digest("hex").slice(0, 8);
46
61
  return `${host}-${suffix}`;
47
62
  }
48
63
  function createDefaultConfig() {
@@ -84,7 +99,10 @@ function createDefaultConfig() {
84
99
  observer: {
85
100
  enabled: true,
86
101
  mode: "per_event",
87
- model: "haiku"
102
+ model: "sonnet"
103
+ },
104
+ transcript_analysis: {
105
+ enabled: false
88
106
  }
89
107
  };
90
108
  }
@@ -143,6 +161,9 @@ function loadConfig() {
143
161
  enabled: asBool(config["observer"]?.["enabled"], defaults.observer.enabled),
144
162
  mode: asObserverMode(config["observer"]?.["mode"], defaults.observer.mode),
145
163
  model: asString(config["observer"]?.["model"], defaults.observer.model)
164
+ },
165
+ transcript_analysis: {
166
+ enabled: asBool(config["transcript_analysis"]?.["enabled"], defaults.transcript_analysis.enabled)
146
167
  }
147
168
  };
148
169
  }
@@ -1039,6 +1060,335 @@ function getOutboxStats(db) {
1039
1060
  return stats;
1040
1061
  }
1041
1062
 
1063
+ // src/storage/migrations.ts
1064
+ var MIGRATIONS2 = [
1065
+ {
1066
+ version: 1,
1067
+ description: "Initial schema: projects, observations, sessions, sync, FTS5",
1068
+ sql: `
1069
+ -- Projects (canonical identity across machines)
1070
+ CREATE TABLE projects (
1071
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1072
+ canonical_id TEXT UNIQUE NOT NULL,
1073
+ name TEXT NOT NULL,
1074
+ local_path TEXT,
1075
+ remote_url TEXT,
1076
+ first_seen_epoch INTEGER NOT NULL,
1077
+ last_active_epoch INTEGER NOT NULL
1078
+ );
1079
+
1080
+ -- Core observations table
1081
+ CREATE TABLE observations (
1082
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1083
+ session_id TEXT,
1084
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1085
+ type TEXT NOT NULL CHECK (type IN (
1086
+ 'bugfix', 'discovery', 'decision', 'pattern',
1087
+ 'change', 'feature', 'refactor', 'digest'
1088
+ )),
1089
+ title TEXT NOT NULL,
1090
+ narrative TEXT,
1091
+ facts TEXT,
1092
+ concepts TEXT,
1093
+ files_read TEXT,
1094
+ files_modified TEXT,
1095
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1096
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1097
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1098
+ )),
1099
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1100
+ 'shared', 'personal', 'secret'
1101
+ )),
1102
+ user_id TEXT NOT NULL,
1103
+ device_id TEXT NOT NULL,
1104
+ agent TEXT DEFAULT 'claude-code',
1105
+ created_at TEXT NOT NULL,
1106
+ created_at_epoch INTEGER NOT NULL,
1107
+ archived_at_epoch INTEGER,
1108
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL
1109
+ );
1110
+
1111
+ -- Session tracking
1112
+ CREATE TABLE sessions (
1113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1114
+ session_id TEXT UNIQUE NOT NULL,
1115
+ project_id INTEGER REFERENCES projects(id),
1116
+ user_id TEXT NOT NULL,
1117
+ device_id TEXT NOT NULL,
1118
+ agent TEXT DEFAULT 'claude-code',
1119
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed')),
1120
+ observation_count INTEGER DEFAULT 0,
1121
+ started_at_epoch INTEGER,
1122
+ completed_at_epoch INTEGER
1123
+ );
1124
+
1125
+ -- Session summaries (generated on Stop hook)
1126
+ CREATE TABLE session_summaries (
1127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1128
+ session_id TEXT UNIQUE NOT NULL,
1129
+ project_id INTEGER REFERENCES projects(id),
1130
+ user_id TEXT NOT NULL,
1131
+ request TEXT,
1132
+ investigated TEXT,
1133
+ learned TEXT,
1134
+ completed TEXT,
1135
+ next_steps TEXT,
1136
+ created_at_epoch INTEGER
1137
+ );
1138
+
1139
+ -- Sync outbox (offline-first queue)
1140
+ CREATE TABLE sync_outbox (
1141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1142
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
1143
+ record_id INTEGER NOT NULL,
1144
+ status TEXT DEFAULT 'pending' CHECK (status IN (
1145
+ 'pending', 'syncing', 'synced', 'failed'
1146
+ )),
1147
+ retry_count INTEGER DEFAULT 0,
1148
+ max_retries INTEGER DEFAULT 10,
1149
+ last_error TEXT,
1150
+ created_at_epoch INTEGER NOT NULL,
1151
+ synced_at_epoch INTEGER,
1152
+ next_retry_epoch INTEGER
1153
+ );
1154
+
1155
+ -- Sync high-water mark and lifecycle job tracking
1156
+ CREATE TABLE sync_state (
1157
+ key TEXT PRIMARY KEY,
1158
+ value TEXT NOT NULL
1159
+ );
1160
+
1161
+ -- FTS5 for local offline search (external content mode)
1162
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1163
+ title, narrative, facts, concepts,
1164
+ content=observations,
1165
+ content_rowid=id
1166
+ );
1167
+
1168
+ -- Indexes: observations
1169
+ CREATE INDEX idx_observations_project ON observations(project_id);
1170
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1171
+ CREATE INDEX idx_observations_type ON observations(type);
1172
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1173
+ CREATE INDEX idx_observations_session ON observations(session_id);
1174
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1175
+ CREATE INDEX idx_observations_quality ON observations(quality);
1176
+ CREATE INDEX idx_observations_user ON observations(user_id);
1177
+
1178
+ -- Indexes: sessions
1179
+ CREATE INDEX idx_sessions_project ON sessions(project_id);
1180
+
1181
+ -- Indexes: sync outbox
1182
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
1183
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
1184
+ `
1185
+ },
1186
+ {
1187
+ version: 2,
1188
+ description: "Add superseded_by for knowledge supersession",
1189
+ sql: `
1190
+ ALTER TABLE observations ADD COLUMN superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL;
1191
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1192
+ `
1193
+ },
1194
+ {
1195
+ version: 3,
1196
+ description: "Add remote_source_id for pull deduplication",
1197
+ sql: `
1198
+ ALTER TABLE observations ADD COLUMN remote_source_id TEXT;
1199
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1200
+ `
1201
+ },
1202
+ {
1203
+ version: 4,
1204
+ description: "Add sqlite-vec for local semantic search",
1205
+ sql: `
1206
+ CREATE VIRTUAL TABLE vec_observations USING vec0(
1207
+ observation_id INTEGER PRIMARY KEY,
1208
+ embedding float[384]
1209
+ );
1210
+ `,
1211
+ condition: (db) => isVecExtensionLoaded2(db)
1212
+ },
1213
+ {
1214
+ version: 5,
1215
+ description: "Session metrics and security findings",
1216
+ sql: `
1217
+ ALTER TABLE sessions ADD COLUMN files_touched_count INTEGER DEFAULT 0;
1218
+ ALTER TABLE sessions ADD COLUMN searches_performed INTEGER DEFAULT 0;
1219
+ ALTER TABLE sessions ADD COLUMN tool_calls_count INTEGER DEFAULT 0;
1220
+
1221
+ CREATE TABLE security_findings (
1222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1223
+ session_id TEXT,
1224
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1225
+ finding_type TEXT NOT NULL,
1226
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
1227
+ pattern_name TEXT NOT NULL,
1228
+ file_path TEXT,
1229
+ snippet TEXT,
1230
+ tool_name TEXT,
1231
+ user_id TEXT NOT NULL,
1232
+ device_id TEXT NOT NULL,
1233
+ created_at_epoch INTEGER NOT NULL
1234
+ );
1235
+
1236
+ CREATE INDEX idx_security_findings_session ON security_findings(session_id);
1237
+ CREATE INDEX idx_security_findings_project ON security_findings(project_id, created_at_epoch);
1238
+ CREATE INDEX idx_security_findings_severity ON security_findings(severity);
1239
+ `
1240
+ },
1241
+ {
1242
+ version: 6,
1243
+ description: "Add risk_score, expand observation types to include standard",
1244
+ sql: `
1245
+ ALTER TABLE sessions ADD COLUMN risk_score INTEGER;
1246
+
1247
+ -- Recreate observations table with expanded type CHECK to include 'standard'
1248
+ -- SQLite doesn't support ALTER CHECK, so we recreate the table
1249
+ CREATE TABLE observations_new (
1250
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1251
+ session_id TEXT,
1252
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1253
+ type TEXT NOT NULL CHECK (type IN (
1254
+ 'bugfix', 'discovery', 'decision', 'pattern',
1255
+ 'change', 'feature', 'refactor', 'digest', 'standard'
1256
+ )),
1257
+ title TEXT NOT NULL,
1258
+ narrative TEXT,
1259
+ facts TEXT,
1260
+ concepts TEXT,
1261
+ files_read TEXT,
1262
+ files_modified TEXT,
1263
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1264
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1265
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1266
+ )),
1267
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1268
+ 'shared', 'personal', 'secret'
1269
+ )),
1270
+ user_id TEXT NOT NULL,
1271
+ device_id TEXT NOT NULL,
1272
+ agent TEXT DEFAULT 'claude-code',
1273
+ created_at TEXT NOT NULL,
1274
+ created_at_epoch INTEGER NOT NULL,
1275
+ archived_at_epoch INTEGER,
1276
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1277
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1278
+ remote_source_id TEXT
1279
+ );
1280
+
1281
+ INSERT INTO observations_new SELECT * FROM observations;
1282
+
1283
+ DROP TABLE observations;
1284
+ ALTER TABLE observations_new RENAME TO observations;
1285
+
1286
+ -- Recreate indexes
1287
+ CREATE INDEX idx_observations_project ON observations(project_id);
1288
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1289
+ CREATE INDEX idx_observations_type ON observations(type);
1290
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1291
+ CREATE INDEX idx_observations_session ON observations(session_id);
1292
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1293
+ CREATE INDEX idx_observations_quality ON observations(quality);
1294
+ CREATE INDEX idx_observations_user ON observations(user_id);
1295
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1296
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1297
+
1298
+ -- Recreate FTS5 (external content mode — must rebuild after table recreation)
1299
+ DROP TABLE IF EXISTS observations_fts;
1300
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1301
+ title, narrative, facts, concepts,
1302
+ content=observations,
1303
+ content_rowid=id
1304
+ );
1305
+ -- Rebuild FTS index
1306
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
1307
+ `
1308
+ },
1309
+ {
1310
+ version: 7,
1311
+ description: "Add packs_installed table for help pack tracking",
1312
+ sql: `
1313
+ CREATE TABLE IF NOT EXISTS packs_installed (
1314
+ name TEXT PRIMARY KEY,
1315
+ installed_at INTEGER NOT NULL,
1316
+ observation_count INTEGER DEFAULT 0
1317
+ );
1318
+ `
1319
+ },
1320
+ {
1321
+ version: 8,
1322
+ description: "Add message type to observations CHECK constraint",
1323
+ sql: `
1324
+ CREATE TABLE IF NOT EXISTS observations_v8 (
1325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1326
+ session_id TEXT,
1327
+ project_id INTEGER NOT NULL REFERENCES projects(id),
1328
+ type TEXT NOT NULL CHECK (type IN (
1329
+ 'bugfix', 'discovery', 'decision', 'pattern',
1330
+ 'change', 'feature', 'refactor', 'digest', 'standard', 'message'
1331
+ )),
1332
+ title TEXT NOT NULL,
1333
+ narrative TEXT,
1334
+ facts TEXT,
1335
+ concepts TEXT,
1336
+ files_read TEXT,
1337
+ files_modified TEXT,
1338
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
1339
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN (
1340
+ 'active', 'aging', 'archived', 'purged', 'pinned'
1341
+ )),
1342
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN (
1343
+ 'shared', 'personal', 'secret'
1344
+ )),
1345
+ user_id TEXT NOT NULL,
1346
+ device_id TEXT NOT NULL,
1347
+ agent TEXT DEFAULT 'claude-code',
1348
+ created_at TEXT NOT NULL,
1349
+ created_at_epoch INTEGER NOT NULL,
1350
+ archived_at_epoch INTEGER,
1351
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1352
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
1353
+ remote_source_id TEXT
1354
+ );
1355
+ INSERT INTO observations_v8 SELECT * FROM observations;
1356
+ DROP TABLE observations;
1357
+ ALTER TABLE observations_v8 RENAME TO observations;
1358
+ CREATE INDEX idx_observations_project ON observations(project_id);
1359
+ CREATE INDEX idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1360
+ CREATE INDEX idx_observations_type ON observations(type);
1361
+ CREATE INDEX idx_observations_created ON observations(created_at_epoch);
1362
+ CREATE INDEX idx_observations_session ON observations(session_id);
1363
+ CREATE INDEX idx_observations_lifecycle ON observations(lifecycle);
1364
+ CREATE INDEX idx_observations_quality ON observations(quality);
1365
+ CREATE INDEX idx_observations_user ON observations(user_id);
1366
+ CREATE INDEX idx_observations_superseded ON observations(superseded_by);
1367
+ CREATE UNIQUE INDEX idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
1368
+ DROP TABLE IF EXISTS observations_fts;
1369
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
1370
+ title, narrative, facts, concepts,
1371
+ content=observations,
1372
+ content_rowid=id
1373
+ );
1374
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
1375
+ `
1376
+ }
1377
+ ];
1378
+ function isVecExtensionLoaded2(db) {
1379
+ try {
1380
+ db.exec("SELECT vec_version()");
1381
+ return true;
1382
+ } catch {
1383
+ return false;
1384
+ }
1385
+ }
1386
+ function getSchemaVersion(db) {
1387
+ const result = db.query("PRAGMA user_version").get();
1388
+ return result.user_version;
1389
+ }
1390
+ var LATEST_SCHEMA_VERSION2 = MIGRATIONS2.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
1391
+
1042
1392
  // src/provisioning/provision.ts
1043
1393
  var DEFAULT_CANDENGO_URL = "https://www.candengo.com";
1044
1394
 
@@ -1086,12 +1436,12 @@ async function provision(baseUrl, request) {
1086
1436
  }
1087
1437
 
1088
1438
  // src/provisioning/browser-auth.ts
1089
- import { randomBytes as randomBytes2 } from "node:crypto";
1439
+ import { randomBytes } from "node:crypto";
1090
1440
  import { createServer } from "node:http";
1091
1441
  import { execFile } from "node:child_process";
1092
1442
  var CALLBACK_TIMEOUT_MS = 120000;
1093
1443
  async function runBrowserAuth(candengoUrl) {
1094
- const state = randomBytes2(16).toString("hex");
1444
+ const state = randomBytes(16).toString("hex");
1095
1445
  const { port, waitForCallback, stop } = await startCallbackServer(state);
1096
1446
  const redirectUri = `http://localhost:${port}/callback`;
1097
1447
  const authUrl = new URL("/connect/mem", candengoUrl);
@@ -1317,7 +1667,7 @@ function registerHooks() {
1317
1667
  hooks: [{ type: "command", command: preToolUseCmd }]
1318
1668
  }, "sentinel");
1319
1669
  hooks["PostToolUse"] = replaceEngrmHook(hooks["PostToolUse"], {
1320
- matcher: "Edit|Write|Bash|Read|mcp__.*",
1670
+ matcher: "Edit|Write|Bash|Read|Grep|Glob|WebSearch|WebFetch|mcp__.*",
1321
1671
  hooks: [{ type: "command", command: postToolUseCmd }]
1322
1672
  }, "post-tool-use");
1323
1673
  hooks["ElicitationResult"] = replaceEngrmHook(hooks["ElicitationResult"], {
@@ -2214,6 +2564,9 @@ switch (command) {
2214
2564
  case "sentinel":
2215
2565
  await handleSentinel(args.slice(1));
2216
2566
  break;
2567
+ case "doctor":
2568
+ await handleDoctor();
2569
+ break;
2217
2570
  default:
2218
2571
  printUsage();
2219
2572
  break;
@@ -2313,6 +2666,7 @@ async function initWithToken(baseUrl, token) {
2313
2666
  console.log(`
2314
2667
  Connected as ${result.user_email}`);
2315
2668
  printPostInit();
2669
+ await checkDeviceLimits(baseUrl, result.api_key);
2316
2670
  } catch (error) {
2317
2671
  if (error instanceof ProvisionError) {
2318
2672
  console.error(`
@@ -2338,6 +2692,7 @@ async function initWithBrowser(baseUrl) {
2338
2692
  console.log(`
2339
2693
  Connected as ${result.user_email}`);
2340
2694
  printPostInit();
2695
+ await checkDeviceLimits(baseUrl, result.api_key);
2341
2696
  } catch (error) {
2342
2697
  if (error instanceof ProvisionError) {
2343
2698
  console.error(`
@@ -2395,6 +2750,14 @@ function writeConfigFromProvision(baseUrl, result) {
2395
2750
  skip_patterns: [],
2396
2751
  daily_limit: 100,
2397
2752
  tier: "free"
2753
+ },
2754
+ observer: {
2755
+ enabled: true,
2756
+ mode: "per_event",
2757
+ model: "haiku"
2758
+ },
2759
+ transcript_analysis: {
2760
+ enabled: false
2398
2761
  }
2399
2762
  };
2400
2763
  saveConfig(config);
@@ -2469,6 +2832,14 @@ function initFromFile(configPath) {
2469
2832
  skip_patterns: [],
2470
2833
  daily_limit: 100,
2471
2834
  tier: "free"
2835
+ },
2836
+ observer: {
2837
+ enabled: true,
2838
+ mode: "per_event",
2839
+ model: "haiku"
2840
+ },
2841
+ transcript_analysis: {
2842
+ enabled: false
2472
2843
  }
2473
2844
  };
2474
2845
  saveConfig(config);
@@ -2534,6 +2905,14 @@ async function initManual() {
2534
2905
  skip_patterns: [],
2535
2906
  daily_limit: 100,
2536
2907
  tier: "free"
2908
+ },
2909
+ observer: {
2910
+ enabled: true,
2911
+ mode: "per_event",
2912
+ model: "haiku"
2913
+ },
2914
+ transcript_analysis: {
2915
+ enabled: false
2537
2916
  }
2538
2917
  };
2539
2918
  saveConfig(config);
@@ -2719,7 +3098,22 @@ function ensureConfigDir() {
2719
3098
  }
2720
3099
  function generateDeviceId2() {
2721
3100
  const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
2722
- const suffix = randomBytes3(4).toString("hex");
3101
+ let mac = "";
3102
+ const ifaces = networkInterfaces2();
3103
+ for (const entries of Object.values(ifaces)) {
3104
+ if (!entries)
3105
+ continue;
3106
+ for (const entry of entries) {
3107
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") {
3108
+ mac = entry.mac;
3109
+ break;
3110
+ }
3111
+ }
3112
+ if (mac)
3113
+ break;
3114
+ }
3115
+ const material = `${host}:${mac || "no-mac"}`;
3116
+ const suffix = createHash2("sha256").update(material).digest("hex").slice(0, 8);
2723
3117
  return `${host}-${suffix}`;
2724
3118
  }
2725
3119
  async function handleInstallPack(flags) {
@@ -2819,6 +3213,231 @@ Restart Claude Code to use the new version.`);
2819
3213
  console.error("Update failed. Try manually: npm install -g engrm@latest");
2820
3214
  }
2821
3215
  }
3216
+ async function handleDoctor() {
3217
+ const results = [];
3218
+ const pass = (msg) => results.push({ symbol: "\u2713", message: msg, kind: "pass" });
3219
+ const fail = (msg) => results.push({ symbol: "\u2717", message: msg, kind: "fail" });
3220
+ const warn = (msg) => results.push({ symbol: "\u26A0", message: msg, kind: "warn" });
3221
+ const info = (msg) => results.push({ symbol: "\u2139", message: msg, kind: "info" });
3222
+ if (configExists()) {
3223
+ pass("Configuration file exists");
3224
+ } else {
3225
+ fail("Configuration file not found \u2014 run: engrm init");
3226
+ printDoctorReport(results);
3227
+ return;
3228
+ }
3229
+ let config = null;
3230
+ try {
3231
+ config = loadConfig();
3232
+ pass("Configuration is valid");
3233
+ } catch (err) {
3234
+ fail(`Configuration is invalid: ${err instanceof Error ? err.message : String(err)}`);
3235
+ printDoctorReport(results);
3236
+ return;
3237
+ }
3238
+ let db = null;
3239
+ try {
3240
+ db = new MemDatabase(getDbPath());
3241
+ pass("Database opens successfully");
3242
+ } catch (err) {
3243
+ fail(`Database failed to open: ${err instanceof Error ? err.message : String(err)}`);
3244
+ printDoctorReport(results);
3245
+ return;
3246
+ }
3247
+ try {
3248
+ const currentVersion = getSchemaVersion(db.db);
3249
+ if (currentVersion >= LATEST_SCHEMA_VERSION2) {
3250
+ pass(`Database schema is current (v${currentVersion})`);
3251
+ } else {
3252
+ warn(`Database schema is outdated (v${currentVersion}, latest is v${LATEST_SCHEMA_VERSION2})`);
3253
+ }
3254
+ } catch {
3255
+ warn("Could not check database schema version");
3256
+ }
3257
+ const claudeJson = join6(homedir3(), ".claude.json");
3258
+ try {
3259
+ if (existsSync6(claudeJson)) {
3260
+ const content = readFileSync6(claudeJson, "utf-8");
3261
+ if (content.includes('"engrm"')) {
3262
+ pass("MCP server registered in Claude Code");
3263
+ } else {
3264
+ warn("MCP server not registered in Claude Code \u2014 run: engrm init");
3265
+ }
3266
+ } else {
3267
+ warn("Claude Code config not found (~/.claude.json)");
3268
+ }
3269
+ } catch {
3270
+ warn("Could not check MCP server registration");
3271
+ }
3272
+ const claudeSettings = join6(homedir3(), ".claude", "settings.json");
3273
+ try {
3274
+ if (existsSync6(claudeSettings)) {
3275
+ const content = readFileSync6(claudeSettings, "utf-8");
3276
+ let hookCount = 0;
3277
+ try {
3278
+ const settings = JSON.parse(content);
3279
+ const hooks = settings?.hooks ?? {};
3280
+ for (const entries of Object.values(hooks)) {
3281
+ if (Array.isArray(entries)) {
3282
+ for (const entry of entries) {
3283
+ const e = entry;
3284
+ 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"))) {
3285
+ hookCount++;
3286
+ }
3287
+ }
3288
+ }
3289
+ }
3290
+ } catch {}
3291
+ if (hookCount > 0) {
3292
+ pass(`Hooks registered (${hookCount} hook${hookCount === 1 ? "" : "s"})`);
3293
+ } else {
3294
+ warn("No Engrm hooks found in Claude Code settings");
3295
+ }
3296
+ } else {
3297
+ warn("Claude Code settings not found (~/.claude/settings.json)");
3298
+ }
3299
+ } catch {
3300
+ warn("Could not check hooks registration");
3301
+ }
3302
+ if (config.candengo_url) {
3303
+ try {
3304
+ const controller = new AbortController;
3305
+ const timeout = setTimeout(() => controller.abort(), 5000);
3306
+ const start = Date.now();
3307
+ const res = await fetch(`${config.candengo_url}/health`, { signal: controller.signal });
3308
+ clearTimeout(timeout);
3309
+ const elapsed = Date.now() - start;
3310
+ if (res.ok) {
3311
+ const host = new URL(config.candengo_url).hostname;
3312
+ pass(`Server connectivity (${host}, ${elapsed}ms)`);
3313
+ } else {
3314
+ fail(`Server returned HTTP ${res.status}`);
3315
+ }
3316
+ } catch (err) {
3317
+ const msg = err instanceof Error ? err.message : String(err);
3318
+ fail(`Server unreachable: ${msg.includes("abort") ? "timeout (5s)" : msg}`);
3319
+ }
3320
+ } else {
3321
+ fail("Server URL not configured");
3322
+ }
3323
+ if (config.candengo_url && config.candengo_api_key) {
3324
+ try {
3325
+ const controller = new AbortController;
3326
+ const timeout = setTimeout(() => controller.abort(), 5000);
3327
+ const res = await fetch(`${config.candengo_url}/v1/account/me`, {
3328
+ headers: { Authorization: `Bearer ${config.candengo_api_key}` },
3329
+ signal: controller.signal
3330
+ });
3331
+ clearTimeout(timeout);
3332
+ if (res.ok) {
3333
+ const data = await res.json();
3334
+ const email = data.email ?? config.user_email ?? "unknown";
3335
+ pass(`Authentication valid (${email})`);
3336
+ } else if (res.status === 401 || res.status === 403) {
3337
+ fail("Authentication failed \u2014 API key may be expired");
3338
+ } else {
3339
+ fail(`Authentication check returned HTTP ${res.status}`);
3340
+ }
3341
+ } catch (err) {
3342
+ const msg = err instanceof Error ? err.message : String(err);
3343
+ fail(`Authentication check failed: ${msg.includes("abort") ? "timeout (5s)" : msg}`);
3344
+ }
3345
+ } else {
3346
+ fail("Authentication not configured (missing URL or API key)");
3347
+ }
3348
+ try {
3349
+ const outbox = getOutboxStats(db);
3350
+ const failedCount = outbox["failed"] ?? 0;
3351
+ if (failedCount > 10) {
3352
+ warn(`Sync has stuck items (${failedCount} failed in outbox)`);
3353
+ } else {
3354
+ const pending = outbox["pending"] ?? 0;
3355
+ pass(`Sync outbox healthy (${pending} pending, ${failedCount} failed)`);
3356
+ }
3357
+ } catch {
3358
+ warn("Could not check sync outbox");
3359
+ }
3360
+ if (db.vecAvailable) {
3361
+ pass("Embedding model available (sqlite-vec loaded)");
3362
+ } else {
3363
+ warn("Embedding model not available (FTS5 fallback active)");
3364
+ }
3365
+ try {
3366
+ const totalActive = db.getActiveObservationCount();
3367
+ if (totalActive > 0) {
3368
+ let breakdownParts = [];
3369
+ try {
3370
+ const byLifecycle = db.db.query(`SELECT lifecycle, COUNT(*) as count FROM observations
3371
+ WHERE superseded_by IS NULL AND lifecycle IN ('active', 'aging', 'pinned')
3372
+ GROUP BY lifecycle`).all();
3373
+ breakdownParts = byLifecycle.map((r) => `${r.lifecycle}: ${r.count.toLocaleString()}`);
3374
+ } catch {}
3375
+ const detail = breakdownParts.length > 0 ? ` (${breakdownParts.join(", ")})` : "";
3376
+ pass(`${totalActive.toLocaleString()} observations${detail}`);
3377
+ } else {
3378
+ warn("No observations yet \u2014 start a Claude Code session to capture context");
3379
+ }
3380
+ } catch {
3381
+ warn("Could not count observations");
3382
+ }
3383
+ try {
3384
+ const dbPath = getDbPath();
3385
+ if (existsSync6(dbPath)) {
3386
+ const stats = statSync(dbPath);
3387
+ const sizeMB = stats.size / (1024 * 1024);
3388
+ const sizeStr = sizeMB >= 1 ? `${sizeMB.toFixed(1)} MB` : `${(stats.size / 1024).toFixed(0)} KB`;
3389
+ info(`Database size: ${sizeStr}`);
3390
+ }
3391
+ } catch {}
3392
+ db.close();
3393
+ printDoctorReport(results);
3394
+ }
3395
+ function printDoctorReport(results) {
3396
+ console.log(`
3397
+ Engrm Doctor \u2014 Diagnostic Report
3398
+ `);
3399
+ for (const r of results) {
3400
+ console.log(` ${r.symbol} ${r.message}`);
3401
+ }
3402
+ const passes = results.filter((r) => r.kind === "pass").length;
3403
+ const fails = results.filter((r) => r.kind === "fail").length;
3404
+ const warns = results.filter((r) => r.kind === "warn").length;
3405
+ const checks = results.filter((r) => r.kind !== "info").length;
3406
+ const parts = [];
3407
+ if (warns > 0)
3408
+ parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
3409
+ if (fails > 0)
3410
+ parts.push(`${fails} failure${fails === 1 ? "" : "s"}`);
3411
+ const summary = `${passes}/${checks} checks passed` + (parts.length > 0 ? `, ${parts.join(", ")}` : "");
3412
+ console.log(`
3413
+ ${summary}`);
3414
+ }
3415
+ async function checkDeviceLimits(baseUrl, apiKey) {
3416
+ try {
3417
+ const controller = new AbortController;
3418
+ const timeout = setTimeout(() => controller.abort(), 5000);
3419
+ const resp = await fetch(`${baseUrl}/v1/mem/billing`, {
3420
+ headers: { Authorization: `Bearer ${apiKey}` },
3421
+ signal: controller.signal
3422
+ });
3423
+ clearTimeout(timeout);
3424
+ if (!resp.ok)
3425
+ return;
3426
+ const billing = await resp.json();
3427
+ const limits = billing.limits || {};
3428
+ const usage = billing.usage || {};
3429
+ if (limits.max_devices && usage.devices && usage.devices >= limits.max_devices) {
3430
+ const upgradeUrl = billing.upgrade_url || "https://engrm.dev/billing";
3431
+ console.warn(`
3432
+ \u26A0\uFE0F Device limit reached (${usage.devices}/${limits.max_devices}).`);
3433
+ console.warn(` This device may not sync. Upgrade at: ${upgradeUrl}`);
3434
+ }
3435
+ if (limits.max_observations && usage.observations && usage.observations >= limits.max_observations) {
3436
+ console.warn(`\u26A0\uFE0F Observation limit reached (${usage.observations.toLocaleString()}/${limits.max_observations.toLocaleString()}).`);
3437
+ console.warn(` New observations won't sync until you upgrade or delete old data.`);
3438
+ }
3439
+ } catch {}
3440
+ }
2822
3441
  function printPostInit() {
2823
3442
  console.log(`
2824
3443
  Registering with Claude Code...`);
@@ -2860,6 +3479,7 @@ function printUsage() {
2860
3479
  console.log(" engrm update Update to latest version");
2861
3480
  console.log(" engrm packs List available starter packs");
2862
3481
  console.log(" engrm install-pack <name> Install a starter pack");
3482
+ console.log(" engrm doctor Run diagnostic checks");
2863
3483
  console.log(" engrm sentinel Sentinel code audit commands");
2864
3484
  console.log(" engrm sentinel init-rules Install Sentinel rule packs");
2865
3485
  }