engrm 0.3.2 → 0.4.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/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
  }
@@ -548,6 +569,56 @@ function runMigrations(db) {
548
569
  }
549
570
  }
550
571
  }
572
+ function ensureObservationTypes(db) {
573
+ try {
574
+ db.exec("INSERT INTO observations (session_id, project_id, type, title, user_id, device_id, agent, created_at, created_at_epoch) " + "VALUES ('_typecheck', 1, 'message', '_test', '_test', '_test', '_test', '2000-01-01', 0)");
575
+ db.exec("DELETE FROM observations WHERE session_id = '_typecheck'");
576
+ } catch {
577
+ db.exec("BEGIN TRANSACTION");
578
+ try {
579
+ db.exec(`
580
+ CREATE TABLE observations_repair (
581
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT,
582
+ project_id INTEGER NOT NULL REFERENCES projects(id),
583
+ type TEXT NOT NULL CHECK (type IN (
584
+ 'bugfix','discovery','decision','pattern','change','feature',
585
+ 'refactor','digest','standard','message')),
586
+ title TEXT NOT NULL, narrative TEXT, facts TEXT, concepts TEXT,
587
+ files_read TEXT, files_modified TEXT,
588
+ quality REAL DEFAULT 0.5 CHECK (quality BETWEEN 0.0 AND 1.0),
589
+ lifecycle TEXT DEFAULT 'active' CHECK (lifecycle IN ('active','aging','archived','purged','pinned')),
590
+ sensitivity TEXT DEFAULT 'shared' CHECK (sensitivity IN ('shared','personal','secret')),
591
+ user_id TEXT NOT NULL, device_id TEXT NOT NULL, agent TEXT DEFAULT 'claude-code',
592
+ created_at TEXT NOT NULL, created_at_epoch INTEGER NOT NULL,
593
+ archived_at_epoch INTEGER,
594
+ compacted_into INTEGER REFERENCES observations(id) ON DELETE SET NULL,
595
+ superseded_by INTEGER REFERENCES observations(id) ON DELETE SET NULL,
596
+ remote_source_id TEXT
597
+ );
598
+ INSERT INTO observations_repair SELECT * FROM observations;
599
+ DROP TABLE observations;
600
+ ALTER TABLE observations_repair RENAME TO observations;
601
+ CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
602
+ CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
603
+ CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
604
+ CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
605
+ CREATE INDEX IF NOT EXISTS idx_observations_lifecycle ON observations(lifecycle);
606
+ CREATE INDEX IF NOT EXISTS idx_observations_quality ON observations(quality);
607
+ CREATE INDEX IF NOT EXISTS idx_observations_user ON observations(user_id);
608
+ CREATE INDEX IF NOT EXISTS idx_observations_superseded ON observations(superseded_by);
609
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_remote_source ON observations(remote_source_id) WHERE remote_source_id IS NOT NULL;
610
+ DROP TABLE IF EXISTS observations_fts;
611
+ CREATE VIRTUAL TABLE observations_fts USING fts5(
612
+ title, narrative, facts, concepts, content=observations, content_rowid=id
613
+ );
614
+ INSERT INTO observations_fts(observations_fts) VALUES('rebuild');
615
+ `);
616
+ db.exec("COMMIT");
617
+ } catch (err) {
618
+ db.exec("ROLLBACK");
619
+ }
620
+ }
621
+ }
551
622
  var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max, m) => Math.max(max, m.version), 0);
552
623
 
553
624
  // src/storage/sqlite.ts
@@ -614,6 +685,7 @@ class MemDatabase {
614
685
  this.db.exec("PRAGMA foreign_keys = ON");
615
686
  this.vecAvailable = this.loadVecExtension();
616
687
  runMigrations(this.db);
688
+ ensureObservationTypes(this.db);
617
689
  }
