@inetafrica/open-claudia 3.0.44 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,14 @@
12
12
  // `weight` and `last_reinforced`. Structural edges (parent/related from pack
13
13
  // metadata) are re-derived idempotently; their weight floor is kept but any
14
14
  // extra reinforcement earned through use is preserved.
15
+ //
16
+ // Decay is computed ON READ, never rewritten in place ("decay-on-read"): the
17
+ // stored `weight` is the value AS OF `last_reinforced`, and every read maps it
18
+ // through effectiveWeight(). Writes that change a weight (reinforce, weaken)
19
+ // first settle the decay accrued so far, then apply their delta and restamp.
20
+ // The old nightly decay() rewrite reapplied the full age to an already-decayed
21
+ // weight every night (compounding — a 60d half-life emptied in ~10 nights);
22
+ // with no rewrite path, that bug class cannot exist.
15
23
 
16
24
  const fs = require("fs");
17
25
  const path = require("path");
@@ -29,13 +37,14 @@ const EDGE_TYPES = ["parent", "governed-by", "related"];
29
37
  // Base weights when an edge is first derived from structure.
30
38
  const BASE_WEIGHT = { parent: 3, "governed-by": 2.5, related: 1 };
31
39
  // Spreading-activation tunables (overridable via env / dream knobs).
40
+ // The decay half-life lives in tuning.js (`decayHalfLifeDays`, env-pinnable),
41
+ // read lazily so a dream knob change applies to the very next read.
32
42
  const DEFAULTS = {
33
43
  hops: 2,
34
44
  threshold: 0.18, // firing threshold as a fraction of the strongest seed
35
45
  damping: 0.55, // fraction of activation transmitted per hop
36
46
  perNodeTopK: 6, // cap fan-out per node (sparsity)
37
47
  maxWeight: 6, // weight at which an edge transmits at full damping
38
- halfLifeDays: 60, // Hebbian weight half-life for decay()
39
48
  };
40
49
 
41
50
  let _db = null;
