@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.
- package/CHANGELOG.md +44 -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/providers/claude-events.js +36 -3
- package/core/providers/events.js +11 -0
- 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/run-context.js +10 -4
- package/core/runner.js +111 -2
- package/core/safe-mode.js +227 -0
- package/core/state.js +50 -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-fixtures/fake-agent-cli.js +29 -1
- package/test-fixtures/sim-no-sqlite.js +14 -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-events.js +71 -0
- package/test-provider-language.js +61 -1
- package/test-provider-recall.js +33 -0
- package/test-provider-runner.js +50 -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
|
@@ -14,7 +14,7 @@ const {
|
|
|
14
14
|
const {
|
|
15
15
|
currentState, saveState, recordSession,
|
|
16
16
|
resetSessionUsage, acquireRunLock, getActiveProvider, getProjectKey,
|
|
17
|
-
getProviderSession, setProviderSession, clearProviderSession, getUsageForSession, commitProviderCompaction,
|
|
17
|
+
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
|
|
18
18
|
} = require("./state");
|
|
19
19
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
20
20
|
const { buildTurnPrompt } = require("./system-prompt");
|
|
@@ -525,7 +525,11 @@ async function executeProviderInvocation(options = {}) {
|
|
|
525
525
|
else if (closeInfo.signal) failure = stableRunError(`Provider process exited on signal ${closeInfo.signal}`, "PROVIDER_SIGNAL_EXIT", { signal: closeInfo.signal });
|
|
526
526
|
else if (rawErrors.length) {
|
|
527
527
|
const event = rawErrors[0];
|
|
528
|
-
failure = stableRunError(
|
|
528
|
+
failure = stableRunError(
|
|
529
|
+
event.message || "Provider reported an error",
|
|
530
|
+
event.refusal ? "PROVIDER_REFUSAL" : (event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR")),
|
|
531
|
+
event,
|
|
532
|
+
);
|
|
529
533
|
} else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
|
|
530
534
|
else if (!terminalSeen) failure = stableRunError("Provider stream ended without a terminal event", "PROVIDER_MISSING_TERMINAL");
|
|
531
535
|
else if (!cleanupOutcome.ok) failure = cleanupOutcome.error;
|
|
@@ -1197,6 +1201,7 @@ function deriveCompactionRunContext(base, overrides = {}) {
|
|
|
1197
1201
|
resumeSessionId: overrides.resumeSessionId ?? null,
|
|
1198
1202
|
continueSession: false,
|
|
1199
1203
|
fresh: !!overrides.fresh,
|
|
1204
|
+
recallQuery: overrides.recallQuery ?? null,
|
|
1200
1205
|
skipAutoCompact: true,
|
|
1201
1206
|
requiredDelivery: false,
|
|
1202
1207
|
readOnly: true,
|
|
@@ -1210,6 +1215,25 @@ function deriveCompactionRunContext(base, overrides = {}) {
|
|
|
1210
1215
|
});
|
|
1211
1216
|
}
|
|
1212
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
|
+
|
|
1213
1237
|
function successfulTerminalCapture(result, { requireSession = false } = {}) {
|
|
1214
1238
|
return !!result?.ok && result.status === "succeeded" && result.terminalSeen === true
|
|
1215
1239
|
&& (!requireSession || (typeof result.sessionId === "string" && !!result.sessionId));
|
|
@@ -1292,6 +1316,7 @@ function createCompactionService(dependencies = {}) {
|
|
|
1292
1316
|
previousSessionId: oldSessionId,
|
|
1293
1317
|
fresh: true,
|
|
1294
1318
|
outputSchemaPath,
|
|
1319
|
+
recallQuery: seedRecallQuery(condensed || fullBrief),
|
|
1295
1320
|
});
|
|
1296
1321
|
const seedRun = await runCapture(seedPrompt, seedContext.cwd || cwd, {
|
|
1297
1322
|
runContext: seedContext,
|
|
@@ -1394,6 +1419,26 @@ function providerRunDiagnostic(result, runContext) {
|
|
|
1394
1419
|
if (code === "PROVIDER_USAGE_LIMIT") {
|
|
1395
1420
|
return `${label} reported a usage or rate limit. Check that provider's account status or select another configured provider.`;
|
|
1396
1421
|
}
|
|
1422
|
+
if (code === "PROVIDER_REFUSAL") {
|
|
1423
|
+
const details = result.error?.details || {};
|
|
1424
|
+
const reason = [
|
|
1425
|
+
details.refusalCategory ? `[${details.refusalCategory}]` : null,
|
|
1426
|
+
details.refusalExplanation || null,
|
|
1427
|
+
].filter(Boolean).join(" ");
|
|
1428
|
+
const lines = [
|
|
1429
|
+
`⛔ ${label} declined this turn — the model's safety classifier refused the request (a policy block, not a crash).`,
|
|
1430
|
+
];
|
|
1431
|
+
if (reason) lines.push(`Reason: ${reason}`);
|
|
1432
|
+
if (details.requestId) lines.push(`Request ID: ${details.requestId}`);
|
|
1433
|
+
lines.push(
|
|
1434
|
+
"",
|
|
1435
|
+
"The classifier scores the whole conversation, not just your last message — so retrying in this session usually refuses again. Options:",
|
|
1436
|
+
"• /new — fresh session without the flagged context",
|
|
1437
|
+
"• Rephrase, or drop the material that tripped it",
|
|
1438
|
+
"• /model or /backend — a different model can usually serve the same request",
|
|
1439
|
+
);
|
|
1440
|
+
return lines.join("\n");
|
|
1441
|
+
}
|
|
1397
1442
|
const detail = result.stderr ? `\n\nStderr:\n${result.stderr.slice(-1200)}` : "";
|
|
1398
1443
|
return `${label} run failed: ${result.error?.message || "unknown provider failure"}${detail}`;
|
|
1399
1444
|
}
|
|
@@ -1899,6 +1944,57 @@ function drainQueuedMessages(state) {
|
|
|
1899
1944
|
return Promise.resolve();
|
|
1900
1945
|
}
|
|
1901
1946
|
|
|
1947
|
+
// A refusal on a RESUMED session is almost always the safety classifier
|
|
1948
|
+
// rejecting flagged material in the old context — which every resume re-sends
|
|
1949
|
+
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
1950
|
+
// nothing resumes it again, then replay the same prompt once in a fresh
|
|
1951
|
+
// session. A refusal on already-fresh context gets no retry (the new message
|
|
1952
|
+
// itself is the problem), and the retry run never retries again.
|
|
1953
|
+
function shouldRetryRefusedRun(result, runContext, { capture = false, refusalRetried = false } = {}) {
|
|
1954
|
+
return !capture
|
|
1955
|
+
&& !refusalRetried
|
|
1956
|
+
&& runContext?.purpose === "foreground"
|
|
1957
|
+
&& !!runContext?.sessionId
|
|
1958
|
+
&& !!result
|
|
1959
|
+
&& !result.ok
|
|
1960
|
+
&& result.error?.code === "PROVIDER_REFUSAL";
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function quarantineRefusedRun(result, runContext, state) {
|
|
1964
|
+
const details = result.error?.details || {};
|
|
1965
|
+
const record = { reason: "refusal", requestId: details.requestId || null };
|
|
1966
|
+
try {
|
|
1967
|
+
quarantineProviderSession(state, runContext.provider, runContext.project?.name, runContext.sessionId, record);
|
|
1968
|
+
// The CLI rotates session ids on resume; the rotated id observed this run
|
|
1969
|
+
// carries the same poisoned context, so quarantine it too.
|
|
1970
|
+
if (result.sessionId && result.sessionId !== runContext.sessionId) {
|
|
1971
|
+
quarantineProviderSession(state, runContext.provider, runContext.project?.name, result.sessionId, record);
|
|
1972
|
+
}
|
|
1973
|
+
saveState({ strict: true });
|
|
1974
|
+
return true;
|
|
1975
|
+
} catch (error) {
|
|
1976
|
+
console.error("refusal quarantine failed:", redactSensitive(String(error.message || error)));
|
|
1977
|
+
return false;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function refusalRetryContext(runContext, state) {
|
|
1982
|
+
return deepFreeze({
|
|
1983
|
+
...runContext,
|
|
1984
|
+
runId: typeof crypto.randomUUID === "function"
|
|
1985
|
+
? crypto.randomUUID()
|
|
1986
|
+
: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
1987
|
+
admittedAt: Date.now(),
|
|
1988
|
+
sessionId: null,
|
|
1989
|
+
admittedSessionId: null,
|
|
1990
|
+
previousSessionId: getProviderSession(state, runContext.provider, runContext.project?.name) || null,
|
|
1991
|
+
fresh: true,
|
|
1992
|
+
continueSession: false,
|
|
1993
|
+
resumeSessionId: null,
|
|
1994
|
+
quarantinedResume: true,
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1902
1998
|
async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
1903
1999
|
const tracksMainRun = ownsAdmissionLock && !capture;
|
|
1904
2000
|
if (tracksMainRun) state.activeRunContext = runContext;
|
|
@@ -1928,6 +2024,16 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
1928
2024
|
stopTyping();
|
|
1929
2025
|
}
|
|
1930
2026
|
|
|
2027
|
+
if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
|
|
2028
|
+
&& quarantineRefusedRun(result, runContext, state)) {
|
|
2029
|
+
try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
|
|
2030
|
+
// Re-arm the admission lock across the notice's async gap so a racing
|
|
2031
|
+
// incoming message queues behind the retry instead of interleaving.
|
|
2032
|
+
if (ownsAdmissionLock) state.preparingRun = true;
|
|
2033
|
+
await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
|
|
2034
|
+
return executeAdmittedRun(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1931
2037
|
if (tracksMainRun) {
|
|
1932
2038
|
sideChatCoordinator.noteMainResult(runContext, result);
|
|
1933
2039
|
for (const item of state.messageQueue) {
|
|
@@ -2114,6 +2220,9 @@ module.exports = {
|
|
|
2114
2220
|
runAgent,
|
|
2115
2221
|
runAgentCapture,
|
|
2116
2222
|
runAgentSilent,
|
|
2223
|
+
shouldRetryRefusedRun,
|
|
2224
|
+
quarantineRefusedRun,
|
|
2225
|
+
refusalRetryContext,
|
|
2117
2226
|
shouldStartSideChat,
|
|
2118
2227
|
runSideChatRequest,
|
|
2119
2228
|
enqueueSideChatRequest,
|
|
@@ -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
|
@@ -428,6 +428,50 @@ function clearProviderSession(state, providerId, projectName) {
|
|
|
428
428
|
return true;
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
const MAX_QUARANTINED_SESSIONS = 20;
|
|
432
|
+
|
|
433
|
+
function boundQuarantinedSessions(source) {
|
|
434
|
+
if (!isRecord(source)) return {};
|
|
435
|
+
return Object.fromEntries(
|
|
436
|
+
Object.entries(source)
|
|
437
|
+
.filter(([sessionId, record]) => sessionId && isRecord(record))
|
|
438
|
+
.sort((a, b) => (a[1].at || 0) - (b[1].at || 0))
|
|
439
|
+
.slice(-MAX_QUARANTINED_SESSIONS)
|
|
440
|
+
.map(([sessionId, record]) => [sessionId, { ...record }]),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A quarantined session is one whose provider-side context tripped the model's
|
|
445
|
+
// safety classifier (stop_reason "refusal"). Resuming it re-sends the flagged
|
|
446
|
+
// context and refuses again, so it must never be resumed — the run-context
|
|
447
|
+
// capture forces such turns fresh. Quarantining also drops the active pointer
|
|
448
|
+
// when it still points at the poisoned session.
|
|
449
|
+
function quarantineProviderSession(state, providerId, projectName, sessionId, details = {}) {
|
|
450
|
+
const provider = normalizeProviderId(providerId, null);
|
|
451
|
+
if (!state || !provider || typeof sessionId !== "string" || !sessionId) return false;
|
|
452
|
+
if (!isRecord(state.quarantinedSessions)) state.quarantinedSessions = {};
|
|
453
|
+
state.quarantinedSessions[sessionId] = {
|
|
454
|
+
provider,
|
|
455
|
+
project: projectNameFrom(projectName) || getProjectKey(state, projectName) || null,
|
|
456
|
+
reason: String(details.reason || "refusal"),
|
|
457
|
+
requestId: details.requestId || null,
|
|
458
|
+
at: Number.isFinite(details.at) ? details.at : Date.now(),
|
|
459
|
+
};
|
|
460
|
+
state.quarantinedSessions = boundQuarantinedSessions(state.quarantinedSessions);
|
|
461
|
+
const project = getProjectKey(state, projectName);
|
|
462
|
+
if (project && getProviderSession(state, provider, project) === sessionId) {
|
|
463
|
+
clearProviderSession(state, provider, project);
|
|
464
|
+
}
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isSessionQuarantined(state, sessionId) {
|
|
469
|
+
return !!(sessionId
|
|
470
|
+
&& typeof sessionId === "string"
|
|
471
|
+
&& isRecord(state?.quarantinedSessions)
|
|
472
|
+
&& isRecord(state.quarantinedSessions[sessionId]));
|
|
473
|
+
}
|
|
474
|
+
|
|
431
475
|
function getProviderSettings(state, providerId) {
|
|
432
476
|
if (!state || typeof state !== "object") throw new TypeError("state is required");
|
|
433
477
|
const provider = normalizeProviderId(providerId || getActiveProvider(state), null);
|
|
@@ -542,6 +586,7 @@ function createUserState(userId) {
|
|
|
542
586
|
streamBuffer: "",
|
|
543
587
|
streamInterval: null,
|
|
544
588
|
activeSessions: normalizeActiveSessions(saved.activeSessions),
|
|
589
|
+
quarantinedSessions: boundQuarantinedSessions(saved.quarantinedSessions),
|
|
545
590
|
providerSettings: normalizeProviderSettings(saved.providerSettings),
|
|
546
591
|
usageBySession: boundUsageBySession(saved.usageBySession),
|
|
547
592
|
messageQueue: [],
|
|
@@ -584,10 +629,10 @@ function seedConversationContinuity(state) {
|
|
|
584
629
|
if (!isRecord(legacy) || isRecord(state.activeSessions[convo])) return;
|
|
585
630
|
state.activeSessions[convo] = { ...legacy };
|
|
586
631
|
if (isRecord(state.usageBySession)) {
|
|
587
|
-
const prefix = "Workspace
|
|
632
|
+
const prefix = "Workspace\u0000";
|
|
588
633
|
for (const [key, usage] of Object.entries(state.usageBySession)) {
|
|
589
634
|
if (key.startsWith(prefix) && isRecord(usage)) {
|
|
590
|
-
const moved = `${convo}
|
|
635
|
+
const moved = `${convo}\u0000${key.slice(prefix.length)}`;
|
|
591
636
|
if (!state.usageBySession[moved]) state.usageBySession[moved] = { ...usage };
|
|
592
637
|
}
|
|
593
638
|
}
|
|
@@ -693,6 +738,7 @@ function serializeUserState(state, existing = {}) {
|
|
|
693
738
|
...existing,
|
|
694
739
|
currentSession: state.currentSession,
|
|
695
740
|
activeSessions: normalizeActiveSessions(state.activeSessions),
|
|
741
|
+
quarantinedSessions: boundQuarantinedSessions(state.quarantinedSessions),
|
|
696
742
|
providerSettings: normalizeProviderSettings(state.providerSettings),
|
|
697
743
|
usageBySession: boundUsageBySession(state.usageBySession),
|
|
698
744
|
settings: serializeCommonSettings(state.settings),
|
|
@@ -1367,6 +1413,8 @@ module.exports = {
|
|
|
1367
1413
|
getProviderSession,
|
|
1368
1414
|
setProviderSession,
|
|
1369
1415
|
clearProviderSession,
|
|
1416
|
+
quarantineProviderSession,
|
|
1417
|
+
isSessionQuarantined,
|
|
1370
1418
|
getProviderSettings,
|
|
1371
1419
|
getActiveUsage,
|
|
1372
1420
|
getUsageForSession,
|
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
|
};
|