@inetafrica/open-claudia 3.0.43 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +9 -5
  3. package/bin/tool.js +15 -5
  4. package/bot.js +78 -3
  5. package/core/boot-health.js +84 -0
  6. package/core/doctor-memory.js +213 -0
  7. package/core/doctor.js +5 -0
  8. package/core/dream.js +11 -9
  9. package/core/entities.js +47 -3
  10. package/core/handlers.js +118 -1
  11. package/core/keyring.js +24 -4
  12. package/core/memory-mutation-queue.js +42 -11
  13. package/core/pack-review.js +60 -4
  14. package/core/packs.js +47 -3
  15. package/core/providers/claude-events.js +36 -3
  16. package/core/providers/events.js +11 -0
  17. package/core/recall/discoverer.js +22 -6
  18. package/core/recall/graph.js +84 -52
  19. package/core/recall/tuning.js +1 -2
  20. package/core/router.js +18 -0
  21. package/core/run-context.js +10 -4
  22. package/core/runner.js +111 -2
  23. package/core/safe-mode.js +227 -0
  24. package/core/state.js +50 -2
  25. package/core/system-prompt.js +43 -3
  26. package/core/tool-graph.js +99 -71
  27. package/core/tools.js +3 -2
  28. package/core/turn-observer.js +23 -1
  29. package/package.json +3 -3
  30. package/test-extractive-injection.js +21 -0
  31. package/test-fixtures/fake-agent-cli.js +29 -1
  32. package/test-fixtures/sim-no-sqlite.js +14 -0
  33. package/test-memory-mutation-queue.js +33 -3
  34. package/test-provider-compaction.js +5 -0
  35. package/test-provider-core-prompt.js +3 -0
  36. package/test-provider-events.js +71 -0
  37. package/test-provider-language.js +61 -1
  38. package/test-provider-recall.js +33 -0
  39. package/test-provider-runner.js +50 -0
  40. package/test-recall-discoverer.js +8 -0
  41. package/test-recall-graph.js +66 -11
  42. package/test-tool-graph.js +67 -16
  43. package/test-tool-manifest.js +32 -0
  44. package/test-tools.js +31 -0
package/core/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(event.message || "Provider reported an error", event.authError ? "PROVIDER_UNAUTHENTICATED" : (event.usageLimit ? "PROVIDER_USAGE_LIMIT" : "PROVIDER_EVENT_ERROR"), event);
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}${key.slice(prefix.length)}`;
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,
@@ -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
- const { userText, contextText } = recallMatchParts(prompt);
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
  };