@inetafrica/open-claudia 3.0.43 β†’ 3.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +9 -5
  3. package/bin/tool.js +15 -5
  4. package/bot.js +78 -3
  5. package/core/boot-health.js +84 -0
  6. package/core/doctor-memory.js +213 -0
  7. package/core/doctor.js +5 -0
  8. package/core/dream.js +11 -9
  9. package/core/entities.js +47 -3
  10. package/core/handlers.js +118 -1
  11. package/core/keyring.js +24 -4
  12. package/core/memory-mutation-queue.js +42 -11
  13. package/core/pack-review.js +60 -4
  14. package/core/packs.js +47 -3
  15. package/core/providers/claude-events.js +36 -3
  16. package/core/providers/events.js +11 -0
  17. package/core/recall/discoverer.js +22 -6
  18. package/core/recall/graph.js +84 -52
  19. package/core/recall/tuning.js +1 -2
  20. package/core/router.js +18 -0
  21. package/core/run-context.js +10 -4
  22. package/core/runner.js +111 -2
  23. package/core/safe-mode.js +227 -0
  24. package/core/state.js +50 -2
  25. package/core/system-prompt.js +43 -3
  26. package/core/tool-graph.js +99 -71
  27. package/core/tools.js +3 -2
  28. package/core/turn-observer.js +23 -1
  29. package/package.json +3 -3
  30. package/test-extractive-injection.js +21 -0
  31. package/test-fixtures/fake-agent-cli.js +29 -1
  32. package/test-fixtures/sim-no-sqlite.js +14 -0
  33. package/test-memory-mutation-queue.js +33 -3
  34. package/test-provider-compaction.js +5 -0
  35. package/test-provider-core-prompt.js +3 -0
  36. package/test-provider-events.js +71 -0
  37. package/test-provider-language.js +61 -1
  38. package/test-provider-recall.js +33 -0
  39. package/test-provider-runner.js +50 -0
  40. package/test-recall-discoverer.js +8 -0
  41. package/test-recall-graph.js +66 -11
  42. package/test-tool-graph.js +67 -16
  43. package/test-tool-manifest.js +32 -0
  44. package/test-tools.js +31 -0
package/core/dream.js CHANGED
@@ -1221,13 +1221,14 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1221
1221
  }
1222
1222
  }
1223
1223
 
1224
- // Recall-graph maintenance: refresh structural edges, decay reinforced
1225
- // weights, prune orphans. Deterministic + safe, runs every dream.
1224
+ // Recall-graph maintenance: refresh structural edges, prune orphans.
1225
+ // Weight decay is read-time (graph.effectiveWeight) β€” nothing to rewrite
1226
+ // nightly. Deterministic + safe, runs every dream.
1226
1227
  let graphNote = "";
1227
1228
  try {
1228
- const g = recallGraphLib.tend(packs, entities, { halfLifeDays: recallTuning.get("decayHalfLifeDays") });
1229
- if (g.synced || g.decayed || g.pruned) {
1230
- graphNote = `πŸ•Έ Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, decayed ${g.decayed}, pruned ${g.pruned}).`;
1229
+ const g = recallGraphLib.tend(packs, entities);
1230
+ if (g.synced || g.pruned) {
1231
+ graphNote = `πŸ•Έ Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, pruned ${g.pruned}).`;
1231
1232
  }
1232
1233
  } catch (e) { /* graph is best-effort */ }
1233
1234
 
@@ -1241,8 +1242,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1241
1242
  }
1242
1243
  } catch (e) { /* episodic index is best-effort */ }
1243
1244
 
1244
- // Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
1245
- // prune faded/orphaned ones) and flag tools whose --pack link now dangles.
1245
+ // Tool hygiene: tend the directed tool-graph (prune faded/orphaned
1246
+ // follows-edges β€” decay itself is read-time) and flag tools whose --pack
1247
+ // link now dangles.
1246
1248
  // Read-only toward the tools themselves β€” a tool is never auto-deleted (that's
1247
1249
  // destructive); we just surface the breakage for the user to re-link or remove.
1248
1250
  let toolNote = "";
@@ -1258,8 +1260,8 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1258
1260
  const capList = (items, sep = ", ", max = 5) => (items.length <= max
1259
1261
  ? items.join(sep)
1260
1262
  : `${items.slice(0, max).join(sep)} …and ${items.length - max} more (full list in the dream log)`);
