@inetafrica/open-claudia 3.0.44 → 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.
- package/CHANGELOG.md +37 -0
- package/README.md +9 -5
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +118 -1
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +48 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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 (
|
|
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
|
};
|
package/core/pack-review.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
623
|
-
|
|
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
|
|
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 = [];
|
|
@@ -150,18 +150,34 @@ function maybeSync(packsLib, entitiesLib) {
|
|
|
150
150
|
// seed — with a 147-pack corpus that is virtually every turn (measured: gated
|
|
151
151
|
// 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
|
|
152
152
|
// skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
|
|
153
|
-
// seeds — short command-like turns (≤4 words
|
|
154
|
-
// enough; inject user-origin seeds directly
|
|
155
|
-
// (the classic baseline, ~ms instead of ~13s).
|
|
153
|
+
// seeds — short command-like turns (≤4 words after any pleasantry prefix):
|
|
154
|
+
// keyword seeds are precise enough; inject user-origin seeds directly
|
|
155
|
+
// and skip graph + walker (the classic baseline, ~ms instead of ~13s).
|
|
156
156
|
// full — substantive turns: graph expansion + walker judgement.
|
|
157
157
|
const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
|
|
158
158
|
|
|
159
|
+
// B2: a pleasantry PREFIX must not hide a real request from the gate — "ok
|
|
160
|
+
// deploy kazee now" is a deploy command wearing an ack, yet the old ≤4-word
|
|
161
|
+
// rule skipped recall for it entirely. Strip leading pleasantry tokens
|
|
162
|
+
// (stacked ones too) plus separators and tier what remains; a pure pleasantry
|
|
163
|
+
// chain strips to "" and skips regardless of its length.
|
|
164
|
+
function stripPleasantryPrefix(t) {
|
|
165
|
+
let s = t;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const m = PLEASANTRY_RE.exec(s);
|
|
168
|
+
if (!m) return s;
|
|
169
|
+
s = s.slice(m[0].length).replace(/^[\s,.!?;:—–-]+/, "");
|
|
170
|
+
if (!s) return "";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
function recallTier(userText, seedCount) {
|
|
160
175
|
const t = String(userText || "").trim().toLowerCase();
|
|
161
176
|
if (!t) return "skip";
|
|
162
|
-
const words = t.split(/\s+/).filter(Boolean);
|
|
163
177
|
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
164
|
-
|
|
178
|
+
const core = stripPleasantryPrefix(t);
|
|
179
|
+
if (!core) return "skip"; // pure pleasantry/ack chain
|
|
180
|
+
const words = core.split(/\s+/).filter(Boolean);
|
|
165
181
|
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
166
182
|
if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
|
|
167
183
|
return "full";
|
|
@@ -325,7 +341,7 @@ function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs
|
|
|
325
341
|
pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
|
|
326
342
|
}
|
|
327
343
|
}
|
|
328
|
-
pairs.sort((x, y) => y.strength - x.strength || (x.a + "
|
|
344
|
+
pairs.sort((x, y) => y.strength - x.strength || (x.a + "\u0000" + x.b).localeCompare(y.a + "\u0000" + y.b));
|
|
329
345
|
return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
|
|
330
346
|
}
|
|
331
347
|
|