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/server.js CHANGED
@@ -14409,6 +14409,7 @@ function ensureObservationTypes(db) {
14409
14409
  DROP TABLE observations;
14410
14410
  ALTER TABLE observations_repair RENAME TO observations;
14411
14411
  CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id);
14412
+ CREATE INDEX IF NOT EXISTS idx_observations_project_lifecycle ON observations(project_id, lifecycle);
14412
14413
  CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
14413
14414
  CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch);
14414
14415
  CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
@@ -14689,6 +14690,7 @@ class MemDatabase {
14689
14690
  this.db = openDatabase(dbPath);
14690
14691
  this.db.exec("PRAGMA journal_mode = WAL");
14691
14692
  this.db.exec("PRAGMA foreign_keys = ON");
14693
+ this.db.exec("PRAGMA busy_timeout = 5000");
14692
14694
  this.vecAvailable = this.loadVecExtension();
14693
14695
  runMigrations(this.db);
14694
14696
  ensureObservationTypes(this.db);
@@ -14710,8 +14712,16 @@ class MemDatabase {
14710
14712
  this.db.close();
14711
14713
  }
14712
14714
  upsertProject(project) {
14715
+ const canonicalId = project.canonical_id?.trim();
14716
+ const name = project.name?.trim();
14717
+ if (!canonicalId) {
14718
+ throw new Error("Project canonical_id is required");
14719
+ }
14720
+ if (!name) {
14721
+ throw new Error("Project name is required");
14722
+ }
14713
14723
  const now = Math.floor(Date.now() / 1000);
14714
- const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(project.canonical_id);
14724
+ const existing = this.db.query("SELECT * FROM projects WHERE canonical_id = ?").get(canonicalId);
14715
14725
  if (existing) {
14716
14726
  this.db.query(`UPDATE projects SET
14717
14727
  local_path = COALESCE(?, local_path),
@@ -14726,7 +14736,7 @@ class MemDatabase {
14726
14736
  };
14727
14737
  }
14728
14738
  const result = this.db.query(`INSERT INTO projects (canonical_id, name, local_path, remote_url, first_seen_epoch, last_active_epoch)
14729
- VALUES (?, ?, ?, ?, ?, ?)`).run(project.canonical_id, project.name, project.local_path ?? null, project.remote_url ?? null, now, now);
14739
+ VALUES (?, ?, ?, ?, ?, ?)`).run(canonicalId, name, project.local_path ?? null, project.remote_url ?? null, now, now);
14730
14740
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(Number(result.lastInsertRowid));
14731
14741
  }
14732
14742
  getProjectByCanonicalId(canonicalId) {
@@ -15822,10 +15832,11 @@ function readProjectConfigFile(directory) {
15822
15832
  }
15823
15833
  }
15824
15834
  function detectProject(directory) {
15825
- const remoteUrl = getGitRemoteUrl(directory);
15835
+ const resolvedDirectory = resolve(directory);
15836
+ const remoteUrl = getGitRemoteUrl(resolvedDirectory);
15826
15837
  if (remoteUrl) {
15827
15838
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
15828
- const repoRoot = getGitTopLevel(directory) ?? directory;
15839
+ const repoRoot = getGitTopLevel(resolvedDirectory) ?? resolvedDirectory;
15829
15840
  return {
15830
15841
  canonical_id: canonicalId,
15831
15842
  name: projectNameFromCanonicalId(canonicalId),
@@ -15833,21 +15844,22 @@ function detectProject(directory) {
15833
15844
  local_path: repoRoot
15834
15845
  };
15835
15846
  }
15836
- const configFile = readProjectConfigFile(directory);
15847
+ const configFile = readProjectConfigFile(resolvedDirectory);
15837
15848
  if (configFile) {
15838
15849
  return {
15839
15850
  canonical_id: configFile.project_id,
15840
15851
  name: configFile.name ?? projectNameFromCanonicalId(configFile.project_id),
15841
15852
  remote_url: null,
15842
- local_path: directory
15853
+ local_path: resolvedDirectory
15843
15854
  };
15844
15855
  }
15845
- const dirName = basename(directory);
15856
+ const dirName = basename(resolvedDirectory);
15857
+ const safeDirName = !dirName || dirName === "/" || dirName === "." ? "root" : dirName;
15846
15858
  return {
15847
- canonical_id: `local/${dirName}`,
15848
- name: dirName,
15859
+ canonical_id: `local/${safeDirName}`,
15860
+ name: safeDirName,
15849
15861
  remote_url: null,
15850
- local_path: directory
15862
+ local_path: resolvedDirectory
15851
15863
  };
15852
15864
  }
15853
15865
  function detectProjectForPath(filePath, fallbackCwd) {
@@ -17537,7 +17549,19 @@ function formatTimestamp(nowMs) {
17537
17549
  return `${yyyy}-${mm}-${dd} ${hh}:${mi}Z`;
17538
17550
  }
17539
17551
  function compactLine(value) {
17540
- const trimmed = value?.replace(/\s+/g, " ").trim();
17552
+ if (value === null || value === undefined)
17553
+ return null;
17554
+ let text;
17555
+ if (typeof value === "string") {
17556
+ text = value;
17557
+ } else {
17558
+ try {
17559
+ text = JSON.stringify(value);
17560
+ } catch {
17561
+ text = String(value);
17562
+ }
17563
+ }
17564
+ const trimmed = text.replace(/\s+/g, " ").trim();
17541
17565
  if (!trimmed)
17542
17566
  return null;
17543
17567
  return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
@@ -21217,11 +21241,12 @@ function getPendingEntries(db, limit = 50) {
21217
21241
  LIMIT ?`).all(now, limit);
21218
21242
  }
21219
21243
  function markSyncing(db, entryId) {
21220
- db.db.query("UPDATE sync_outbox SET status = 'syncing' WHERE id = ?").run(entryId);
21244
+ const now = Math.floor(Date.now() / 1000);
21245
+ db.db.query("UPDATE sync_outbox SET status = 'syncing', next_retry_epoch = ? WHERE id = ?").run(now, entryId);
21221
21246
  }
21222
21247
  function markSynced(db, entryId) {
21223
21248
  const now = Math.floor(Date.now() / 1000);
21224
- db.db.query("UPDATE sync_outbox SET status = 'synced', synced_at_epoch = ? WHERE id = ?").run(now, entryId);
21249
+ db.db.query("UPDATE sync_outbox SET status = 'synced', synced_at_epoch = ?, next_retry_epoch = NULL, last_error = NULL WHERE id = ?").run(now, entryId);
21225
21250
  }
21226
21251
  function markFailed(db, entryId, error48) {
21227
21252
  const now = Math.floor(Date.now() / 1000);
@@ -21245,6 +21270,74 @@ function getOutboxStats(db) {
21245
21270
  }
21246
21271
  return stats;
21247
21272
  }
21273
+ function getOutboxFailureSummaries(db, limit = 5) {
21274
+ return db.db.query(`SELECT COALESCE(last_error, '') as error, COUNT(*) as count
21275
+ FROM sync_outbox
21276
+ WHERE status = 'failed'
21277
+ GROUP BY COALESCE(last_error, '')
21278
+ ORDER BY count DESC, error ASC
21279
+ LIMIT ?`).all(limit).filter((row) => row.error.length > 0);
21280
+ }
21281
+ function classifyOutboxFailure(error48) {
21282
+ const normalized = error48.toLowerCase();
21283
+ if (normalized.includes("401") || normalized.includes("invalid or missing credentials")) {
21284
+ return "auth";
21285
+ }
21286
+ if (normalized.includes("429") || normalized.includes("rate limit")) {
21287
+ return "rate_limit";
21288
+ }
21289
+ if (normalized.includes("timeout") || normalized.includes("abort")) {
21290
+ return "timeout";
21291
+ }
21292
+ if (normalized.includes("network") || normalized.includes("fetch") || normalized.includes("econn")) {
21293
+ return "network";
21294
+ }
21295
+ if (normalized.includes("400") || normalized.includes("422") || normalized.includes("validation")) {
21296
+ return "validation";
21297
+ }
21298
+ return "other";
21299
+ }
21300
+ function resetFailedEntries(db) {
21301
+ const result = db.db.query(`UPDATE sync_outbox
21302
+ SET status = 'pending',
21303
+ retry_count = 0,
21304
+ last_error = NULL,
21305
+ next_retry_epoch = NULL
21306
+ WHERE status = 'failed'`).run();
21307
+ return result.changes;
21308
+ }
21309
+ function resetFailedEntriesMatching(db, predicate) {
21310
+ const rows = db.db.query(`SELECT id, last_error
21311
+ FROM sync_outbox
21312
+ WHERE status = 'failed'`).all();
21313
+ const matchingIds = rows.filter((row) => row.last_error && predicate(row.last_error)).map((row) => row.id);
21314
+ if (matchingIds.length === 0)
21315
+ return 0;
21316
+ const placeholders = matchingIds.map(() => "?").join(", ");
21317
+ const result = db.db.query(`UPDATE sync_outbox
21318
+ SET status = 'pending',
21319
+ retry_count = 0,
21320
+ last_error = NULL,
21321
+ next_retry_epoch = NULL
21322
+ WHERE id IN (${placeholders})`).run(...matchingIds);
21323
+ return result.changes;
21324
+ }
21325
+ function resetSyncingEntries(db) {
21326
+ const result = db.db.query(`UPDATE sync_outbox
21327
+ SET status = 'pending',
21328
+ next_retry_epoch = NULL
21329
+ WHERE status = 'syncing'`).run();
21330
+ return result.changes;
21331
+ }
21332
+ function resetStaleSyncingEntries(db, maxAgeSeconds = 300) {
21333
+ const cutoff = Math.floor(Date.now() / 1000) - maxAgeSeconds;
21334
+ const result = db.db.query(`UPDATE sync_outbox
21335
+ SET status = 'pending',
21336
+ next_retry_epoch = NULL
21337
+ WHERE status = 'syncing'
21338
+ AND (next_retry_epoch IS NULL OR next_retry_epoch <= ?)`).run(cutoff);
21339
+ return result.changes;
21340
+ }
21248
21341
 
21249
21342
  // src/intelligence/value-signals.ts
21250
21343
  var LESSON_TYPES = new Set(["bugfix", "decision", "pattern"]);
@@ -21381,7 +21474,12 @@ function getMemoryStats(db) {
21381
21474
  recent_completed: insights.recent_completed,
21382
21475
  next_steps: insights.next_steps,
21383
21476
  installed_packs: db.getInstalledPacks(),
21384
- outbox: getOutboxStats(db)
21477
+ outbox: getOutboxStats(db),
21478
+ outbox_failure_summary: getOutboxFailureSummaries(db).map((row) => ({
21479
+ category: classifyOutboxFailure(row.error),
21480
+ error: row.error,
21481
+ count: row.count
21482
+ }))
21385
21483
  };
21386
21484
  }
21387
21485
 
@@ -21564,6 +21662,7 @@ function isDue(db, key, interval, now) {
21564
21662
  }
21565
21663
 
21566
21664
  // src/sync/auth.ts
21665
+ import { createHash as createHash4 } from "node:crypto";
21567
21666
  var LEGACY_PUBLIC_HOSTS = new Set(["www.candengo.com", "candengo.com"]);
21568
21667
  function normalizeBaseUrl(url2) {
21569
21668
  const trimmed = url2.trim();
@@ -21594,6 +21693,36 @@ function getBaseUrl(config2) {
21594
21693
  }
21595
21694
  return null;
21596
21695
  }
21696
+ function getAuthFingerprint(config2) {
21697
+ const apiKey = getApiKey(config2);
21698
+ const baseUrl = getBaseUrl(config2);
21699
+ if (!apiKey || !baseUrl)
21700
+ return null;
21701
+ return createHash4("sha256").update(`${baseUrl}
21702
+ ${apiKey}
21703
+ ${config2.namespace}
21704
+ ${config2.site_id}`).digest("hex");
21705
+ }
21706
+ function recoverOutboxAfterAuthChange(db, config2) {
21707
+ const fingerprint = getAuthFingerprint(config2);
21708
+ if (!fingerprint) {
21709
+ const staleSyncingReset2 = resetStaleSyncingEntries(db);
21710
+ return { fingerprintChanged: false, failedReset: 0, authFailedReset: 0, syncingReset: 0, staleSyncingReset: staleSyncingReset2 };
21711
+ }
21712
+ const key = "sync_auth_fingerprint";
21713
+ const previous = db.getSyncState(key);
21714
+ const fingerprintChanged = previous !== fingerprint;
21715
+ if (!fingerprintChanged) {
21716
+ const authFailedReset = resetFailedEntriesMatching(db, (error48) => classifyOutboxFailure(error48) === "auth");
21717
+ const staleSyncingReset2 = resetStaleSyncingEntries(db);
21718
+ return { fingerprintChanged: false, failedReset: 0, authFailedReset, syncingReset: 0, staleSyncingReset: staleSyncingReset2 };
21719
+ }
21720
+ const failedReset = resetFailedEntries(db);
21721
+ const syncingReset = resetSyncingEntries(db);
21722
+ const staleSyncingReset = 0;
21723
+ db.setSyncState(key, fingerprint);
21724
+ return { fingerprintChanged: true, failedReset, authFailedReset: 0, syncingReset, staleSyncingReset };
21725
+ }
21597
21726
  function buildSourceId(config2, localId, type = "obs") {
21598
21727
  return `${config2.user_id}-${config2.device_id}-${type}-${localId}`;
21599
21728
  }
@@ -21782,7 +21911,19 @@ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolN
21782
21911
  return null;
21783
21912
  }
21784
21913
  function compactLine2(value) {
21785
- const trimmed = value?.replace(/\s+/g, " ").trim();
21914
+ if (value === null || value === undefined)
21915
+ return null;
21916
+ let text;
21917
+ if (typeof value === "string") {
21918
+ text = value;
21919
+ } else {
21920
+ try {
21921
+ text = JSON.stringify(value);
21922
+ } catch {
21923
+ text = String(value);
21924
+ }
21925
+ }
21926
+ const trimmed = text.replace(/\s+/g, " ").trim();
21786
21927
  if (!trimmed)
21787
21928
  return null;
21788
21929
  return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
@@ -22375,6 +22516,9 @@ async function pullSettings(client, config2) {
22375
22516
 
22376
22517
  // src/sync/engine.ts
22377
22518
  var DEFAULT_PULL_INTERVAL = 60000;
22519
+ function recordNowEpoch(db, key) {
22520
+ db.setSyncState(key, String(Math.floor(Date.now() / 1000)));
22521
+ }
22378
22522
 
22379
22523
  class SyncEngine {
22380
22524
  db;
@@ -22433,7 +22577,11 @@ class SyncEngine {
22433
22577
  return;
22434
22578
  this._pushing = true;
22435
22579
  try {
22436
- await pushOutbox(this.db, this.config, this.config.sync.batch_size);
22580
+ recoverOutboxAfterAuthChange(this.db, this.config);
22581
+ const result = await pushOutbox(this.db, this.config, this.config.sync.batch_size);
22582
+ if (result.pushed > 0) {
22583
+ recordNowEpoch(this.db, "last_push_epoch");
22584
+ }
22437
22585
  } finally {
22438
22586
  this._pushing = false;
22439
22587
  }
@@ -22443,11 +22591,16 @@ class SyncEngine {
22443
22591
  return;
22444
22592
  this._pulling = true;
22445
22593
  try {
22446
- await pullFromVector(this.db, this.client, this.config);
22594
+ const primary = await pullFromVector(this.db, this.client, this.config);
22595
+ let totalReceived = primary.received;
22447
22596
  if (this.fleetClient) {
22448
- await pullFromVector(this.db, this.fleetClient, this.config);
22597
+ const fleet = await pullFromVector(this.db, this.fleetClient, this.config);
22598
+ totalReceived += fleet.received;
22449
22599
  }
22450
22600
  await pullSettings(this.client, this.config);
22601
+ if (totalReceived > 0) {
22602
+ recordNowEpoch(this.db, "last_pull_epoch");
22603
+ }
22451
22604
  } finally {
22452
22605
  this._pulling = false;
22453
22606
  }
@@ -23103,20 +23256,41 @@ function resolveAgentName(clientName) {
23103
23256
  return clientName;
23104
23257
  }
23105
23258
  var syncEngine = null;
23106
- process.on("SIGINT", () => {
23107
- syncEngine?.stop();
23108
- db.close();
23109
- process.exit(0);
23110
- });
23111
- process.on("SIGTERM", () => {
23259
+ var shuttingDown = false;
23260
+ function shutdown(code = 0) {
23261
+ if (shuttingDown)
23262
+ return;
23263
+ shuttingDown = true;
23112
23264
  syncEngine?.stop();
23113
23265
  db.close();
23114
- process.exit(0);
23115
- });
23266
+ process.exit(code);
23267
+ }
23268
+ process.on("SIGINT", () => shutdown(0));
23269
+ process.on("SIGTERM", () => shutdown(0));
23270
+ function installStdioLivenessGuards() {
23271
+ process.stdin.on("end", () => shutdown(0));
23272
+ process.stdin.on("close", () => shutdown(0));
23273
+ process.stdin.on("error", () => shutdown(0));
23274
+ process.stdin.resume();
23275
+ const parentPid = process.ppid;
23276
+ if (!Number.isInteger(parentPid) || parentPid <= 1)
23277
+ return;
23278
+ const heartbeat = setInterval(() => {
23279
+ try {
23280
+ process.kill(parentPid, 0);
23281
+ } catch {
23282
+ shutdown(0);
23283
+ }
23284
+ if (process.ppid === 1) {
23285
+ shutdown(0);
23286
+ }
23287
+ }, 30000);
23288
+ heartbeat.unref();
23289
+ }
23116
23290
  function buildServer() {
23117
23291
  const server = new McpServer({
23118
23292
  name: "engrm",
23119
- version: "0.4.42"
23293
+ version: "0.4.45"
23120
23294
  });
23121
23295
  const enabledToolNames = getEnabledToolNames(config2.tool_profile);
23122
23296
  const originalTool = server.tool.bind(server);
@@ -25127,6 +25301,7 @@ async function main() {
25127
25301
  await startHttpServer();
25128
25302
  return;
25129
25303
  }
25304
+ installStdioLivenessGuards();
25130
25305
  const transport = new StdioServerTransport;
25131
25306
  await server.connect(transport);
25132
25307
  }
@@ -31,12 +31,12 @@ The MCP entry written by the helper script matches:
31
31
  {
32
32
  "$schema": "https://opencode.ai/config.json",
33
33
  "mcp": {
34
- "engrm": {
35
- "type": "local",
36
- "command": ["engrm", "serve"],
37
- "enabled": true,
38
- "timeout": 5000
39
- }
34
+ "engrm": {
35
+ "type": "local",
36
+ "command": ["node", "/absolute/path/to/engrm/dist/server.js"],
37
+ "enabled": true,
38
+ "timeout": 5000
39
+ }
40
40
  }
41
41
  }
42
42
  ```
@@ -13,6 +13,12 @@ import os
13
13
  root = Path(os.environ["ENGRM_OPENCODE_SCRIPT_DIR"]).resolve()
14
14
  repo = root.parent
15
15
  plugin_source = repo / "opencode" / "plugin" / "engrm-opencode.js"
16
+ runtime = shutil.which("node") or shutil.which("bun") or "node"
17
+ dist_server = repo / "dist" / "server.js"
18
+ src_server = repo / "src" / "server.ts"
19
+ command = [runtime, str(dist_server if dist_server.exists() else src_server)]
20
+ if not dist_server.exists() and command[0].endswith("bun"):
21
+ command = [runtime, "run", str(src_server)]
16
22
  config_dir = Path.home() / ".config" / "opencode"
17
23
  plugins_dir = config_dir / "plugins"
18
24
  config_path = config_dir / "opencode.json"
@@ -32,7 +38,7 @@ config.setdefault("$schema", "https://opencode.ai/config.json")
32
38
  mcp = config.setdefault("mcp", {})
33
39
  mcp["engrm"] = {
34
40
  "type": "local",
35
- "command": ["engrm", "serve"],
41
+ "command": command,
36
42
  "enabled": True,
37
43
  "timeout": 5000,
38
44
  }
@@ -4,8 +4,8 @@
4
4
  "engrm": {
5
5
  "type": "local",
6
6
  "command": [
7
- "engrm",
8
- "serve"
7
+ "node",
8
+ "/absolute/path/to/engrm/dist/server.js"
9
9
  ],
10
10
  "enabled": true,
11
11
  "timeout": 5000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engrm",
3
- "version": "0.4.43",
3
+ "version": "0.4.45",
4
4
  "description": "Shared memory across devices, sessions, and agents, with thin MCP tools for durable capture, live continuity, and Hermes-ready remote MCP support",
5
5
  "mcpName": "io.github.dr12hes/engrm",
6
6
  "type": "module",