1261
- if (tg.edges || tg.decayed || tg.pruned) {
1262
- push(`πŸ”§ Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
1263
+ if (tg.edges || tg.pruned) {
1264
+ push(`πŸ”§ Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (pruned ${tg.pruned}).`);
1263
1265
  }
1264
1266
  const allTools = toolsLib.listTools();
1265
1267
  const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
package/core/entities.js CHANGED
@@ -264,7 +264,7 @@ let _db = null;
264
264
  let _indexedAt = 0;
265
265
  let _dirty = true;
266
266
 
267
- function markIndexDirty() { _dirty = true; }
267
+ function markIndexDirty() { _dirty = true; _lexicalDocsAt = -1; }
268
268
 
269
269
  function openDb() {
270
270
  if (!DatabaseSync) return null;
@@ -350,16 +350,60 @@ function queryTerms(text) {
350
350
  return terms;
351
351
  }
352
352
 
353
+ // Pure-JS floor under matchEntities for substrates without node:sqlite β€”
354
+ // same rationale and scoring as the packs floor. Strong is name+aliases
355
+ // only (description scores as body, mirroring the FTS strong filter).
356
+ function termVariants(t) {
357
+ for (const suffix of ["ing", "ed", "es", "s"]) {
358
+ if (t.length > suffix.length + 2 && t.endsWith(suffix)) return [t, t.slice(0, -suffix.length)];
359
+ }
360
+ return [t];
361
+ }
362
+
363
+ let _lexicalDocs = null;
364
+ let _lexicalDocsAt = -1;
365
+
366
+ function lexicalDocs() {
367
+ const mtime = latestMtime();
368
+ if (_lexicalDocs && _lexicalDocsAt === mtime) return _lexicalDocs;
369
+ _lexicalDocs = listEntities().map((e) => ({
370
+ slug: e.slug,
371
+ name: e.name,
372
+ strong: [e.name, e.aliases.join(" ")].join("\n").toLowerCase(),
373
+ body: [e.description, e.sections.Notes, e.sections.Log, e.sections.Role, e.sections.Style, e.sections.Knows]
374
+ .filter(Boolean).join("\n").toLowerCase(),
375
+ }));
376
+ _lexicalDocsAt = mtime;
377
+ return _lexicalDocs;
378
+ }
379
+
380
+ function matchEntitiesLexical(text, { limit = 4, threshold = null } = {}) {
381
+ const terms = queryTerms(text);
382
+ if (terms.length === 0) return [];
383
+ const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
384
+ const scored = [];
385
+ for (const doc of lexicalDocs()) {
386
+ let score = 0;
387
+ for (const t of terms) {
388
+ const variants = termVariants(t);
389
+ if (variants.some((v) => doc.strong.includes(v))) score += 2;
390
+ else if (variants.some((v) => doc.body.includes(v))) score += 1;
391
+ }
392
+ if (score >= minScore) scored.push({ slug: doc.slug, name: doc.name, score });
393
+ }
394
+ return scored.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)));
395
+ }
396
+
353
397
  function matchEntities(text, { limit = 4, threshold = null } = {}) {
354
398
  const db = openDb();
355
- if (!db) return [];
399
+ if (!db) return matchEntitiesLexical(text, { limit, threshold });
356
400
  ensureIndex();
357
401
  const terms = queryTerms(text);
358
402
  if (terms.length === 0) return [];
359
403
  const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
360
404
  const scores = new Map();
361
405
  let stmt;
362
- try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return []; }
406
+ try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return matchEntitiesLexical(text, { limit, threshold }); }
363
407
  for (const t of terms) {
364
408
  const quoted = `"${t.replace(/"/g, '""')}"`;
365
409
  let strong = [];
