engrm 0.4.43 → 0.4.45

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
@@ -21,8 +21,8 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
21
21
  import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync8, statSync } from "fs";
22
22
  import { hostname as hostname3, homedir as homedir5, networkInterfaces as networkInterfaces3 } from "os";
23
23
  import { dirname as dirname5, join as join8 } from "path";
24
- import { createHash as createHash4 } from "crypto";
25
- import { fileURLToPath as fileURLToPath4 } from "url";
24
+ import { createHash as createHash5 } from "crypto";
25
+ import { fileURLToPath as fileURLToPath4, pathToFileURL } from "url";
26
26
 
27
27
  // src/config.ts
28
28
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -880,6 +880,7 @@ function ensureObservationTypes(db) {
880
880
  DROP TABLE observations;
881
881
  ALTER TABLE observations_repair RENAME TO observations;
882
882
  CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
883
+ CREATE INDEX IF NOT EXISTS idx_observations_project_lifecycle ON observations(project_id, lifecycle);
883
884
  CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
884
885
  CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
885
886
  CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
@@ -1160,6 +1161,7 @@ class MemDatabase {
1160
1161
  this.db = openDatabase(dbPath);
1161
1162
  this.db.exec("PRAGMA journal_mode = WAL");
1162
1163
  this.db.exec("PRAGMA foreign_keys = ON");
1164
+ this.db.exec("PRAGMA busy_timeout = 5000");
1163
1165
  this.vecAvailable = this.loadVecExtension();
1164
1166
  runMigrations(this.db);
1165
1167
  ensureObservationTypes(this.db);
@@ -1181,8 +1183,16 @@ class MemDatabase {
1181
1183
  this.db.close();
1182
1184
  }
1183
1185
  upsertProject(project) {
1186
+ const canonicalId = project.canonical_id?.trim();
1187
+ const name = project.name?.trim();
1188
+ if (!canonicalId) {
1189
+ throw new Error("Project canonical_id is required");
1190
+ }
1191
+ if (!name) {
1192
+ throw new Error("Project name is required");
1193
+ }
1184
1194
  const now = Math.floor(Date.now() / 1000);
1185
- const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
1195
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId);
1186
1196
  if (existing) {
1187
1197
  this.db.query(`UPDATE projects SET
1188
1198
  local_path = COALESCE(?, local_path),
@@ -1197,7 +1207,7 @@ class MemDatabase {
1197
1207
  };
1198
1208
  }
1199
1209
  const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
1200
- VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
1210
+ VALUES (?, ?, ?, ?, ?, ?)`).run(canonicalId, name, project.local_path ?? null, project.remote_url ?? null, now, now);
1201
1211
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
1202
1212
  }
1203
1213
  getProjectByCanonicalId(canonicalId) {
@@ -1890,6 +1900,33 @@ function getOutboxStats(db) {
1890
1900
  }
1891
1901
  return stats;
1892
1902
  }
1903
+ function getOutboxFailureSummaries(db, limit = 5) {
1904
+ return db.db.query(`SELECT COALESCE(last_error, '') as error, COUNT(*) as count
1905
+ FROM sync_outbox
1906
+ WHERE status = 'failed'
1907
+ GROUP BY COALESCE(last_error, '')
1908
+ ORDER BY count DESC, error ASC
1909
+ LIMIT ?`).all(limit).filter((row) => row.error.length > 0);
1910
+ }
1911
+ function classifyOutboxFailure(error) {
1912
+ const normalized = error.toLowerCase();
1913
+ if (normalized.includes("401") || normalized.includes("invalid or missing credentials")) {
1914
+ return "auth";
1915
+ }
1916
+ if (normalized.includes("429") || normalized.includes("rate limit")) {
1917
+ return "rate_limit";
1918
+ }
1919
+ if (normalized.includes("timeout") || normalized.includes("abort")) {
1920
+ return "timeout";
1921
+ }
1922
+ if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("econn")) {
1923
+ return "network";
1924
+ }
1925
+ if (normalized.includes("400") || normalized.includes("422") || normalized.includes("validation")) {
1926
+ return "validation";
1927
+ }
1928
+ return "other";
1929
+ }
1893
1930
 
1894
1931
  // src/intelligence/value-signals.ts
1895
1932
  var LESSON_TYPES = new Set(["bugfix", "decision", "pattern"]);
@@ -2475,7 +2512,7 @@ async function provision(baseUrl, request) {
2475
2512
  import { randomBytes } from "node:crypto";
2476
2513
  import { createServer } from "node:http";
2477
2514
  import { execFile } from "node:child_process";
2478
- var CALLBACK_TIMEOUT_MS = 120000;
2515
+ var CALLBACK_TIMEOUT_MS = 600000;
2479
2516
  async function runBrowserAuth(candengoUrl) {
2480
2517
  const state = randomBytes(16).toString("hex");
2481
2518
  const { port, waitForCallback, stop } = await startCallbackServer(state);
@@ -2810,13 +2847,16 @@ function registerCodexHooks() {
2810
2847
  return { path: CODEX_HOOKS, added: true };
2811
2848
  }
2812
2849
  function registerOpenCode() {
2850
+ const runtime = findRuntime();
2813
2851
  const root = findPackageRoot();
2852
+ const dist = isBuiltDist();
2814
2853
  const pluginSource = join2(root, "opencode", "plugin", "engrm-opencode.js");
2854
+ const serverPath = dist ? join2(root, "dist", "server.js") : join2(root, "src", "server.ts");
2815
2855
  const config = readJsonFile(OPENCODE_CONFIG);
2816
2856
  const mcp = config["mcp"] ?? {};
2817
2857
  mcp["engrm"] = {
2818
2858
  type: "local",
2819
- command: ["engrm", "serve"],
2859
+ command: dist ? [runtime, serverPath] : [runtime, "run", serverPath],
2820
2860
  enabled: true,
2821
2861
  timeout: 5000
2822
2862
  };
@@ -3326,10 +3366,11 @@ function readProjectConfigFile(directory) {
3326
3366
  }
3327
3367
  }
3328
3368
  function detectProject(directory) {
3329
- const remoteUrl = getGitRemoteUrl(directory);
3369
+ const resolvedDirectory = resolve(directory);
3370
+ const remoteUrl = getGitRemoteUrl(resolvedDirectory);
3330
3371
  if (remoteUrl) {
3331
3372
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
3332
- const repoRoot = getGitTopLevel(directory) ?? directory;
3373
+ const repoRoot = getGitTopLevel(resolvedDirectory) ?? resolvedDirectory;
3333
3374
  return {
3334
3375
  canonical_id: canonicalId,
3335
3376
  name: projectNameFromCanonicalId(canonicalId),
@@ -3337,21 +3378,22 @@ function detectProject(directory) {
3337
3378
  local_path: repoRoot
3338
3379
  };
3339
3380
  }
3340
- const configFile = readProjectConfigFile(directory);
3381
+ const configFile = readProjectConfigFile(resolvedDirectory);
3341
3382
  if (configFile) {
3342
3383
  return {
3343
3384
  canonical_id: configFile.project_id,
3344
3385
  name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
3345
3386
  remote_url: null,
3346
- local_path: directory
3387
+ local_path: resolvedDirectory
3347
3388
  };
3348
3389
  }
3349
- const dirName = basename(directory);
3390
+ const dirName = basename(resolvedDirectory);
3391
+ const safeDirName = !dirName || dirName === "/" || dirName === "." ? "root" : dirName;
3350
3392
  return {
3351
- canonical_id: `local/${dirName}`,
3352
- name: dirName,
3393
+ canonical_id: `local/${safeDirName}`,
3394
+ name: safeDirName,
3353
3395
  remote_url: null,
3354
- local_path: directory
3396
+ local_path: resolvedDirectory
3355
3397
  };
3356
3398
  }
3357
3399
  function detectProjectForPath(filePath, fallbackCwd) {
@@ -4323,6 +4365,117 @@ function hasOpenClawMcpRegistration(content) {
4323
4365
  }
4324
4366
  }
4325
4367
 
4368
+ // src/sync/auth.ts
4369
+ import { createHash as createHash4 } from "node:crypto";
4370
+
4371
+ // src/storage/outbox.ts
4372
+ function getPendingEntries(db, limit = 50) {
4373
+ const now = Math.floor(Date.now() / 1000);
4374
+ return db.db.query(`SELECT * FROM sync_outbox
4375
+ WHERE (status = 'pending')
4376
+ OR (status = 'failed' AND retry_count < max_retries AND (next_retry_epoch IS NULL OR next_retry_epoch <= ?))
4377
+ ORDER BY created_at_epoch ASC
4378
+ LIMIT ?`).all(now, limit);
4379
+ }
4380
+ function markSyncing(db, entryId) {
4381
+ const now = Math.floor(Date.now() / 1000);
4382
+ db.db.query("UPDATE sync_outbox SET status = 'syncing', next_retry_epoch = ? WHERE id = ?").run(now, entryId);
4383
+ }
4384
+ function markSynced(db, entryId) {
4385
+ const now = Math.floor(Date.now() / 1000);
4386
+ db.db.query("UPDATE sync_outbox SET status = 'synced', synced_at_epoch = ?, next_retry_epoch = NULL, last_error = NULL WHERE id = ?").run(now, entryId);
4387
+ }
4388
+ function markFailed(db, entryId, error) {
4389
+ const now = Math.floor(Date.now() / 1000);
4390
+ db.db.query(`UPDATE sync_outbox SET
4391
+ status = 'failed',
4392
+ retry_count = retry_count + 1,
4393
+ last_error = ?,
4394
+ next_retry_epoch = ? + MIN(30 * (1 << retry_count), 3600)
4395
+ WHERE id = ?`).run(error, now, entryId);
4396
+ }
4397
+ function getOutboxStats2(db) {
4398
+ const rows = db.db.query("SELECT status, COUNT(*) as count FROM sync_outbox GROUP BY status").all();
4399
+ const stats = {
4400
+ pending: 0,
4401
+ syncing: 0,
4402
+ synced: 0,
4403
+ failed: 0
4404
+ };
4405
+ for (const row of rows) {
4406
+ stats[row.status] = row.count;
4407
+ }
4408
+ return stats;
4409
+ }
4410
+ function getOutboxFailureSummaries2(db, limit = 5) {
4411
+ return db.db.query(`SELECT COALESCE(last_error, '') as error, COUNT(*) as count
4412
+ FROM sync_outbox
4413
+ WHERE status = 'failed'
4414
+ GROUP BY COALESCE(last_error, '')
4415
+ ORDER BY count DESC, error ASC
4416
+ LIMIT ?`).all(limit).filter((row) => row.error.length > 0);
4417
+ }
4418
+ function classifyOutboxFailure2(error) {
4419
+ const normalized = error.toLowerCase();
4420
+ if (normalized.includes("401") || normalized.includes("invalid or missing credentials")) {
4421
+ return "auth";
4422
+ }
4423
+ if (normalized.includes("429") || normalized.includes("rate limit")) {
4424
+ return "rate_limit";
4425
+ }
4426
+ if (normalized.includes("timeout") || normalized.includes("abort")) {
4427
+ return "timeout";
4428
+ }
4429
+ if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("econn")) {
4430
+ return "network";
4431
+ }
4432
+ if (normalized.includes("400") || normalized.includes("422") || normalized.includes("validation")) {
4433
+ return "validation";
4434
+ }
4435
+ return "other";
4436
+ }
4437
+ function resetFailedEntries(db) {
4438
+ const result = db.db.query(`UPDATE sync_outbox
4439
+ SET status = 'pending',
4440
+ retry_count = 0,
4441
+ last_error = NULL,
4442
+ next_retry_epoch = NULL
4443
+ WHERE status = 'failed'`).run();
4444
+ return result.changes;
4445
+ }
4446
+ function resetFailedEntriesMatching(db, predicate) {
4447
+ const rows = db.db.query(`SELECT id, last_error
4448
+ FROM sync_outbox
4449
+ WHERE status = 'failed'`).all();
4450
+ const matchingIds = rows.filter((row) => row.last_error && predicate(row.last_error)).map((row) => row.id);
4451
+ if (matchingIds.length === 0)
4452
+ return 0;
4453
+ const placeholders = matchingIds.map(() => "?").join(", ");
4454
+ const result = db.db.query(`UPDATE sync_outbox
4455
+ SET status = 'pending',
4456
+ retry_count = 0,
4457
+ last_error = NULL,
4458
+ next_retry_epoch = NULL
4459
+ WHERE id IN (${placeholders})`).run(...matchingIds);
4460
+ return result.changes;
4461
+ }
4462
+ function resetSyncingEntries(db) {
4463
+ const result = db.db.query(`UPDATE sync_outbox
4464
+ SET status = 'pending',
4465
+ next_retry_epoch = NULL
4466
+ WHERE status = 'syncing'`).run();
4467
+ return result.changes;
4468
+ }
4469
+ function resetStaleSyncingEntries(db, maxAgeSeconds = 300) {
4470
+ const cutoff = Math.floor(Date.now() / 1000) - maxAgeSeconds;
4471
+ const result = db.db.query(`UPDATE sync_outbox
4472
+ SET status = 'pending',
4473
+ next_retry_epoch = NULL
4474
+ WHERE status = 'syncing'
4475
+ AND (next_retry_epoch IS NULL OR next_retry_epoch <= ?)`).run(cutoff);
4476
+ return result.changes;
4477
+ }
4478
+
4326
4479
  // src/sync/auth.ts
4327
4480
  var LEGACY_PUBLIC_HOSTS = new Set(["www.candengo.com", "candengo.com"]);
4328
4481
  function normalizeBaseUrl(url) {
@@ -4339,6 +4492,46 @@ function normalizeBaseUrl(url) {
4339
4492
  return trimmed.replace(/\/$/, "");
4340
4493
  }
4341
4494
  }
4495
+ function getApiKey(config) {
4496
+ const envKey = process.env.ENGRM_TOKEN;
4497
+ if (envKey && envKey.startsWith("cvk_"))
4498
+ return envKey;
4499
+ if (config.candengo_api_key && config.candengo_api_key.length > 0) {
4500
+ return config.candengo_api_key;
4501
+ }
4502
+ return null;
4503
+ }
4504
+ function getBaseUrl(config) {
4505
+ if (config.candengo_url && config.candengo_url.length > 0) {
4506
+ return normalizeBaseUrl(config.candengo_url);
4507
+ }
4508
+ return null;
4509
+ }
4510
+ function getAuthFingerprint(config) {
4511
+ const apiKey = getApiKey(config);
4512
+ const baseUrl = getBaseUrl(config);
4513
+ if (!apiKey || !baseUrl)
4514
+ return null;
4515
+ return createHash4("sha256").update(`${baseUrl}
4516
+ ${apiKey}
4517
+ ${config.namespace}
4518
+ ${config.site_id}`).digest("hex");
4519
+ }
4520
+ function recoverOutboxAfterSuccessfulAuth(db, config) {
4521
+ const fingerprint = getAuthFingerprint(config);
4522
+ const staleSyncingReset = resetStaleSyncingEntries(db);
4523
+ const authFailedReset = resetFailedEntriesMatching(db, (error) => classifyOutboxFailure2(error) === "auth");
4524
+ if (fingerprint) {
4525
+ db.setSyncState("sync_auth_fingerprint", fingerprint);
4526
+ }
4527
+ return {
4528
+ fingerprintChanged: false,
4529
+ failedReset: 0,
4530
+ authFailedReset,
4531
+ syncingReset: 0,
4532
+ staleSyncingReset
4533
+ };
4534
+ }
4342
4535
 
4343
4536
  // src/cli.ts
4344
4537
  var LEGACY_CODEX_SERVER_NAME3 = `candengo-${"mem"}`;
@@ -4350,6 +4543,9 @@ switch (command) {
4350
4543
  case "init":
4351
4544
  await handleInit(args.slice(1));
4352
4545
  break;
4546
+ case "serve":
4547
+ await handleServe();
4548
+ break;
4353
4549
  case "status":
4354
4550
  handleStatus();
4355
4551
  break;
@@ -4415,6 +4611,12 @@ async function handleInit(flags) {
4415
4611
  await initWithBrowser(url);
4416
4612
  await maybeInstallPack(flags);
4417
4613
  }
4614
+ async function handleServe() {
4615
+ const packageRoot = join8(THIS_DIR, "..");
4616
+ const serverPath = IS_BUILT_DIST ? join8(packageRoot, "dist", "server.js") : join8(packageRoot, "src", "server.ts");
4617
+ await import(pathToFileURL(serverPath).href);
4618
+ await new Promise(() => {});
4619
+ }
4418
4620
  async function maybeInstallPack(flags) {
4419
4621
  const packFlag = flags.find((f) => f.startsWith("--pack"));
4420
4622
  if (!packFlag)
@@ -4510,11 +4712,27 @@ function writeConfigFromProvision(baseUrl, result) {
4510
4712
  ensureConfigDir();
4511
4713
  let existingDeviceId;
4512
4714
  let existingSentinel;
4715
+ let existingObserver;
4716
+ let existingTranscriptAnalysis;
4717
+ let existingHttp;
4718
+ let existingFleet;
4719
+ let existingToolProfile;
4720
+ let existingSync;
4721
+ let existingSearch;
4722
+ let existingScrubbing;
4513
4723
  if (configExists()) {
4514
4724
  try {
4515
4725
  const existing = loadConfig();
4516
4726
  existingDeviceId = existing.device_id;
4517
4727
  existingSentinel = existing.sentinel;
4728
+ existingObserver = existing.observer;
4729
+ existingTranscriptAnalysis = existing.transcript_analysis;
4730
+ existingHttp = existing.http;
4731
+ existingFleet = existing.fleet;
4732
+ existingToolProfile = existing.tool_profile;
4733
+ existingSync = existing.sync;
4734
+ existingSearch = existing.search;
4735
+ existingScrubbing = existing.scrubbing;
4518
4736
  } catch {}
4519
4737
  }
4520
4738
  const config = {
@@ -4526,17 +4744,17 @@ function writeConfigFromProvision(baseUrl, result) {
4526
4744
  user_email: result.user_email,
4527
4745
  device_id: existingDeviceId || generateDeviceId3(),
4528
4746
  teams: result.teams ?? [],
4529
- sync: {
4747
+ sync: existingSync ?? {
4530
4748
  enabled: true,
4531
4749
  interval_seconds: 30,
4532
4750
  batch_size: 50
4533
4751
  },
4534
- search: {
4752
+ search: existingSearch ?? {
4535
4753
  default_limit: 10,
4536
4754
  local_boost: 1.2,
4537
4755
  scope: "all"
4538
4756
  },
4539
- scrubbing: {
4757
+ scrubbing: existingScrubbing ?? {
4540
4758
  enabled: true,
4541
4759
  custom_patterns: [],
4542
4760
  default_sensitivity: "shared"
@@ -4552,17 +4770,29 @@ function writeConfigFromProvision(baseUrl, result) {
4552
4770
  daily_limit: 100,
4553
4771
  tier: "free"
4554
4772
  },
4555
- observer: {
4773
+ observer: existingObserver ?? {
4556
4774
  enabled: true,
4557
4775
  mode: "per_event",
4558
4776
  model: "haiku"
4559
4777
  },
4560
- transcript_analysis: {
4778
+ transcript_analysis: existingTranscriptAnalysis ?? {
4561
4779
  enabled: false
4562
- }
4780
+ },
4781
+ http: existingHttp ?? {
4782
+ enabled: false,
4783
+ port: 3767,
4784
+ bearer_tokens: []
4785
+ },
4786
+ fleet: existingFleet ?? {
4787
+ project_name: "shared-experience",
4788
+ namespace: "",
4789
+ api_key: ""
4790
+ },
4791
+ tool_profile: existingToolProfile ?? "full"
4563
4792
  };
4564
4793
  saveConfig(config);
4565
4794
  const db = new MemDatabase(getDbPath());
4795
+ recoverOutboxAfterSuccessfulAuth(db, config);
4566
4796
  db.close();
4567
4797
  console.log(`Configuration saved to ${getSettingsPath()}`);
4568
4798
  console.log(`Database initialised at ${getDbPath()}`);
@@ -4878,7 +5108,7 @@ function handleStatus() {
4878
5108
  }
4879
5109
  console.log(` Value: ${signalParts.join(", ")}`);
4880
5110
  if (signals.security_findings_count > 0 || signals.delivery_review_ready) {
4881
- console.log(` Review/Safety: ${signals.delivery_review_ready ? "delivery-ready" : "not ready"}, ` + `${signals.security_findings_count} finding${signals.security_findings_count === 1 ? "" : "s"}`);
5111
+ console.log(` Review/Safety: ${signals.delivery_review_ready ? "delivery-ready" : "not ready"}, ${signals.security_findings_count} finding${signals.security_findings_count === 1 ? "" : "s"}`);
4882
5112
  }
4883
5113
  } catch {}
4884
5114
  try {
@@ -4899,6 +5129,11 @@ function handleStatus() {
4899
5129
  console.log(`
4900
5130
  Sync`);
4901
5131
  console.log(` Outbox: ${outbox["pending"] ?? 0} pending, ${outbox["failed"] ?? 0} failed, ${outbox["synced"] ?? 0} synced`);
5132
+ const topFailures = getOutboxFailureSummaries(db, 2);
5133
+ if (topFailures.length > 0) {
5134
+ const failureSummary = topFailures.map((row) => `${classifyOutboxFailure(row.error)} ${row.count}`).join(", ");
5135
+ console.log(` Failures: ${failureSummary}`);
5136
+ }
4902
5137
  try {
4903
5138
  const lastPush = db.db.query("SELECT value FROM sync_state WHERE key = ?").get("last_push_epoch");
4904
5139
  const lastPull = db.db.query("SELECT value FROM sync_state WHERE key = ?").get("last_pull_epoch");
@@ -4979,7 +5214,7 @@ function generateDeviceId3() {
4979
5214
  break;
4980
5215
  }
4981
5216
  const material = `${host}:${mac || "no-mac"}`;
4982
- const suffix = createHash4("sha256").update(material).digest("hex").slice(0, 8);
5217
+ const suffix = createHash5("sha256").update(material).digest("hex").slice(0, 8);
4983
5218
  return `${host}-${suffix}`;
4984
5219
  }
4985
5220
  async function handleInstallPack(flags) {
@@ -5393,7 +5628,7 @@ async function handleDoctor() {
5393
5628
  const dbPath = getDbPath();
5394
5629
  if (existsSync8(dbPath)) {
5395
5630
  const stats = statSync(dbPath);
5396
- const sizeMB = stats.size / (1024 * 1024);
5631
+ const sizeMB = stats.size / 1048576;
5397
5632
  const sizeStr = sizeMB >= 1 ? `${sizeMB.toFixed(1)} MB` : `${(stats.size / 1024).toFixed(0)} KB`;
5398
5633
  info(`Database size: ${sizeStr}`);
5399
5634
  }
@@ -5529,7 +5764,7 @@ And add to ~/.config/opencode/opencode.json:`);
5529
5764
  "mcp": {
5530
5765
  "engrm": {
5531
5766
  "type": "local",
5532
- "command": ["engrm", "serve"],
5767
+ "command": ${JSON.stringify(IS_BUILT_DIST ? [process.execPath, join8(packageRoot, "dist", "server.js")] : ["bun", "run", join8(packageRoot, "src", "server.ts")])},
5533
5768
  "enabled": true,
5534
5769
  "timeout": 5000
5535
5770
  }
@@ -5558,6 +5793,7 @@ function printUsage() {
5558
5793
  console.log(`Engrm \u2014 Memory layer for AI coding agents
5559
5794
  `);
5560
5795
  console.log("Usage:");
5796
+ console.log(" engrm serve Run the MCP server over stdio");
5561
5797
  console.log(" engrm init Setup via browser (recommended)");
5562
5798
  console.log(" engrm init --token=cmt_xxx Setup from provisioning token");
5563
5799
  console.log(" engrm init --pack=<name> Setup + install a starter pack");
@@ -419,10 +419,11 @@ function readProjectConfigFile(directory) {
419
419
  }
420
420
  }
421
421
  function detectProject(directory) {
422
- const remoteUrl = getGitRemoteUrl(directory);
422
+ const resolvedDirectory = resolve(directory);
423
+ const remoteUrl = getGitRemoteUrl(resolvedDirectory);
423
424
  if (remoteUrl) {
424
425
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
425
- const repoRoot = getGitTopLevel(directory) ?? directory;
426
+ const repoRoot = getGitTopLevel(resolvedDirectory) ?? resolvedDirectory;
426
427
  return {
427
428
  canonical_id: canonicalId,
428
429
  name: projectNameFromCanonicalId(canonicalId),
@@ -430,21 +431,22 @@ function detectProject(directory) {
430
431
  local_path: repoRoot
431
432
  };
432
433
  }
433
- const configFile = readProjectConfigFile(directory);
434
+ const configFile = readProjectConfigFile(resolvedDirectory);
434
435
  if (configFile) {
435
436
  return {
436
437
  canonical_id: configFile.project_id,
437
438
  name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
438
439
  remote_url: null,
439
- local_path: directory
440
+ local_path: resolvedDirectory
440
441
  };
441
442
  }
442
- const dirName = basename(directory);
443
+ const dirName = basename(resolvedDirectory);
444
+ const safeDirName = !dirName || dirName === "/" || dirName === "." ? "root" : dirName;
443
445
  return {
444
- canonical_id: `local/${dirName}`,
445
- name: dirName,
446
+ canonical_id: `local/${safeDirName}`,
447
+ name: safeDirName,
446
448
  remote_url: null,
447
- local_path: directory
449
+ local_path: resolvedDirectory
448
450
  };
449
451
  }
450
452
  function detectProjectForPath(filePath, fallbackCwd) {
@@ -1752,6 +1754,7 @@ function ensureObservationTypes(db) {
1752
1754
  DROP TABLE observations;
1753
1755
  ALTER TABLE observations_repair RENAME TO observations;
1754
1756
  CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
1757
+ CREATE INDEX IF NOT EXISTS idx_observations_project_lifecycle ON observations(project_id, lifecycle);
1755
1758
  CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
1756
1759
  CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
1757
1760
  CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
@@ -2032,6 +2035,7 @@ class MemDatabase {
2032
2035
  this.db = openDatabase(dbPath);
2033
2036
  this.db.exec("PRAGMA journal_mode = WAL");
2034
2037
  this.db.exec("PRAGMA foreign_keys = ON");
2038
+ this.db.exec("PRAGMA busy_timeout = 5000");
2035
2039
  this.vecAvailable = this.loadVecExtension();
2036
2040
  runMigrations(this.db);
2037
2041
  ensureObservationTypes(this.db);
@@ -2053,8 +2057,16 @@ class MemDatabase {
2053
2057
  this.db.close();
2054
2058
  }
2055
2059
  upsertProject(project) {
2060
+ const canonicalId = project.canonical_id?.trim();
2061
+ const name = project.name?.trim();
2062
+ if (!canonicalId) {
2063
+ throw new Error("Project canonical_id is required");
2064
+ }
2065
+ if (!name) {
2066
+ throw new Error("Project name is required");
2067
+ }
2056
2068
  const now = Math.floor(Date.now() / 1000);
2057
- const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
2069
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId);
2058
2070
  if (existing) {
2059
2071
  this.db.query(`UPDATE projects SET
2060
2072
  local_path = COALESCE(?, local_path),
@@ -2069,7 +2081,7 @@ class MemDatabase {
2069
2081
  };
2070
2082
  }
2071
2083
  const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
2072
- VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
2084
+ VALUES (?, ?, ?, ?, ?, ?)`).run(canonicalId, name, project.local_path ?? null, project.remote_url ?? null, now, now);
2073
2085
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
2074
2086
  }
2075
2087
  getProjectByCanonicalId(canonicalId) {