@@ -62,6 +71,17 @@ function openDb() {
62
71
  )`);
63
72
  db.exec("CREATE INDEX IF NOT EXISTS edges_src ON edges (src)");
64
73
  db.exec("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
74
+ // v1 migration: legacy DBs carry weights the old nightly decay() rewrite
75
+ // had already over-decayed (it reapplied the full age every night). Treat
76
+ // stored values as current and restart the clock — decay is read-time from
77
+ // here on, so this is the last time the stamp is touched in bulk.
78
+ try {
79
+ const uv = db.prepare("PRAGMA user_version").get() || {};
80
+ if (Number(uv.user_version || 0) < 1) {
81
+ db.prepare("UPDATE edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(now());
82
+ db.exec("PRAGMA user_version = 1");
83
+ }
84
+ } catch (e) {}
65
85
  _db = db;
66
86
  return db;
67
87
  } catch (e) {
@@ -76,6 +96,29 @@ function normType(t) {
76
96
  return EDGE_TYPES.includes(s) ? s : "related";
77
97
  }
78
98
 
99
+ // Effective decay half-life: explicit override > dream-tunable knob (env-pinnable).
100
+ function currentHalfLife(opts = {}) {
101
+ if (Number.isFinite(opts.halfLifeDays)) return Math.max(1, opts.halfLifeDays);
102
+ let hl;
103
+ try { hl = require("./tuning").get("decayHalfLifeDays"); } catch (e) {}
104
+ return Number.isFinite(hl) ? Math.max(1, hl) : 60;
105
+ }
106
+
107
+ // Read-time weight of an edge row: exponential decay of the stored weight since
108
+ // last_reinforced, clamped at the structural floor. Pure — storage is never
109
+ // touched. An edge that was never reinforced (last_reinforced null) does not
110
+ // decay: it sits at its structural base until first use.
111
+ function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = currentHalfLife()) {
112
+ if (!e || !Number.isFinite(e.weight)) return 0;
113
+ if (!e.last_reinforced) return e.weight;
114
+ const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
115
+ if (!(ageDays > 0)) return e.weight;
116
+ const floor = BASE_WEIGHT[e.type] || 0;
117
+ if (e.weight <= floor) return e.weight;
118
+ const f = Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
119
+ return Math.max(floor, e.weight * f);
120
+ }
121
+
79
122
  // Upsert an edge. By default keeps the larger of the existing/new weight so a
80
123
  // structural re-derive never stomps reinforcement; pass `{ bump }` to add to
81
124
  // the current weight (Hebbian) and stamp last_reinforced.
@@ -86,15 +129,19 @@ function addEdge(src, dst, type, opts = {}) {
86
129
  const weight = Number.isFinite(opts.weight) ? opts.weight : (BASE_WEIGHT[t] || 1);
87
130
  const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
88
131
  try {
89
- const existing = db.prepare("SELECT weight FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
132
+ const existing = db.prepare("SELECT weight, last_reinforced FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
90
133
  if (existing) {
91
- const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
92
- const stamp = bump ? now() : null;
93
- if (stamp) {
134
+ if (bump) {
135
+ // Settle the decay accrued since last_reinforced, then bump and restamp
136
+ // — keeps the invariant that the stored weight is valued at its stamp.
137
+ const settled = effectiveWeight({ ...existing, type: t });
94
138
  db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?")
95
- .run(next, stamp, src, dst, t);
139
+ .run(settled + bump, now(), src, dst, t);
96
140
  } else {
97
- db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
141
+ const next = Math.max(existing.weight, weight);
142
+ if (next !== existing.weight) {
143
+ db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
144
+ }
98
145
  }
99
146
  } else {
100
147
  db.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
@@ -123,18 +170,21 @@ function allEdges() {
123
170
  catch (e) { return []; }
124
171
  }
125
172
 
126
- // Undirected neighbours of a node: edges where it is src OR dst.
173
+ // Undirected neighbours of a node: edges where it is src OR dst. Weights are
174
+ // read-time effective values.
127
175
  function neighbors(nodeId) {
128
176
  const db = openDb();
129
177
  if (!db || !nodeId) return [];
130
178
  try {
131
179
  const rows = db.prepare(
132
- "SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?"
180
+ "SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?"
133
181
  ).all(nodeId, nodeId);
182
+ const nowMs = Date.now();
183
+ const hl = currentHalfLife();
134
184
  return rows.map((r) => ({
135
185
  node: r.src === nodeId ? r.dst : r.src,
136
186
  type: r.type,
137
- weight: r.weight,
187
+ weight: effectiveWeight(r, nowMs, hl),
138
188
  }));
139
189
  } catch (e) { return []; }
140
190
  }
@@ -158,24 +208,30 @@ function reinforceSet(nodeIds, amount = 1) {
158
208
 
159
209
  // Negative Hebbian: weaken every edge touching a node that keeps surfacing
160
210
  // via the graph but never gets kept or used — firing wrongly, so unwire.
161
- // Same floor contract as decay(); additionally, `related` edges that fall to
162
- // their structural base are deleted outright: a link-derived edge re-derives
163
- // at base weight on the next sync (harmless), while a co-use edge that earned
164
- // its weight from wrong firings is gone for good.
211
+ // Operates on the EFFECTIVE weight (settle accrued decay first, then apply the
212
+ // factor and restamp — same invariant as the reinforce path). Floor contract
213
+ // as everywhere: never below the type's base weight. Additionally, `related`
214
+ // edges that fall to their structural base are deleted outright: a
215
+ // link-derived edge re-derives at base weight on the next sync (harmless),
216
+ // while a co-use edge that earned its weight from wrong firings is gone for
217
+ // good.
165
218
  function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
166
219
  const db = openDb();
167
220
  if (!db || !nodeId) return { weakened: 0, pruned: 0 };
168
221
  let weakened = 0, pruned = 0;
169
222
  try {
170
- const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
223
+ const rows = db.prepare("SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
224
+ const nowMs = Date.now();
225
+ const hl = currentHalfLife();
226
+ const stamp = now();
171
227
  for (const e of rows) {
172
228
  const floor = BASE_WEIGHT[e.type] || 0;
173
- const next = Math.max(floor, e.weight * factor);
229
+ const next = Math.max(floor, effectiveWeight(e, nowMs, hl) * factor);
174
230
  if (prune && e.type === "related" && next <= floor + 0.05) {
175
231
  db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
176
232
  pruned++;
177
233
  } else if (Math.abs(next - e.weight) > 1e-6) {
178
- db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, e.src, e.dst, e.type);
234
+ db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?").run(next, stamp, e.src, e.dst, e.type);
179
235
  weakened++;
180
236
  }
181
237
  }
@@ -183,33 +239,6 @@ function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
183
239
  return { weakened, pruned };
184
240
  }
185
241
 
186
- // Exponential time decay on reinforced weight. Structural floor is preserved:
187
- // an edge never decays below its type's base weight.
188
- function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
189
- const db = openDb();
190
- if (!db) return 0;
191
- const rows = allEdges();
192
- const nowMs = Date.now();
193
- const ln2 = Math.log(2);
194
- let changed = 0;
195
- for (const e of rows) {
196
- if (!e.last_reinforced) continue;
197
- const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
198
- if (!(ageDays > 0)) continue;
199
- const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
200
- const floor = BASE_WEIGHT[e.type] || 0;
201
- const next = Math.max(floor, e.weight * factor);
202
- if (Math.abs(next - e.weight) > 1e-6) {
203
- try {
204
- db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?")
205
- .run(next, e.src, e.dst, e.type);
206
- changed++;
207
- } catch (e2) {}
208
- }
209
- }
210
- return changed;
211
- }
212
-
213
242
  // Spreading activation. `seeds` is [{ id, score }] (score = match strength).
214
243
  // Returns Map id -> { activation, hop, via } for nodes that fired above the
215
244
  // threshold, EXCLUDING the original seeds (those are already in the result set
@@ -220,10 +249,13 @@ function expand(seeds, opts = {}) {
220
249
  const seedList = (seeds || []).filter((s) => s && s.id);
221
250
  if (!seedList.length || !available()) return out;
222
251
 
223
- // Build adjacency once, keeping the strongest edge per (node, neighbour).
252
+ // Build adjacency once over READ-TIME weights, keeping the strongest edge
253
+ // per (node, neighbour).
224
254
  const adj = new Map();
255
+ const nowMs = Date.now();
256
+ const hl = currentHalfLife(opts);
225
257
  for (const e of allEdges()) {
226
- const w = e.weight;
258
+ const w = effectiveWeight(e, nowMs, hl);
227
259
  const push = (from, to) => {
228
260
  if (!adj.has(from)) adj.set(from, new Map());
229
261
  const m = adj.get(from);
@@ -378,14 +410,14 @@ function pruneOrphans(packsLib, entitiesLib) {
378
410
  return removed;
379
411
  }
380
412
 
381
- // Nightly maintenance: refresh structural edges, decay reinforced weights,
382
- // drop orphans. Deterministic + safe — the high-judgment "extract shared
413
+ // Nightly maintenance: refresh structural edges, drop orphans. There is no
414
+ // weight pass — decay is applied on read (effectiveWeight), so nightly has
415
+ // nothing to rewrite. Deterministic + safe — the high-judgment "extract shared
383
416
  // concern node" surgery is left to the dream model proper.
384
- function tend(packsLib, entitiesLib, opts = {}) {
417
+ function tend(packsLib, entitiesLib) {
385
418
  const synced = syncFromCorpus(packsLib, entitiesLib);
386
- const decayed = decay(opts);
387
419
  const pruned = pruneOrphans(packsLib, entitiesLib);
388
- return { synced: synced.edges, decayed, pruned, ...stats() };
420
+ return { synced: synced.edges, pruned, ...stats() };
389
421
  }
390
422
 
391
423
  function stats() {
@@ -404,7 +436,7 @@ module.exports = {
404
436
  EDGE_TYPES, DEFAULTS, GRAPH_DB,
405
437
  available, openDb,
406
438
  addEdge, removeEdge, allEdges, neighbors,
407
- reinforce, reinforceSet, decay, weakenNode, expand,
439
+ reinforce, reinforceSet, effectiveWeight, weakenNode, expand,
408
440
  syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
409
441
  _resetForTest,
410
442
  };
@@ -132,8 +132,7 @@ function history() { return readFile().history || []; }
132
132
 
133
133
  function write(data) {
134
134
  try {
135
- fs.mkdirSync(path.dirname(TUNING_FILE), { recursive: true });
136
- fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2));
135
+ require("../fsutil").atomicWriteFileSync(TUNING_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
137
136
  } catch (e) {}
138
137
  _cache = null; _cacheMtime = -1;
139
138
  }
package/core/router.js CHANGED
@@ -27,6 +27,7 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
27
27
  const keyring = require("./keyring");
28
28
  const { classifyPastedCredential } = require("./credential-detect");
29
29
  const channelWizard = require("./channel-wizard");
30
+ const safeModeLib = require("./safe-mode");
30
31
  require("./handlers"); // side-effect: register slash commands
31
32
 
32
33
  const USER_VISIBLE_PROVIDER_ERRORS = new Set([
@@ -96,6 +97,19 @@ async function onMessage(envelope) {
96
97
  // owner's canonical bucket on first contact, before any state is read.
97
98
  try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
98
99
 
100
+ // Safe mode: only the diagnosis/rescue command set runs; everything else
101
+ // gets a short banner pointing at /safemode. Preempts wizards, media and
102
+ // agent turns — nothing may write memory or spawn a provider here.
103
+ if (safeModeLib.isSafeMode()) {
104
+ const raw = envelope.text || "";
105
+ const isCommand = envelope.type === "command" || raw.startsWith("/");
106
+ if (!isCommand || !safeModeLib.commandAllowed(raw)) {
107
+ if (isDuplicate(envelope)) return;
108
+ await send(safeModeLib.banner());
109
+ return;
110
+ }
111
+ }
112
+
99
113
  // A channel wizard listening for the owner's message on a freshly-added
100
114
  // adapter consumes that inbound message here: it arrives on a different
101
115
  // channel than the operator's, so the isAwaiting() gate below (keyed to
@@ -151,6 +165,10 @@ async function onMessage(envelope) {
151
165
  async function onAction(envelope) {
152
166
  return scope(envelope, async () => {
153
167
  try {
168
+ if (safeModeLib.isSafeMode()) {
169
+ try { await send(safeModeLib.banner()); } catch (e) {}
170
+ return;
171
+ }
154
172
  if (await channelWizard.handleAction(envelope)) return;
155
173
  await handleAction(envelope);
156
174
  }
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}${key.slice(prefix.length)}`;
635
+ const moved = `${convo}\u0000${key.slice(prefix.length)}`;
636
636
  if (!state.usageBySession[moved]) state.usageBySession[moved] = { ...usage };
637
637
  }
638
638
  }
@@ -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
  };