package/core/handlers.js CHANGED
@@ -375,7 +375,7 @@ register({
375
375
  "Connect: /login (pick model + sign in)",
376
376
  "Claude auth: /auth_status /setup_token /use_oauth_token /clear_oauth_token",
377
377
  "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
378
- "System: /doctor /requirements /restart /upgrade",
378
+ "System: /doctor /requirements /restart /upgrade /downgrade /safemode",
379
379
  "",
380
380
  "Send text, voice, photos, or files.",
381
381
  "Reply to any message for context.",
@@ -693,6 +693,8 @@ register({
693
693
  // can be SIGTERM'd at any moment, and an unsent ack dies with us.
694
694
  await send("Upgrade requested β€” AgentSpace will pull latest image and restart, see you in a minute.");
695
695
  recordUpgradeRequested();
696
+ try { require("./boot-health").recordPendingDoctor({ reason: "AgentSpace image rollout", from: CURRENT_VERSION }); } catch (e) {}
697
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
696
698
  try {
697
699
  const result = await requestAgentSpaceUpgrade();
698
700
  if (result && (result.status === 202 || result.status === 200)) {
@@ -762,6 +764,8 @@ register({
762
764
  const tailNote = "Source refreshed in /app. If this release also changed the Dockerfile (apt packages, env, base image), the host still needs a docker pull + recreate for those.";
763
765
  const msg = `Installed v${newVersion}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\n${tailNote}\n\nRestarting...`;
764
766
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
767
+ try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
768
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
765
769
  try { require("./restart-reason").recordExitReason(`User /upgrade β†’ v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
766
770
  setTimeout(() => process.exit(0), 2000);
767
771
  return;
@@ -810,6 +814,8 @@ register({
810
814
  const doctorReport = formatDoctorReport(runDoctorChecks());
811
815
  const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
812
816
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
817
+ try { require("./boot-health").recordPendingDoctor({ reason: "npm upgrade", from: CURRENT_VERSION, to: newPkg.version }); } catch (e) {}
818
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
813
819
  try { require("./restart-reason").recordExitReason(`User /upgrade β†’ v${newPkg.version}`, { code: 0 }); } catch (e) {}
814
820
  } catch (e) {
815
821
  const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
@@ -820,6 +826,115 @@ register({
820
826
  },
821
827
  });
822
828
 
829
+ // Roll back to an earlier release. The rescue half of /upgrade: a bad release
830
+ // that crash-loops lands in safe mode, and this is the chat-side way back to
831
+ // a version that worked. No arg β‡’ the previously-running version from boot
832
+ // history. Deliberately skips the AgentSpace control-plane path (it can only
833
+ // pull :latest): on pods this overlays /app in place, which survives restarts
834
+ // but not a pod recreate β€” the permanent fix there is rolling the image.
835
+ register({
836
+ name: "downgrade", description: "Roll back to a previous version", args: "[version]", ownerOnly: true,
837
+ handler: async (env, { args }) => {
838
+ if (!ownerEnv(env)) return;
839
+ const safeModeLib = require("./safe-mode");
840
+ let target = (args[0] || "").trim().replace(/^v/i, "");
841
+ if (!target) {
842
+ target = safeModeLib.previousVersion(CURRENT_VERSION);
843
+ if (!target) {
844
+ await send("No earlier version in my boot history β€” tell me one explicitly, e.g. /downgrade 3.0.44");
845
+ return;
846
+ }
847
+ }
848
+ // The version lands in a shell command: accept strict semver only.
849
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(target)) {
850
+ await send(`That doesn't look like a version: "${target}". Use e.g. /downgrade 3.0.44`);
851
+ return;
852
+ }
853
+ if (target === CURRENT_VERSION) {
854
+ await send(`I'm already on v${CURRENT_VERSION}.`);
855
+ return;
856
+ }
857
+ const execEnv = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
858
+ let resolved = null;
859
+ try {
860
+ resolved = execSync(`npm view @inetafrica/open-claudia@${target} version`, {
861
+ encoding: "utf-8", timeout: 15000, env: execEnv,
862
+ }).trim().split("\n").filter(Boolean).pop() || null;
863
+ } catch (e) {
864
+ await send(`Could not verify v${target} on npm (${(e.message || String(e)).slice(0, 200)}).`);
865
+ return;
866
+ }
867
+ if (!resolved) {
868
+ await send(`v${target} does not exist on the npm registry.`);
869
+ return;
870
+ }
871
+ const direction = isNewerVersion(resolved, CURRENT_VERSION) ? "Moving" : "Rolling back";
872
+ if (isDockerAppLayout()) {
873
+ try { process.chdir("/tmp"); } catch (e) {}
874
+ const podNote = process.env.AGENTSPACE_POD_TOKEN
875
+ ? "\nNote: this is an in-place overlay β€” a pod recreate returns to the deployment image version. Roll the image for a permanent downgrade."
876
+ : "";
877
+ await send(`${direction} v${CURRENT_VERSION} β†’ v${resolved} (in-place tarball overlay into /app)...${podNote}`);
878
+ try {
879
+ inPlaceTarballUpgrade(resolved);
880
+ } catch (e) {
881
+ const errOutput = (e.stdout || e.stderr || e.message || String(e)).slice(-700);
882
+ await send(`Downgrade failed:\n${errOutput}`);
883
+ return;
884
+ }
885
+ await send(`Installed v${resolved}. Restarting...`);
886
+ try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
887
+ try { safeModeLib.clearCrashLoop(); } catch (e) {}
888
+ try { require("./restart-reason").recordExitReason(`User /downgrade β†’ v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
889
+ setTimeout(() => process.exit(0), 2000);
890
+ return;
891
+ }
892
+ try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
893
+ await send(`${direction} v${CURRENT_VERSION} β†’ v${resolved}...`);
894
+ try {
895
+ execSync(`npm install -g @inetafrica/open-claudia@${resolved} 2>&1`, {
896
+ encoding: "utf-8", timeout: 120000,
897
+ cwd: process.env.HOME || require("os").homedir(),
898
+ env: execEnv,
899
+ });
900
+ await send(`Installed v${resolved}. Going offline to restart...`);
901
+ try { require("./boot-health").recordPendingDoctor({ reason: "downgrade", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
902
+ try { safeModeLib.clearCrashLoop(); } catch (e) {}
903
+ try { require("./restart-reason").recordExitReason(`User /downgrade β†’ v${resolved}`, { code: 0 }); } catch (e) {}
904
+ } catch (e) {
905
+ const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
906
+ await send(`Downgrade failed:\n${errOutput}`);
907
+ return;
908
+ }
909
+ setTimeout(() => process.exit(0), 2000);
910
+ },
911
+ });
912
+
913
+ register({
914
+ name: "safemode", description: "Safe-mode status, or continue past it", args: "[continue]", ownerOnly: true,
915
+ handler: async (env, { args }) => {
916
+ if (!ownerEnv(env)) return;
917
+ const safeModeLib = require("./safe-mode");
918
+ const sub = (args[0] || "").toLowerCase();
919
+ if (sub === "continue") {
920
+ if (!safeModeLib.isSafeMode()) {
921
+ await send(`Running normally (v${CURRENT_VERSION}) β€” there's no safe mode to continue past.`);
922
+ return;
923
+ }
924
+ safeModeLib.requestContinue();
925
+ await send("Override armed: the next boot runs normally even if the substrate is missing (keyword-only memory) and the crash-loop counter resets. Restarting now...");
926
+ try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
927
+ setTimeout(() => process.exit(0), 1500);
928
+ return;
929
+ }
930
+ if (safeModeLib.isSafeMode()) {
931
+ await send(safeModeLib.formatSafeModeAlert({ version: CURRENT_VERSION }));
932
+ } else {
933
+ await send(`Running normally (v${CURRENT_VERSION}) β€” safe mode not active.`);
934
+ }
935
+ },
936
+ });
937
+
823
938
  register({
824
939
  name: "spaces", description: "Control Kazee Spaces auto-replies (status/pause/resume/mode)",
825
940
  args: "<status | pause | resume | mode advisory|autonomous|off>", ownerOnly: true,
@@ -1488,7 +1603,9 @@ register({
1488
1603
  return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
1489
1604
  }).join(" Β· ")
1490
1605
  : "none";
1606
+ const safeModeLib = require("./safe-mode");
1491
1607
  send([
1608
+ ...(safeModeLib.isSafeMode() ? [`β›” SAFE MODE (${safeModeLib.bootInfo().reasons.join(", ")}) β€” /safemode for details`] : []),
1492
1609
  `Project: ${state.currentSession?.name || "none"}`,
1493
1610
  `Provider: ${provider?.label || "none"}`,
1494
1611
  `Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
package/core/keyring.js CHANGED
@@ -21,21 +21,41 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const CONFIG_DIR = require("../config-dir");
24
+ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
24
25
 
25
26
  const KEYRING_FILE = process.env.KEYRING_FILE || path.join(CONFIG_DIR, "keyring.json");
26
27
 
28
+ let warnedCorrupt = false;
29
+
27
30
  function load() {
31
+ let raw;
28
32
  try {
29
- const data = JSON.parse(fs.readFileSync(KEYRING_FILE, "utf-8"));
33
+ raw = fs.readFileSync(KEYRING_FILE, "utf-8");
34
+ } catch (e) {
35
+ return {}; // no keyring yet β€” fine
36
+ }
37
+ try {
38
+ const data = JSON.parse(raw);
30
39
  return data && typeof data === "object" ? data : {};
31
40
  } catch (e) {
32
- return {}; // missing or corrupt β†’ empty keyring
41
+ // File exists but is unparseable: fall back to the last-good .bak snapshot
42
+ // rather than silently running credential-less, and say so loudly β€” stderr
43
+ // reaches the bot log, and from `tool run` it lands in the agent's
44
+ // transcript so the failure surfaces to the owner instead of vanishing.
45
+ const data = readJsonWithFallback(KEYRING_FILE, null);
46
+ if (!warnedCorrupt) {
47
+ warnedCorrupt = true;
48
+ console.error(`[keyring] ${KEYRING_FILE} is corrupt (${e.message}); ${
49
+ data ? "using the last-good .bak snapshot" : "NO usable backup β€” tools run WITHOUT credentials until it is restored"}.`);
50
+ }
51
+ return data && typeof data === "object" ? data : {};
33
52
  }
34
53
  }
35
54
 
36
55
  function save(data) {
37
- fs.writeFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n");
38
- try { fs.chmodSync(KEYRING_FILE, 0o600); } catch (e) { /* best effort on odd filesystems */ }
56
+ // Atomic, 0600 from birth (no torn-file window, no chmod-after-write gap),
57
+ // with a last-good .bak snapshot that load() falls back to on corruption.
58
+ atomicWriteFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n", { backup: true, mode: 0o600 });
39
59
  }
40
60
 
41
61
  // Full {key: value} map β€” used to inject creds into subprocess env.
@@ -8,8 +8,17 @@ const CONFIG_DIR = require("../config-dir");
8
8
  const DEFAULT_MAX_PENDING = 50;
9
9
  const DEFAULT_LOCK_TIMEOUT_MS = 30 * 1000;
10
10
  const DEFAULT_LOCK_POLL_MS = 25;
11
- const DEFAULT_STALE_LOCK_MS = 30 * 60 * 1000;
12
11
  const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
12
+ // A lock dir whose owner.json never landed (crash inside the mkdir→write
13
+ // window) is an orphan once it outlives this grace.
14
+ const ORPHAN_LOCK_GRACE_MS = 5000;
15
+
16
+ // Distinguishes process INCARNATIONS sharing a pid. In a container the bot is
17
+ // pid 1 and a restarted bot is ALSO pid 1, so pid-liveness alone made a lock
18
+ // left by a crashed incarnation look held forever β€” every writer then timed
19
+ // out (measured on a pod with 43 restarts: 87% of reviewer runs failed with
20
+ // MEMORY_MUTATION_LOCK_TIMEOUT and pack capture starved for 16 days).
21
+ const BOOT_ID = crypto.randomBytes(8).toString("hex");
13
22
 
14
23
  class MutationQueueError extends Error {
15
24
  constructor(code, message, details = {}) {
@@ -44,20 +53,33 @@ function safeMetadata(metadata) {
44
53
  return result;
45
54
  }
46
55
 
47
- function ownerIsAlive(lockDir) {
48
- let owner;
49
- try { owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")); }
50
- catch (_) { return false; }
51
- const pid = Number(owner?.pid);
52
- if (!Number.isSafeInteger(pid) || pid < 1) return false;
56
+ function readOwner(lockDir) {
57
+ try { return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")); }
58
+ catch (_) { return null; }
59
+ }
60
+
61
+ function pidLooksAlive(pid) {
62
+ const n = Number(pid);
63
+ if (!Number.isSafeInteger(n) || n < 1) return false;
53
64
  try {
54
- process.kill(pid, 0);
65
+ process.kill(n, 0);
55
66
  return true;
56
67
  } catch (error) {
57
68
  return error.code === "EPERM";
58
69
  }
59
70
  }
60
71
 
72
+ // Is a held lock safe to break? Never for a live holder; immediately for a
73
+ // dead one. owner.pid === our pid means either ourselves (same bootId β€” never
74
+ // break) or a previous incarnation that reused our pid (container pid-1 case β€”
75
+ // definitionally dead, break at once).
76
+ function lockIsStale(lockDir, ageMs) {
77
+ const owner = readOwner(lockDir);
78
+ if (!owner) return ageMs > ORPHAN_LOCK_GRACE_MS;
79
+ if (Number(owner.pid) === process.pid) return owner.bootId !== BOOT_ID;
80
+ return !pidLooksAlive(owner.pid);
81
+ }
82
+
61
83
  class MemoryMutationQueue {
62
84
  constructor(options = {}) {
63
85
  this.lockDir = options.lockDir || path.join(CONFIG_DIR, "memory-mutation.lock");
@@ -65,7 +87,6 @@ class MemoryMutationQueue {
65
87
  this.maxPending = positiveInteger(options.maxPending, DEFAULT_MAX_PENDING, "maxPending");
66
88
  this.lockTimeoutMs = positiveInteger(options.lockTimeoutMs, DEFAULT_LOCK_TIMEOUT_MS, "lockTimeoutMs");
67
89
  this.lockPollMs = positiveInteger(options.lockPollMs, DEFAULT_LOCK_POLL_MS, "lockPollMs");
68
- this.staleLockMs = positiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS, "staleLockMs");
69
90
  this.accepting = true;
70
91
  this.stopReason = null;
71
92
  this.pending = new Map();
@@ -93,7 +114,7 @@ class MemoryMutationQueue {
93
114
  try {
94
115
  fs.mkdirSync(this.lockDir, { recursive: false, mode: 0o700 });
95
116
  try {
96
- fs.writeFileSync(path.join(this.lockDir, "owner.json"), `${JSON.stringify({ token, pid: process.pid, id: entry.id, at: new Date().toISOString() })}\n`, { mode: 0o600 });
117
+ fs.writeFileSync(path.join(this.lockDir, "owner.json"), `${JSON.stringify({ token, pid: process.pid, bootId: BOOT_ID, id: entry.id, at: new Date().toISOString() })}\n`, { mode: 0o600 });
97
118
  } catch (error) {
98
119
  try { fs.rmSync(this.lockDir, { recursive: true, force: true }); } catch (_) {}
99
120
  throw error;
@@ -107,7 +128,7 @@ class MemoryMutationQueue {
107
128
 
108
129
  try {
109
130
  const age = Date.now() - fs.statSync(this.lockDir).mtimeMs;
110
- if (age > this.staleLockMs && !ownerIsAlive(this.lockDir)) {
131
+ if (lockIsStale(this.lockDir, age)) {
111
132
  fs.rmSync(this.lockDir, { recursive: true, force: true });
112
133
  }
113
134
  } catch (_) { /* another process may have released it */ }
@@ -230,6 +251,15 @@ class MemoryMutationQueue {
230
251
  }
231
252
  }
232
253
 
254
+ // Read-only view of the shared lock for health surfaces (/doctor).
255
+ function inspectLock(lockDir) {
256
+ const dir = lockDir || path.join(CONFIG_DIR, "memory-mutation.lock");
257
+ let stat;
258
+ try { stat = fs.statSync(dir); } catch (_) { return { held: false }; }
259
+ const ageMs = Date.now() - stat.mtimeMs;
260
+ return { held: true, ageMs, owner: readOwner(dir), stale: lockIsStale(dir, ageMs) };
261
+ }
262
+
233
263
  const memoryMutationQueue = new MemoryMutationQueue();
234
264
 
235
265
  module.exports = {
@@ -238,4 +268,5 @@ module.exports = {
238
268
  MemoryMutationQueue,
239
269
  MutationQueueError,
240
270
  memoryMutationQueue,
271
+ inspectLock,
241
272
  };
@@ -7,8 +7,12 @@
7
7
  // rule.
8
8
 
9
9
  const crypto = require("crypto");
10
+ const fs = require("fs");
11
+ const path = require("path");
10
12
  const { spawnUtilityAgent, cancelUtilityAgents } = require("./utility-agent");
11
13
  const { memoryMutationQueue } = require("./memory-mutation-queue");
14
+ const { atomicWriteFileSync } = require("./fsutil");
15
+ const CONFIG_DIR = require("../config-dir");
12
16
  const packs = require("./packs");
13
17
  const entities = require("./entities");
14
18
  const lessons = require("./lessons");
@@ -160,6 +164,7 @@ Decide. Rules:
160
164
  - Bias toward action: if the turn did real work on an identifiable topic (a named system, project, server, app, person, or domain), record it β€” at minimum a journal line. Most working turns deserve one. Reserve empty actions for small talk, pure status checks, and turns that contain nothing new.
161
165
  - ATTRIBUTION: prefer updating one of the ACTIVE packs above. The turn text often does not name its project (pronouns, follow-ups, pasted URLs) β€” never attribute the work to a different pack from the index on thematic similarity alone when an active pack fits the work.
162
166
  - State/Stance/Procedure rewrites are MERGES, never regenerations: start from the pack's CURRENT content shown in the ACTIVE block above, carry forward every fact that is still true, drop only what this turn contradicted or completed, then fold in what changed. Never drop a durable fact merely because this turn did not mention it. If a pack's current content is NOT shown above, do not rewrite its sections β€” record a journal line only (section rewrites there are discarded).
167
+ - LOCKED lines: a Stance line containing the word LOCKED is a constraint the user explicitly froze. NEVER weaken, reword, or drop a LOCKED line in a stance rewrite β€” carry it forward verbatim β€” unless THIS turn shows the user explicitly unlocking or changing it. COMPLIANCE CHECK: compare the assistant's reply against every LOCKED line in the ACTIVE packs above; if the reply violated one (did what the line forbids, or contradicted the locked choice), append a journal line on that pack recording it, e.g. "VIOLATION: reply used <X> where Stance locks <Y>." β€” violations must become visible, never silently absorbed.
163
168
  - UPDATE an existing pack when the turn touched that topic: append a journal line, and rewrite State if where-things-stand changed. Update Stance when the user expressed a lasting preference or rule; Procedure when a verified working method emerged.
164
169
  - PROMOTE durable conclusions out of the Journal. A confirmed root-cause diagnosis, an established fact, or a settled design decision must go into Stance/Procedure/State (the parts always injected in full), NOT only a Journal line β€” Journal is truncated to the last few lines on injection, so a conclusion left only there will silently fall out of recall over time.
165
170
  - CREATE a pack ONLY when the turn worked on a durable topic that NO existing pack covers. Before creating, scan the existing packs above for one already about this project/system/domain β€” if one exists, UPDATE it instead. A new pack must be a genuinely DISTINCT topic, never a narrower facet, version, release, sub-feature, phase, or component of a topic an existing pack already owns. In particular: a release or version of a project (e.g. a pack named after "<project> v2.6.39") is a Journal line + State update on that project's EXISTING pack β€” NEVER its own pack. Likewise sub-features of one system belong in that system's pack, not in siblings. When unsure whether something is a new topic or a facet of an existing one, treat it as a facet and UPDATE. Do not create packs for one-off trivia.
@@ -533,10 +538,38 @@ function reviewerMetric(entry) {
533
538
  try { require("./recall/metrics").logReviewerRun(entry); } catch (_) {}
534
539
  }
535
540
 
541
+ // Lock-timeout seatbelt. A decision that cannot get the mutation lock even
542
+ // after one retry used to be dropped on the floor β€” on one pod 87% of
543
+ // reviewer runs lost their computed mutations this way and nobody could see
544
+ // what was lost. Persist it instead (capped, no auto-replay: pack state may
545
+ // have moved on, so /doctor surfaces the backlog and a human decides).
546
+ const DEFERRED_FILE = path.join(CONFIG_DIR, "pending-review-mutations.jsonl");
547
+ const MAX_DEFERRED = 20;
548
+ const LOCK_RETRY_DELAY_MS = 60 * 1000;
549
+
550
+ function persistDeferredDecision(entry) {
551
+ try {
552
+ let lines = [];
553
+ try { lines = fs.readFileSync(DEFERRED_FILE, "utf-8").trim().split("\n").filter(Boolean); } catch (_) {}
554
+ lines.push(JSON.stringify(entry));
555
+ atomicWriteFileSync(DEFERRED_FILE, lines.slice(-MAX_DEFERRED).join("\n") + "\n", { mode: 0o600 });
556
+ return true;
557
+ } catch (_) { return false; }
558
+ }
559
+
560
+ function listDeferredDecisions() {
561
+ try {
562
+ return fs.readFileSync(DEFERRED_FILE, "utf-8").trim().split("\n").filter(Boolean)
563
+ .map((l) => { try { return JSON.parse(l); } catch (_) { return null; } })
564
+ .filter(Boolean);
565
+ } catch (_) { return []; }
566
+ }
567
+
536
568
  function createPackReviewer(dependencies = {}) {
537
569
  const runUtility = dependencies.spawnUtilityAgent || spawnUtilityAgent;
538
570
  const queue = dependencies.mutationQueue || memoryMutationQueue;
539
571
  const cancelUtilities = dependencies.cancelUtilityAgents || cancelUtilityAgents;
572
+ const lockRetryDelayMs = dependencies.lockRetryDelayMs ?? LOCK_RETRY_DELAY_MS;
540
573
  const handles = new Map();
541
574
  let accepting = true;
542
575
 
@@ -586,7 +619,7 @@ function createPackReviewer(dependencies = {}) {
586
619
  reviewerMetric({ provider: utilityResult.provider, model: utilityResult.model, status: "no_change", fallbackUsed: utilityResult.fallbackUsed });
587
620
  return { status: "no_change", provider: utilityResult.provider, model: utilityResult.model, fallbackUsed: !!utilityResult.fallbackUsed };
588
621
  }
589
- const applied = await queue.enqueue("review", () => applyDecisionBatch({
622
+ const enqueueApply = () => queue.enqueue("review", () => applyDecisionBatch({
590
623
  decision,
591
624
  activePacks,
592
625
  versions,
@@ -597,6 +630,27 @@ function createPackReviewer(dependencies = {}) {
597
630
  purpose: "review",
598
631
  runId,
599
632
  });
633
+ let applied;
634
+ try {
635
+ applied = await enqueueApply();
636
+ } catch (error) {
637
+ if (error.code !== "MEMORY_MUTATION_LOCK_TIMEOUT") throw error;
638
+ await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs));
639
+ try {
640
+ applied = await enqueueApply();
641
+ } catch (retryError) {
642
+ if (retryError.code === "MEMORY_MUTATION_LOCK_TIMEOUT") {
643
+ retryError.deferred = persistDeferredDecision({
644
+ at: new Date().toISOString(),
645
+ source: source || "",
646
+ provider: utilityResult.provider || "",
647
+ model: utilityResult.model || "",
648
+ decision,
649
+ });
650
+ }
651
+ throw retryError;
652
+ }
653
+ }
600
654
  let announcementFailed = false;
601
655
  if (applied.lines.length && typeof announce === "function") {
602
656
  try { await announce(formatAnnouncement(applied.lines)); }
@@ -619,10 +673,11 @@ function createPackReviewer(dependencies = {}) {
619
673
  try { await announce(formatAnnouncement(partialLines)); }
620
674
  catch (_) { announcementFailed = true; }
621
675
  }
622
- reviewerMetric({ provider: resolvedProvider, model: utilityResult?.model || "", status: "failed", code: error.code || "REVIEW_FAILED" });
623
- console.warn(`[pack-review] reviewer failed: ${error.message}`);
676
+ const status = error.deferred ? "deferred" : "failed";
677
+ reviewerMetric({ provider: resolvedProvider, model: utilityResult?.model || "", status, code: error.code || "REVIEW_FAILED" });
678
+ console.warn(`[pack-review] reviewer ${status}: ${error.message}`);
624
679
  return {
625
- status: "failed",
680
+ status,
626
681
  provider: resolvedProvider || null,
627
682
  model: utilityResult?.model || null,
628
683
  lines: partialLines,
@@ -690,6 +745,7 @@ module.exports = {
690
745
  contentVersion,
691
746
  createPackReviewer,
692
747
  drainReviews: defaultReviewer.drainReviews,
748
+ listDeferredDecisions,
693
749
  parseDecision,
694
750
  provenanceTag,
695
751
  reviewTurn: defaultReviewer.reviewTurn,
package/core/packs.js CHANGED
@@ -528,7 +528,7 @@ let _db = null;
528
528
  let _indexedAt = 0;
529
529
  let _dirty = true;
530
530
 
531
- function markIndexDirty() { _dirty = true; }
531
+ function markIndexDirty() { _dirty = true; _lexicalDocsAt = -1; }
532
532
 
533
533
  function openDb() {
534
534
  if (!DatabaseSync) return null;
@@ -609,6 +609,50 @@ function queryTerms(text) {
609
609
  return terms;
610
610
  }
611
611
 
612
+ // Pure-JS floor under matchPacks for substrates without node:sqlite (a
613
+ // node-20 pod once ran 16 days with zero recall because the matcher bailed
614
+ // silently). Same shape and scoring as the FTS path; porter stemming is
615
+ // approximated by substring hits on the term plus a suffix-stripped variant.
616
+ function termVariants(t) {
617
+ for (const suffix of ["ing", "ed", "es", "s"]) {
618
+ if (t.length > suffix.length + 2 && t.endsWith(suffix)) return [t, t.slice(0, -suffix.length)];
619
+ }
620
+ return [t];
621
+ }
622
+
623
+ let _lexicalDocs = null;
624
+ let _lexicalDocsAt = -1;
625
+
626
+ function lexicalDocs() {
627
+ const mtime = latestMtime();
628
+ if (_lexicalDocs && _lexicalDocsAt === mtime) return _lexicalDocs;
629
+ _lexicalDocs = listPacks().map((p) => ({
630
+ dir: p.dir,
631
+ name: p.name,
632
+ strong: [p.name, p.description, p.tags.join(" ")].join("\n").toLowerCase(),
633
+ body: [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Journal].join("\n").toLowerCase(),
634
+ }));
635
+ _lexicalDocsAt = mtime;
636
+ return _lexicalDocs;
637
+ }
638
+
639
+ function matchPacksLexical(text, { limit = 3, threshold = null } = {}) {
640
+ const terms = queryTerms(text);
641
+ if (terms.length === 0) return [];
642
+ const minScore = threshold ?? Number(process.env.PACK_MATCH_THRESHOLD || 2);
643
+ const scored = [];
644
+ for (const doc of lexicalDocs()) {
645
+ let score = 0;
646
+ for (const t of terms) {
647
+ const variants = termVariants(t);
648
+ if (variants.some((v) => doc.strong.includes(v))) score += 2;
649
+ else if (variants.some((v) => doc.body.includes(v))) score += 1;
650
+ }
651
+ if (score >= minScore) scored.push({ dir: doc.dir, name: doc.name, score });
652
+ }
653
+ return scored.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)));
654
+ }
655
+
612
656
  // Match a free-text message against packs. BM25 is unusable here β€” with
613
657
  // a small corpus its IDF term collapses to ~0 β€” so score per term
614
658
  // instead: a hit in name/description/tags counts 2 (porter-stemmed via
@@ -616,14 +660,14 @@ function queryTerms(text) {
616
660
  // single incidental body word (score 1) never drags a pack in.
617
661
  function matchPacks(text, { limit = 3, threshold = null } = {}) {
618
662
  const db = openDb();
619
- if (!db) return [];
663
+ if (!db) return matchPacksLexical(text, { limit, threshold });
620
664
  ensureIndex();
621
665
  const terms = queryTerms(text);
622
666
  if (terms.length === 0) return [];
623
667
  const minScore = threshold ?? Number(process.env.PACK_MATCH_THRESHOLD || 2);
624
668
  const scores = new Map();
625
669
  let stmt;
626
- try { stmt = db.prepare("SELECT dir FROM packs WHERE packs MATCH ?"); } catch (e) { return []; }
670
+ try { stmt = db.prepare("SELECT dir FROM packs WHERE packs MATCH ?"); } catch (e) { return matchPacksLexical(text, { limit, threshold }); }
627
671
  for (const t of terms) {
628
672
  const quoted = `"${t.replace(/"/g, '""')}"`;
629
673
  let strong = [];