618
690
  loadVecExtension() {
619
691
  try {
@@ -988,6 +1060,335 @@ function getOutboxStats(db) {
988
1060
  return stats;
989
1061
  }
990
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
+
991
1392
  // src/provisioning/provision.ts
992
1393
  var DEFAULT_CANDENGO_URL = "https://www.candengo.com";
993
1394
 
@@ -1035,12 +1436,12 @@ async function provision(baseUrl, request) {
1035
1436
  }
1036
1437
 
1037
1438
  // src/provisioning/browser-auth.ts
1038
- import { randomBytes as randomBytes2 } from "node:crypto";
1439
+ import { randomBytes } from "node:crypto";
1039
1440
  import { createServer } from "node:http";
1040
1441
  import { execFile } from "node:child_process";
1041
1442
  var CALLBACK_TIMEOUT_MS = 120000;
1042
1443
  async function runBrowserAuth(candengoUrl) {
1043
- const state = randomBytes2(16).toString("hex");
1444
+ const state = randomBytes(16).toString("hex");
1044
1445
  const { port, waitForCallback, stop } = await startCallbackServer(state);
1045
1446
  const redirectUri = `http://localhost:${port}/callback`;
1046
1447
  const authUrl = new URL("/connect/mem", candengoUrl);
@@ -1266,7 +1667,7 @@ function registerHooks() {
1266
1667
  hooks: [{ type: "command", command: preToolUseCmd }]
1267
1668
  }, "sentinel");
1268
1669
  hooks["PostToolUse"] = replaceEngrmHook(hooks["PostToolUse"], {
1269
- matcher: "Edit|Write|Bash|Read|mcp__.*",
1670
+ matcher: "Edit|Write|Bash|Read|Grep|Glob|WebSearch|WebFetch|mcp__.*",
1270
1671
  hooks: [{ type: "command", command: postToolUseCmd }]
1271
1672
  }, "post-tool-use");
1272
1673
  hooks["ElicitationResult"] = replaceEngrmHook(hooks["ElicitationResult"], {
@@ -1905,7 +2306,8 @@ var VALID_TYPES = [
1905
2306
  "feature",
1906
2307
  "refactor",
1907
2308
  "digest",
1908
- "standard"
2309
+ "standard",
2310
+ "message"
1909
2311
  ];
1910
2312
  async function saveObservation(db, config, input) {
1911
2313
  if (!VALID_TYPES.includes(input.type)) {
@@ -2162,6 +2564,9 @@ switch (command) {
2162
2564
  case "sentinel":
2163
2565
  await handleSentinel(args.slice(1));
2164
2566
  break;
2567
+ case "doctor":
2568
+ await handleDoctor();
2569
+ break;
2165
2570
  default:
2166
2571
  printUsage();
2167
2572
  break;
@@ -2261,6 +2666,7 @@ async function initWithToken(baseUrl, token) {
2261
2666
  console.log(`
2262
2667
  Connected as ${result.user_email}`);
2263
2668
  printPostInit();
2669
+ await checkDeviceLimits(baseUrl, result.api_key);
2264
2670
  } catch (error) {
2265
2671
  if (error instanceof ProvisionError) {
2266
2672
  console.error(`
@@ -2286,6 +2692,7 @@ async function initWithBrowser(baseUrl) {
2286
2692
  console.log(`
2287
2693
  Connected as ${result.user_email}`);
2288
2694
  printPostInit();
2695
+ await checkDeviceLimits(baseUrl, result.api_key);
2289
2696
  } catch (error) {
2290
2697
  if (error instanceof ProvisionError) {
2291
2698
  console.error(`
@@ -2343,6 +2750,14 @@ function writeConfigFromProvision(baseUrl, result) {
2343
2750
  skip_patterns: [],
2344
2751
  daily_limit: 100,
2345
2752
  tier: "free"
2753
+ },
2754
+ observer: {
2755
+ enabled: true,
2756
+ mode: "per_event",
2757
+ model: "haiku"
2758
+ },
2759
+ transcript_analysis: {
2760
+ enabled: false
2346
2761
  }
2347
2762
  };
2348
2763
  saveConfig(config);
@@ -2417,6 +2832,14 @@ function initFromFile(configPath) {
2417
2832
  skip_patterns: [],
2418
2833
  daily_limit: 100,
2419
2834
  tier: "free"
2835
+ },
2836
+ observer: {
2837
+ enabled: true,
2838
+ mode: "per_event",
2839
+ model: "haiku"
2840
+ },
2841
+ transcript_analysis: {
2842
+ enabled: false
2420
2843
  }
2421
2844
  };
2422
2845
  saveConfig(config);
@@ -2482,6 +2905,14 @@ async function initManual() {
2482
2905
  skip_patterns: [],
2483
2906
  daily_limit: 100,
2484
2907
  tier: "free"
2908
+ },
2909
+ observer: {
2910
+ enabled: true,
2911
+ mode: "per_event",
2912
+ model: "haiku"
2913
+ },
2914
+ transcript_analysis: {
2915
+ enabled: false
2485
2916
  }
2486
2917
  };
2487
2918
  saveConfig(config);
@@ -2667,7 +3098,22 @@ function ensureConfigDir() {
2667
3098
  }
2668
3099
  function generateDeviceId2() {
2669
3100
  const host = hostname2().toLowerCase().replace(/[^a-z0-9-]/g, "");
2670
- 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);
2671
3117
  return `${host}-${suffix}`;
2672
3118
  }
2673
3119
  async function handleInstallPack(flags) {
@@ -2767,6 +3213,231 @@ Restart Claude Code to use the new version.`);
2767
3213
  console.error("Update failed. Try manually: npm install -g engrm@latest");
2768
3214
  }
2769
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
+ }
2770
3441
  function printPostInit() {
2771
3442
  console.log(`
2772
3443
  Registering with Claude Code...`);
@@ -2808,6 +3479,7 @@ function printUsage() {
2808
3479
  console.log(" engrm update Update to latest version");
2809
3480
  console.log(" engrm packs List available starter packs");
2810
3481
  console.log(" engrm install-pack <name> Install a starter pack");
3482
+ console.log(" engrm doctor Run diagnostic checks");
2811
3483
  console.log(" engrm sentinel Sentinel code audit commands");
2812
3484
  console.log(" engrm sentinel init-rules Install Sentinel rule packs");
2813
3485
  }