@inetafrica/open-claudia 3.0.44 → 3.1.1
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 +46 -0
- package/README.md +12 -7
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/actions.js +22 -0
- package/core/auth-flow.js +68 -0
- 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 +160 -2
- 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 +59 -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/runner.js
CHANGED
|
@@ -1201,6 +1201,7 @@ function deriveCompactionRunContext(base, overrides = {}) {
|
|
|
1201
1201
|
resumeSessionId: overrides.resumeSessionId ?? null,
|
|
1202
1202
|
continueSession: false,
|
|
1203
1203
|
fresh: !!overrides.fresh,
|
|
1204
|
+
recallQuery: overrides.recallQuery ?? null,
|
|
1204
1205
|
skipAutoCompact: true,
|
|
1205
1206
|
requiredDelivery: false,
|
|
1206
1207
|
readOnly: true,
|
|
@@ -1214,6 +1215,25 @@ function deriveCompactionRunContext(base, overrides = {}) {
|
|
|
1214
1215
|
});
|
|
1215
1216
|
}
|
|
1216
1217
|
|
|
1218
|
+
// The seed turn opens a fresh session, so whatever packs were active before
|
|
1219
|
+
// compaction only come back if recall re-attracts them. Match on what the
|
|
1220
|
+
// work IS — the condensed brief plus open task titles — rather than the seed
|
|
1221
|
+
// prompt's instruction boilerplate.
|
|
1222
|
+
function seedRecallQuery(condensedSummary) {
|
|
1223
|
+
const parts = [String(condensedSummary || "").slice(0, 4000)];
|
|
1224
|
+
try {
|
|
1225
|
+
const { currentAdapter, currentChannelId } = require("./context");
|
|
1226
|
+
const adapter = currentAdapter();
|
|
1227
|
+
const channelId = currentChannelId();
|
|
1228
|
+
if (adapter && channelId) {
|
|
1229
|
+
const titles = require("./tasks").pendingSummary(adapter.id, channelId)
|
|
1230
|
+
.map((t) => t.content).filter(Boolean).slice(0, 20);
|
|
1231
|
+
if (titles.length) parts.push(titles.join("\n"));
|
|
1232
|
+
}
|
|
1233
|
+
} catch (_) {}
|
|
1234
|
+
return parts.filter(Boolean).join("\n");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1217
1237
|
function successfulTerminalCapture(result, { requireSession = false } = {}) {
|
|
1218
1238
|
return !!result?.ok && result.status === "succeeded" && result.terminalSeen === true
|
|
1219
1239
|
&& (!requireSession || (typeof result.sessionId === "string" && !!result.sessionId));
|
|
@@ -1296,6 +1316,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1296
1316
|
previousSessionId: oldSessionId,
|
|
1297
1317
|
fresh: true,
|
|
1298
1318
|
outputSchemaPath,
|
|
1319
|
+
recallQuery: seedRecallQuery(condensed || fullBrief),
|
|
1299
1320
|
});
|
|
1300
1321
|
const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
|
|
1301
1322
|
runContext: seedContext,
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Safe-mode boot gate. Two failure classes land here instead of running (or
|
|
2
|
+
// dying) blind:
|
|
3
|
+
// 1) REQUIRED substrate missing — no node:sqlite means no pack FTS, no
|
|
4
|
+
// recall graph, no episodes. A pod once ran 16 days like that, silently.
|
|
5
|
+
// Running degraded is now an explicit operator choice, never a default.
|
|
6
|
+
// 2) Boot crash loop — N consecutive boots died before reaching stable
|
|
7
|
+
// uptime. A hard exit here would strand the operator with a dead chat;
|
|
8
|
+
// safe mode keeps the channel alive so /doctor, /upgrade, /downgrade and
|
|
9
|
+
// /safemode continue can rescue it FROM CHAT.
|
|
10
|
+
// Safe mode boots adapters + slash commands only: no scheduler, no crons, no
|
|
11
|
+
// agent turns, no reviewer. bot.js consults beginBoot() once at startup;
|
|
12
|
+
// router.js gates every message through isSafeMode()/commandAllowed().
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const CONFIG_DIR = require("../config-dir");
|
|
17
|
+
const { sqliteAvailable } = require("./doctor-memory");
|
|
18
|
+
|
|
19
|
+
const MARKER_FILE = path.join(CONFIG_DIR, "boot-marker.json");
|
|
20
|
+
const CONTINUE_FILE = path.join(CONFIG_DIR, "safemode-continue.json");
|
|
21
|
+
const VERSION_HISTORY_FILE = path.join(CONFIG_DIR, "version-history.json");
|
|
22
|
+
|
|
23
|
+
const CRASH_LOOP_THRESHOLD = 3;
|
|
24
|
+
const STABLE_UPTIME_MS = 5 * 60 * 1000;
|
|
25
|
+
const CONTINUE_FRESH_MS = 15 * 60 * 1000;
|
|
26
|
+
const VERSION_HISTORY_CAP = 20;
|
|
27
|
+
|
|
28
|
+
// Commands that still work in safe mode: diagnosis + the ways out. Everything
|
|
29
|
+
// else (agent turns, memory, schedulers, wizards) is paused.
|
|
30
|
+
const SAFE_COMMANDS = new Set([
|
|
31
|
+
"help", "start", "version", "status", "doctor", "requirements",
|
|
32
|
+
"restart", "upgrade", "downgrade", "safemode", "whoami", "cluster",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
let _state = { mode: "full", reasons: [], consecutiveCrashes: 0, prevExit: null };
|
|
36
|
+
|
|
37
|
+
function readJson(file) {
|
|
38
|
+
try { return JSON.parse(fs.readFileSync(file, "utf-8")); } catch (_) { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeJson(file, value) {
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
44
|
+
fs.writeFileSync(file, JSON.stringify(value), { mode: 0o600 });
|
|
45
|
+
return true;
|
|
46
|
+
} catch (_) { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pure decision, unit-testable. degradedAllowed = explicit env override;
|
|
50
|
+
// continueRequested = one-shot /safemode continue flag (also resets the loop).
|
|
51
|
+
function decideBootMode({ sqliteOk, crashCount, degradedAllowed, continueRequested }) {
|
|
52
|
+
const reasons = [];
|
|
53
|
+
const effectiveCrashes = continueRequested ? 0 : crashCount;
|
|
54
|
+
if (!sqliteOk && !degradedAllowed && !continueRequested) reasons.push("substrate");
|
|
55
|
+
if (effectiveCrashes >= CRASH_LOOP_THRESHOLD) reasons.push("crash-loop");
|
|
56
|
+
if (reasons.length) return { mode: "safe", reasons };
|
|
57
|
+
return { mode: sqliteOk ? "full" : "degraded", reasons: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function recordVersionSeen(version, now = Date.now()) {
|
|
61
|
+
if (!version) return;
|
|
62
|
+
const history = Array.isArray(readJson(VERSION_HISTORY_FILE)) ? readJson(VERSION_HISTORY_FILE) : [];
|
|
63
|
+
const last = history[history.length - 1];
|
|
64
|
+
if (last && last.version === version) return;
|
|
65
|
+
history.push({ version, at: new Date(now).toISOString() });
|
|
66
|
+
writeJson(VERSION_HISTORY_FILE, history.slice(-VERSION_HISTORY_CAP));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Most recent version in boot history that differs from the current one — the
|
|
70
|
+
// default /downgrade target ("what ran before this").
|
|
71
|
+
function previousVersion(current) {
|
|
72
|
+
const history = readJson(VERSION_HISTORY_FILE);
|
|
73
|
+
if (!Array.isArray(history)) return null;
|
|
74
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
75
|
+
const v = history[i] && history[i].version;
|
|
76
|
+
if (v && v !== current) return v;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function consumeContinueFlag(now = Date.now()) {
|
|
82
|
+
const flag = readJson(CONTINUE_FILE);
|
|
83
|
+
try { fs.rmSync(CONTINUE_FILE, { force: true }); } catch (_) {}
|
|
84
|
+
if (!flag || !flag.at) return false;
|
|
85
|
+
return now - Date.parse(flag.at) < CONTINUE_FRESH_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requestContinue() {
|
|
89
|
+
return writeJson(CONTINUE_FILE, { at: new Date().toISOString() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Called once per process at the very top of bot.js. Counts crash-loop state
|
|
93
|
+
// from the previous boot marker + exit record, decides the boot mode, writes
|
|
94
|
+
// this boot's marker. Crash rule: the previous incarnation counts as a crash
|
|
95
|
+
// iff it never reached stable uptime AND did not record a clean (code 0)
|
|
96
|
+
// deliberate exit. A safe-mode incarnation preserves the count (safe mode
|
|
97
|
+
// being stable must not launder the loop away) — only markBootStable(),
|
|
98
|
+
// /safemode continue, or a deliberate version change resets it.
|
|
99
|
+
function beginBoot({ exitInfo = null, currentVersion = "", env = process.env, now = Date.now() } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
// Test runs boot bot.js repeatedly and kill it hard; marker counting would
|
|
102
|
+
// sabotage the suite. The feature's own tests opt back in explicitly.
|
|
103
|
+
if (env.OPEN_CLAUDIA_TEST === "1" && env.OPEN_CLAUDIA_SAFEMODE_TEST !== "1") {
|
|
104
|
+
_state = { mode: "full", reasons: [], consecutiveCrashes: 0, prevExit: exitInfo };
|
|
105
|
+
return { ..._state };
|
|
106
|
+
}
|
|
107
|
+
recordVersionSeen(currentVersion, now);
|
|
108
|
+
const prev = readJson(MARKER_FILE);
|
|
109
|
+
const cleanExit = !!(exitInfo && Number(exitInfo.code) === 0);
|
|
110
|
+
const crashed = !!(prev && !prev.stable && !cleanExit);
|
|
111
|
+
const baseCount = prev ? Number(prev.consecutiveCrashes) || 0 : 0;
|
|
112
|
+
const crashCount = crashed ? baseCount + 1 : (prev && prev.mode === "safe" ? baseCount : 0);
|
|
113
|
+
const continueRequested = consumeContinueFlag(now);
|
|
114
|
+
const decision = decideBootMode({
|
|
115
|
+
sqliteOk: sqliteAvailable(),
|
|
116
|
+
crashCount,
|
|
117
|
+
degradedAllowed: env.MEMORY_ALLOW_DEGRADED === "1",
|
|
118
|
+
continueRequested,
|
|
119
|
+
});
|
|
120
|
+
const effectiveCount = continueRequested ? 0 : crashCount;
|
|
121
|
+
writeJson(MARKER_FILE, {
|
|
122
|
+
bootedAt: new Date(now).toISOString(),
|
|
123
|
+
pid: process.pid,
|
|
124
|
+
mode: decision.mode,
|
|
125
|
+
// The safe-mode process itself is stable; its marker must still carry
|
|
126
|
+
// the count so a plain /restart re-enters safe mode instead of retrying
|
|
127
|
+
// the crashing full boot.
|
|
128
|
+
stable: decision.mode === "safe",
|
|
129
|
+
consecutiveCrashes: effectiveCount,
|
|
130
|
+
version: currentVersion,
|
|
131
|
+
});
|
|
132
|
+
_state = {
|
|
133
|
+
mode: decision.mode,
|
|
134
|
+
reasons: decision.reasons,
|
|
135
|
+
consecutiveCrashes: effectiveCount,
|
|
136
|
+
prevExit: exitInfo,
|
|
137
|
+
};
|
|
138
|
+
return { ..._state };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// The gate must never itself brick a boot.
|
|
141
|
+
try { console.error("safe-mode beginBoot failed:", e.message); } catch (_) {}
|
|
142
|
+
_state = { mode: "full", reasons: [], consecutiveCrashes: 0, prevExit: exitInfo };
|
|
143
|
+
return { ..._state };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Full/degraded boots call this after STABLE_UPTIME_MS: the incarnation
|
|
148
|
+
// proved itself, so the crash streak resets.
|
|
149
|
+
function markBootStable() {
|
|
150
|
+
const marker = readJson(MARKER_FILE);
|
|
151
|
+
if (!marker) return;
|
|
152
|
+
marker.stable = true;
|
|
153
|
+
marker.consecutiveCrashes = 0;
|
|
154
|
+
writeJson(MARKER_FILE, marker);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A deliberate version change (/upgrade, /downgrade) is an operator fix
|
|
158
|
+
// attempt: give the next boot a clean slate.
|
|
159
|
+
function clearCrashLoop() {
|
|
160
|
+
const marker = readJson(MARKER_FILE);
|
|
161
|
+
if (!marker) return;
|
|
162
|
+
marker.consecutiveCrashes = 0;
|
|
163
|
+
if (marker.mode === "safe") marker.mode = "full";
|
|
164
|
+
writeJson(MARKER_FILE, marker);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isSafeMode() {
|
|
168
|
+
return _state.mode === "safe";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function bootInfo() {
|
|
172
|
+
return { ..._state };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function commandAllowed(text) {
|
|
176
|
+
const t = String(text || "").trim();
|
|
177
|
+
if (!t.startsWith("/")) return false;
|
|
178
|
+
const word = t.slice(1).split(/\s+/)[0].split("@")[0].toLowerCase();
|
|
179
|
+
return SAFE_COMMANDS.has(word);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function reasonLines() {
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (const r of _state.reasons) {
|
|
185
|
+
if (r === "substrate") {
|
|
186
|
+
lines.push(`• Memory substrate missing: Node ${process.version} has no node:sqlite (Open Claudia requires Node ≥24).`);
|
|
187
|
+
} else if (r === "crash-loop") {
|
|
188
|
+
lines.push(`• Boot crash loop: ${_state.consecutiveCrashes} consecutive boots died before reaching stable uptime.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function lastExitLine() {
|
|
195
|
+
if (_state.prevExit && _state.prevExit.reason) return `Last exit: ${_state.prevExit.reason}`;
|
|
196
|
+
return "Last exit: hard kill — no exit record (OOM, SIGKILL, or a crash before any handler ran).";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function banner() {
|
|
200
|
+
const why = _state.reasons.includes("substrate") ? "memory substrate missing" : "boot crash loop";
|
|
201
|
+
return `⛔ Safe mode (${why}) — normal turns are paused. /safemode shows details and ways out; /doctor /status /upgrade /downgrade /restart still work.`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatSafeModeAlert({ version = "" } = {}) {
|
|
205
|
+
return [
|
|
206
|
+
`⛔ SAFE MODE — Open Claudia v${version} booted with normal operation suspended.`,
|
|
207
|
+
...reasonLines(),
|
|
208
|
+
lastExitLine(),
|
|
209
|
+
"",
|
|
210
|
+
"No agent turns, crons, watches or memory writes will run in this state.",
|
|
211
|
+
"Working commands: /doctor /status /version /cluster /upgrade /downgrade [version] /restart /safemode continue /help",
|
|
212
|
+
"",
|
|
213
|
+
"Ways out:",
|
|
214
|
+
"• /upgrade — install the latest release",
|
|
215
|
+
"• /downgrade — roll back to the previously-running version",
|
|
216
|
+
"• /safemode continue — boot normally anyway (keyword-only memory if the substrate is missing)",
|
|
217
|
+
"• Fix the runtime (pods: roll to the node:24 image), then /restart",
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
MARKER_FILE, CONTINUE_FILE, VERSION_HISTORY_FILE,
|
|
223
|
+
CRASH_LOOP_THRESHOLD, STABLE_UPTIME_MS, SAFE_COMMANDS,
|
|
224
|
+
decideBootMode, beginBoot, markBootStable, clearCrashLoop,
|
|
225
|
+
recordVersionSeen, previousVersion, requestContinue,
|
|
226
|
+
isSafeMode, bootInfo, commandAllowed, banner, formatSafeModeAlert,
|
|
227
|
+
};
|
package/core/state.js
CHANGED
|
@@ -629,10 +629,10 @@ function seedConversationContinuity(state) {
|
|
|
629
629
|
if (!isRecord(legacy) || isRecord(state.activeSessions[convo])) return;
|
|
630
630
|
state.activeSessions[convo] = { ...legacy };
|
|
631
631
|
if (isRecord(state.usageBySession)) {
|
|
632
|
-
const prefix = "Workspace
|
|
632
|
+
const prefix = "Workspace\u0000";
|
|
633
633
|
for (const [key, usage] of Object.entries(state.usageBySession)) {
|
|
634
634
|
if (key.startsWith(prefix) && isRecord(usage)) {
|
|
635
|
-
const moved = `${convo}
|
|
635
|
+
const moved = `${convo}\u0000${key.slice(prefix.length)}`;
|
|
636
636
|
if (!state.usageBySession[moved]) state.usageBySession[moved] = { ...usage };
|
|
637
637
|
}
|
|
638
638
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -539,6 +539,23 @@ function consumeLastInjected() {
|
|
|
539
539
|
return out;
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
// A Stance line carrying the word LOCKED (uppercase — prose "locked" stays
|
|
543
|
+
// put) is a constraint the user explicitly froze. The branding-font incident
|
|
544
|
+
// showed such a line sitting mid-Stance reads as background prose and gets
|
|
545
|
+
// violated; hoist LOCKED lines under a must-follow header so they carry rule
|
|
546
|
+
// authority in every injection mode.
|
|
547
|
+
function formatStanceSection(stance) {
|
|
548
|
+
const lines = stance.split("\n");
|
|
549
|
+
const locked = lines.map((l) => l.trim()).filter((l) => l && /\bLOCKED\b/.test(l));
|
|
550
|
+
if (!locked.length) return stance;
|
|
551
|
+
const rest = lines.filter((l) => !/\bLOCKED\b/.test(l)).join("\n").trim();
|
|
552
|
+
const block = [
|
|
553
|
+
"BINDING CONSTRAINTS (LOCKED — follow exactly; do not deviate or reinterpret unless the user explicitly unlocks them this turn):",
|
|
554
|
+
...locked.map((l) => (/^[-*]/.test(l) ? l : `- ${l}`)),
|
|
555
|
+
].join("\n");
|
|
556
|
+
return rest ? `${block}\n\n${rest}` : block;
|
|
557
|
+
}
|
|
558
|
+
|
|
542
559
|
function formatPackForContext(pack, packsLib, opts = {}) {
|
|
543
560
|
const extractive = !!opts.extractive;
|
|
544
561
|
const headline = !!opts.headline;
|
|
@@ -564,7 +581,7 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
564
581
|
// still used by the classic engine and as the fallback when no lines matched.
|
|
565
582
|
if (extractive) {
|
|
566
583
|
const stance = (pack.sections.Stance || "").trim();
|
|
567
|
-
if (stance) parts.push(`#### Stance\n${stance}`);
|
|
584
|
+
if (stance) parts.push(`#### Stance\n${formatStanceSection(stance)}`);
|
|
568
585
|
const stanceLow = stance.toLowerCase();
|
|
569
586
|
const bySection = new Map();
|
|
570
587
|
for (const h of (Array.isArray(opts.matchedLines) ? opts.matchedLines : [])) {
|
|
@@ -598,7 +615,7 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
598
615
|
: ["Stance", "Procedure", "State"];
|
|
599
616
|
for (const section of sections) {
|
|
600
617
|
const body = (pack.sections[section] || "").trim();
|
|
601
|
-
if (body) parts.push(`#### ${section}\n${body}`);
|
|
618
|
+
if (body) parts.push(`#### ${section}\n${section === "Stance" ? formatStanceSection(body) : body}`);
|
|
602
619
|
}
|
|
603
620
|
if (headline) {
|
|
604
621
|
const hidden = ["State", "Procedure", "Journal"].filter((s) => (pack.sections[s] || "").trim());
|
|
@@ -984,7 +1001,25 @@ function voiceReplyGuidance(opts = {}) {
|
|
|
984
1001
|
|
|
985
1002
|
async function buildOptionalRecallContext(prompt, opts = {}) {
|
|
986
1003
|
const collector = { packs: [], packDirs: [], entities: [], recall: null };
|
|
987
|
-
|
|
1004
|
+
// Compaction meta-turns: the summarizer's prompt is instructions about
|
|
1005
|
+
// summarizing, so matching packs against it is pure noise — skip recall.
|
|
1006
|
+
// The seed turn is the opposite: it opens a FRESH session, so recall is the
|
|
1007
|
+
// only way the packs active before compaction come back — match on the
|
|
1008
|
+
// runner-supplied recallQuery (condensed brief + open task titles), not the
|
|
1009
|
+
// seed prompt's boilerplate.
|
|
1010
|
+
if (opts.runContext?.purpose === "compact-summary") {
|
|
1011
|
+
return {
|
|
1012
|
+
dynamicContext: "",
|
|
1013
|
+
metadata: { status: "omitted", omitted: true, reason: "compaction_meta_turn" },
|
|
1014
|
+
legacy: collector,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const queryOverride = typeof opts.runContext?.recallQuery === "string" && opts.runContext.recallQuery.trim()
|
|
1018
|
+
? opts.runContext.recallQuery
|
|
1019
|
+
: null;
|
|
1020
|
+
const { userText, contextText } = queryOverride
|
|
1021
|
+
? { userText: stripRecallAnnouncements(queryOverride), contextText: "" }
|
|
1022
|
+
: recallMatchParts(prompt);
|
|
988
1023
|
let historyText = "";
|
|
989
1024
|
try {
|
|
990
1025
|
historyText = stripRecallAnnouncements(require("./transcripts").recentTranscriptText({ limit: 6 }, promptState(opts)));
|
|
@@ -1085,12 +1120,17 @@ function createPromptBuilder(dependencies = {}) {
|
|
|
1085
1120
|
throw new TypeError("recall builder returned an invalid result");
|
|
1086
1121
|
}
|
|
1087
1122
|
} catch (error) {
|
|
1123
|
+
// Fail-open by design (a broken recall must not block the turn), but
|
|
1124
|
+
// never silently: the error goes to the bot log here, and the turn
|
|
1125
|
+
// observer counts consecutive failures and alerts the owner (B5).
|
|
1126
|
+
try { console.error("[recall] builder failed:", error?.message || error); } catch (_) {}
|
|
1088
1127
|
recallResult = {
|
|
1089
1128
|
dynamicContext: "",
|
|
1090
1129
|
metadata: {
|
|
1091
1130
|
status: "omitted",
|
|
1092
1131
|
omitted: true,
|
|
1093
1132
|
reason: "optional_recall_unavailable",
|
|
1133
|
+
detail: String(error?.message || error).slice(0, 200),
|
|
1094
1134
|
},
|
|
1095
1135
|
legacy: { packs: [], packDirs: [], entities: [], recall: null },
|
|
1096
1136
|
};
|
package/core/tool-graph.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
// and traversed directionally. Each edge carries a Hebbian `weight` bumped on
|
|
24
24
|
// co-use and decayed over time; unlike pack edges there is no structural floor,
|
|
25
25
|
// so a chain that stops being used eventually decays away and is pruned.
|
|
26
|
+
//
|
|
27
|
+
// Decay is computed ON READ (same doctrine as core/recall/graph.js): the stored
|
|
28
|
+
// `weight` is the value AS OF `last_reinforced`; reads map it through
|
|
29
|
+
// effectiveWeight() and reinforcement settles accrued decay before bumping.
|
|
30
|
+
// Nothing rewrites weights nightly — tend() only physically deletes edges whose
|
|
31
|
+
// effective weight has faded under `pruneBelow`. The old nightly decay()
|
|
32
|
+
// rewrite compounded (full age reapplied to an already-decayed weight each
|
|
33
|
+
// night), emptying chains in days; that path no longer exists.
|
|
26
34
|
|
|
27
35
|
const fs = require("fs");
|
|
28
36
|
const path = require("path");
|
|
@@ -37,8 +45,8 @@ const GRAPH_DB = process.env.TOOL_GRAPH_DB
|
|
|
37
45
|
|
|
38
46
|
// Tunables (overridable via dream knobs later).
|
|
39
47
|
const DEFAULTS = {
|
|
40
|
-
halfLifeDays: 45, // Hebbian weight half-life for decay
|
|
41
|
-
pruneBelow: 0.15, // edges
|
|
48
|
+
halfLifeDays: 45, // Hebbian weight half-life for read-time decay
|
|
49
|
+
pruneBelow: 0.15, // edges whose effective weight falls under this are dead (no structural floor)
|
|
42
50
|
};
|
|
43
51
|
|
|
44
52
|
let _db = null;
|
|
@@ -82,6 +90,18 @@ function openDb() {
|
|
|
82
90
|
)`);
|
|
83
91
|
db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_pack ON pack_tool_edges (pack)");
|
|
84
92
|
db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_tool ON pack_tool_edges (tool)");
|
|
93
|
+
// v1 migration: legacy DBs carry weights the old nightly decay() rewrite
|
|
94
|
+
// had already over-decayed (compounding). Treat stored values as current
|
|
95
|
+
// and restart the clock — decay is read-time from here on.
|
|
96
|
+
try {
|
|
97
|
+
const uv = db.prepare("PRAGMA user_version").get() || {};
|
|
98
|
+
if (Number(uv.user_version || 0) < 1) {
|
|
99
|
+
const stamp = now();
|
|
100
|
+
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
|
|
101
|
+
db.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
|
|
102
|
+
db.exec("PRAGMA user_version = 1");
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {}
|
|
85
105
|
_db = db;
|
|
86
106
|
return db;
|
|
87
107
|
} catch (e) {
|
|
@@ -91,23 +111,49 @@ function openDb() {
|
|
|
91
111
|
|
|
92
112
|
function now() { return new Date().toISOString(); }
|
|
93
113
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
114
|
+
// Read-time weight of an edge row: exponential decay of the stored weight since
|
|
115
|
+
// last_reinforced. Pure — storage is never touched. No structural floor here;
|
|
116
|
+
// an edge that was never reinforced (last_reinforced null) does not decay.
|
|
117
|
+
function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = DEFAULTS.halfLifeDays) {
|
|
118
|
+
if (!e || !Number.isFinite(e.weight)) return 0;
|
|
119
|
+
if (!e.last_reinforced) return e.weight;
|
|
120
|
+
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
121
|
+
if (!(ageDays > 0)) return e.weight;
|
|
122
|
+
return e.weight * Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Rank rows by read-time weight, dropping ones already faded under pruneBelow
|
|
126
|
+
// (dead advice — physically deleted on the next tend()). Weights are rounded
|
|
127
|
+
// to 4dp so a seconds-old reinforcement still reads as its whole value.
|
|
128
|
+
function rankLive(rows, limit) {
|
|
129
|
+
const nowMs = Date.now();
|
|
130
|
+
return rows
|
|
131
|
+
.map((r) => ({ ...r, weight: Math.round(effectiveWeight(r, nowMs) * 1e4) / 1e4 }))
|
|
132
|
+
.filter((r) => r.weight >= DEFAULTS.pruneBelow)
|
|
133
|
+
.sort((a, b) => b.weight - a.weight
|
|
134
|
+
|| String(b.last_reinforced || "").localeCompare(String(a.last_reinforced || "")))
|
|
135
|
+
.slice(0, Math.max(0, limit));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Directed upsert of a follows-edge src→dst. `bump` settles the decay accrued
|
|
139
|
+
// since last_reinforced, adds to the effective weight (Hebbian) and restamps;
|
|
140
|
+
// with no bump it just ensures the edge exists at `weight` (default 1).
|
|
97
141
|
function addFollow(src, dst, opts = {}) {
|
|
98
142
|
const db = openDb();
|
|
99
143
|
if (!db || !src || !dst || src === dst) return false;
|
|
100
144
|
const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
|
|
101
145
|
const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
|
|
102
146
|
try {
|
|
103
|
-
const existing = db.prepare("SELECT weight FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
|
|
147
|
+
const existing = db.prepare("SELECT weight, last_reinforced FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
|
|
104
148
|
if (existing) {
|
|
105
|
-
const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
|
|
106
149
|
if (bump) {
|
|
107
150
|
db.prepare("UPDATE tool_edges SET weight=?, last_reinforced=? WHERE src=? AND dst=?")
|
|
108
|
-
.run(
|
|
151
|
+
.run(effectiveWeight(existing) + bump, now(), src, dst);
|
|
109
152
|
} else {
|
|
110
|
-
|
|
153
|
+
const next = Math.max(existing.weight, weight);
|
|
154
|
+
if (next !== existing.weight) {
|
|
155
|
+
db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
|
|
156
|
+
}
|
|
111
157
|
}
|
|
112
158
|
} else {
|
|
113
159
|
// No structural floor for tools: a brand-new edge is worth exactly its
|
|
@@ -148,14 +194,14 @@ function allEdges() {
|
|
|
148
194
|
catch (e) { return []; }
|
|
149
195
|
}
|
|
150
196
|
|
|
151
|
-
// Tools that tend to follow `name`, strongest first. This
|
|
152
|
-
// signal: "you ran X — you usually run these next".
|
|
197
|
+
// Tools that tend to follow `name`, strongest first (read-time weights). This
|
|
198
|
+
// is the surfaced signal: "you ran X — you usually run these next".
|
|
153
199
|
function followers(name, limit = 5) {
|
|
154
200
|
const db = openDb();
|
|
155
201
|
if (!db || !name) return [];
|
|
156
202
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
const rows = db.prepare("SELECT dst AS name, weight, last_reinforced FROM tool_edges WHERE src=?").all(name);
|
|
204
|
+
return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
|
|
159
205
|
} catch (e) { return []; }
|
|
160
206
|
}
|
|
161
207
|
|
|
@@ -164,8 +210,8 @@ function predecessors(name, limit = 5) {
|
|
|
164
210
|
const db = openDb();
|
|
165
211
|
if (!db || !name) return [];
|
|
166
212
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
213
|
+
const rows = db.prepare("SELECT src AS name, weight, last_reinforced FROM tool_edges WHERE dst=?").all(name);
|
|
214
|
+
return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
|
|
169
215
|
} catch (e) { return []; }
|
|
170
216
|
}
|
|
171
217
|
|
|
@@ -206,7 +252,13 @@ function parseToolRuns(command) {
|
|
|
206
252
|
const out = [];
|
|
207
253
|
const cmd = String(command || "");
|
|
208
254
|
if (!/tool\s+(?:run|exec)\s+/i.test(cmd)) return out;
|
|
209
|
-
|
|
255
|
+
// B17: split on ALL shell composition operators — &&, ||, |, &, ;, newline.
|
|
256
|
+
// The single pipe was missed, so `tool run a … | tool run b …` parsed as one
|
|
257
|
+
// segment and only the first tool earned a follows-edge. Separators inside
|
|
258
|
+
// quoted spans are neutralized first so a pipe in an argument doesn't split
|
|
259
|
+
// (quoted content never reaches the shape anyway — shapeCommand masks it).
|
|
260
|
+
const masked = cmd.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q.replace(/[;\n|&]/g, " "));
|
|
261
|
+
for (const seg of masked.split(/[;\n|&]+/)) {
|
|
210
262
|
const m = seg.match(/\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)["']?\s*(.*)$/i);
|
|
211
263
|
if (!m) continue;
|
|
212
264
|
out.push({ name: m[1], shape: shapeCommand(`${m[1]} ${m[2] || ""}`) });
|
|
@@ -227,7 +279,8 @@ function recordPackTool(pack, tool, opts = {}) {
|
|
|
227
279
|
try {
|
|
228
280
|
const row = db.prepare("SELECT weight, intent, last_reinforced FROM pack_tool_edges WHERE pack=? AND tool=? AND command=?").get(pack, tool, command);
|
|
229
281
|
if (row) {
|
|
230
|
-
|
|
282
|
+
// Bump settles accrued decay first (same invariant as addFollow).
|
|
283
|
+
const next = bump ? effectiveWeight(row) + bump : Math.max(row.weight, weight);
|
|
231
284
|
const intent = (opts.intent != null && opts.intent !== "") ? String(opts.intent).slice(0, 200) : row.intent;
|
|
232
285
|
db.prepare("UPDATE pack_tool_edges SET weight=?, intent=?, last_reinforced=? WHERE pack=? AND tool=? AND command=?")
|
|
233
286
|
.run(next, intent, bump ? now() : row.last_reinforced, pack, tool, command);
|
|
@@ -243,15 +296,16 @@ function recordPackTool(pack, tool, opts = {}) {
|
|
|
243
296
|
}
|
|
244
297
|
|
|
245
298
|
// Strongest contextual tool usages recorded under a pack, for surfacing in that
|
|
246
|
-
// pack's injected context. Ordered by
|
|
299
|
+
// pack's injected context. Ordered by read-time weight, newest first as a
|
|
247
300
|
// tiebreak. One row per distinct command shape.
|
|
248
301
|
function packTools(pack, limit = 2) {
|
|
249
302
|
const db = openDb();
|
|
250
303
|
if (!db || !pack) return [];
|
|
251
304
|
try {
|
|
252
|
-
|
|
253
|
-
"SELECT tool, command, intent, weight FROM pack_tool_edges WHERE pack=?
|
|
254
|
-
).all(pack
|
|
305
|
+
const rows = db.prepare(
|
|
306
|
+
"SELECT tool, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE pack=?"
|
|
307
|
+
).all(pack);
|
|
308
|
+
return rankLive(rows, limit).map((r) => ({ tool: r.tool, command: r.command, intent: r.intent, weight: r.weight }));
|
|
255
309
|
} catch (e) { return []; }
|
|
256
310
|
}
|
|
257
311
|
|
|
@@ -260,9 +314,10 @@ function toolPacks(tool, limit = 5) {
|
|
|
260
314
|
const db = openDb();
|
|
261
315
|
if (!db || !tool) return [];
|
|
262
316
|
try {
|
|
263
|
-
|
|
264
|
-
"SELECT pack, command, intent, weight FROM pack_tool_edges WHERE tool=?
|
|
265
|
-
).all(tool
|
|
317
|
+
const rows = db.prepare(
|
|
318
|
+
"SELECT pack, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE tool=?"
|
|
319
|
+
).all(tool);
|
|
320
|
+
return rankLive(rows, limit).map((r) => ({ pack: r.pack, command: r.command, intent: r.intent, weight: r.weight }));
|
|
266
321
|
} catch (e) { return []; }
|
|
267
322
|
}
|
|
268
323
|
|
|
@@ -280,53 +335,25 @@ function allPackToolEdges() {
|
|
|
280
335
|
catch (e) { return []; }
|
|
281
336
|
}
|
|
282
337
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
// follows-edges and the pack↔tool
|
|
287
|
-
|
|
338
|
+
// Physically delete edges whose EFFECTIVE weight has faded under `pruneBelow` —
|
|
339
|
+
// a chain that fell out of use disappears instead of lingering as stale advice.
|
|
340
|
+
// Weights themselves are never rewritten (decay-on-read); this is pure garbage
|
|
341
|
+
// collection. Applies to both the tool→tool follows-edges and the pack↔tool
|
|
342
|
+
// contextual edges (same physics). Never-reinforced ensure-edges don't fade.
|
|
343
|
+
function prune({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
|
|
288
344
|
const db = openDb();
|
|
289
|
-
if (!db) return {
|
|
345
|
+
if (!db) return { pruned: 0 };
|
|
290
346
|
const nowMs = Date.now();
|
|
291
|
-
|
|
292
|
-
let decayed = 0, pruned = 0;
|
|
293
|
-
const decayFactor = (lastReinforced) => {
|
|
294
|
-
if (!lastReinforced) return null;
|
|
295
|
-
const ageDays = (nowMs - Date.parse(lastReinforced)) / 86400000;
|
|
296
|
-
if (!(ageDays > 0)) return null;
|
|
297
|
-
return Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
|
|
298
|
-
};
|
|
347
|
+
let pruned = 0;
|
|
299
348
|
for (const e of allEdges()) {
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
const next = e.weight * factor;
|
|
303
|
-
if (next < pruneBelow) {
|
|
304
|
-
if (removeEdge(e.src, e.dst)) pruned++;
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
if (Math.abs(next - e.weight) > 1e-6) {
|
|
308
|
-
try {
|
|
309
|
-
db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, e.src, e.dst);
|
|
310
|
-
decayed++;
|
|
311
|
-
} catch (e2) {}
|
|
312
|
-
}
|
|
349
|
+
if (!e.last_reinforced) continue;
|
|
350
|
+
if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removeEdge(e.src, e.dst)) pruned++;
|
|
313
351
|
}
|
|
314
352
|
for (const e of allPackToolEdges()) {
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
const next = e.weight * factor;
|
|
318
|
-
if (next < pruneBelow) {
|
|
319
|
-
if (removePackTool(e.pack, e.tool, e.command)) pruned++;
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
if (Math.abs(next - e.weight) > 1e-6) {
|
|
323
|
-
try {
|
|
324
|
-
db.prepare("UPDATE pack_tool_edges SET weight=? WHERE pack=? AND tool=? AND command=?").run(next, e.pack, e.tool, e.command);
|
|
325
|
-
decayed++;
|
|
326
|
-
} catch (e2) {}
|
|
327
|
-
}
|
|
353
|
+
if (!e.last_reinforced) continue;
|
|
354
|
+
if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removePackTool(e.pack, e.tool, e.command)) pruned++;
|
|
328
355
|
}
|
|
329
|
-
return {
|
|
356
|
+
return { pruned };
|
|
330
357
|
}
|
|
331
358
|
|
|
332
359
|
// Drop edges whose endpoints are no longer registered tools (a tool was removed
|
|
@@ -361,13 +388,14 @@ function pruneOrphans(toolsLib, packsLib) {
|
|
|
361
388
|
return removed;
|
|
362
389
|
}
|
|
363
390
|
|
|
364
|
-
// Nightly maintenance (called by the dream):
|
|
365
|
-
//
|
|
366
|
-
// { packsLib } to also prune pack↔tool edges whose pack was
|
|
391
|
+
// Nightly maintenance (called by the dream): drop faded and orphaned edges.
|
|
392
|
+
// No weight pass — decay is applied on read. Deterministic + safe — no model
|
|
393
|
+
// needed. Pass { packsLib } to also prune pack↔tool edges whose pack was
|
|
394
|
+
// removed.
|
|
367
395
|
function tend(toolsLib, opts = {}) {
|
|
368
|
-
const {
|
|
396
|
+
const { pruned } = prune(opts);
|
|
369
397
|
const orphaned = toolsLib ? pruneOrphans(toolsLib, opts.packsLib) : 0;
|
|
370
|
-
return {
|
|
398
|
+
return { pruned: pruned + orphaned, ...stats() };
|
|
371
399
|
}
|
|
372
400
|
|
|
373
401
|
function stats() {
|
|
@@ -384,7 +412,7 @@ module.exports = {
|
|
|
384
412
|
DEFAULTS, GRAPH_DB,
|
|
385
413
|
available, openDb,
|
|
386
414
|
addFollow, reinforceSequence, removeEdge, allEdges,
|
|
387
|
-
followers, predecessors,
|
|
415
|
+
followers, predecessors, effectiveWeight, prune, pruneOrphans, tend, stats,
|
|
388
416
|
shapeCommand, parseToolRuns, recordPackTool, packTools, toolPacks,
|
|
389
417
|
removePackTool, allPackToolEdges,
|
|
390
418
|
_resetForTest,
|