@inetafrica/open-claudia 3.0.44 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +9 -5
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +118 -1
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +48 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
package/core/recall/graph.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
139
|
+
.run(settled + bump, now(), src, dst, t);
|
|
96
140
|
} else {
|
|
97
|
-
|
|
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
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
382
|
-
//
|
|
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
|
|
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,
|
|
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,
|
|
439
|
+
reinforce, reinforceSet, effectiveWeight, weakenNode, expand,
|
|
408
440
|
syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
|
|
409
441
|
_resetForTest,
|
|
410
442
|
};
|
package/core/recall/tuning.js
CHANGED
|
@@ -132,8 +132,7 @@ function history() { return readFile().history || []; }
|
|
|
132
132
|
|
|
133
133
|
function write(data) {
|
|
134
134
|
try {
|
|
135
|
-
|
|
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}
|
|
635
|
+
const moved = `${convo}\u0000${key.slice(prefix.length)}`;
|
|
636
636
|
if (!state.usageBySession[moved]) state.usageBySession[moved] = { ...usage };
|
|
637
637
|
}
|
|
638
638
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -539,6 +539,23 @@ function consumeLastInjected() {
|
|
|
539
539
|
return out;
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
// A Stance line carrying the word LOCKED (uppercase — prose "locked" stays
|
|
543
|
+
// put) is a constraint the user explicitly froze. The branding-font incident
|
|
544
|
+
// showed such a line sitting mid-Stance reads as background prose and gets
|
|
545
|
+
// violated; hoist LOCKED lines under a must-follow header so they carry rule
|
|
546
|
+
// authority in every injection mode.
|
|
547
|
+
function formatStanceSection(stance) {
|
|
548
|
+
const lines = stance.split("\n");
|
|
549
|
+
const locked = lines.map((l) => l.trim()).filter((l) => l && /\bLOCKED\b/.test(l));
|
|
550
|
+
if (!locked.length) return stance;
|
|
551
|
+
const rest = lines.filter((l) => !/\bLOCKED\b/.test(l)).join("\n").trim();
|
|
552
|
+
const block = [
|
|
553
|
+
"BINDING CONSTRAINTS (LOCKED — follow exactly; do not deviate or reinterpret unless the user explicitly unlocks them this turn):",
|
|
554
|
+
...locked.map((l) => (/^[-*]/.test(l) ? l : `- ${l}`)),
|
|
555
|
+
].join("\n");
|
|
556
|
+
return rest ? `${block}\n\n${rest}` : block;
|
|
557
|
+
}
|
|
558
|
+
|
|
542
559
|
function formatPackForContext(pack, packsLib, opts = {}) {
|
|
543
560
|
const extractive = !!opts.extractive;
|
|
544
561
|
const headline = !!opts.headline;
|
|
@@ -564,7 +581,7 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
564
581
|
// still used by the classic engine and as the fallback when no lines matched.
|
|
565
582
|
if (extractive) {
|
|
566
583
|
const stance = (pack.sections.Stance || "").trim();
|
|
567
|
-
if (stance) parts.push(`#### Stance\n${stance}`);
|
|
584
|
+
if (stance) parts.push(`#### Stance\n${formatStanceSection(stance)}`);
|
|
568
585
|
const stanceLow = stance.toLowerCase();
|
|
569
586
|
const bySection = new Map();
|
|
570
587
|
for (const h of (Array.isArray(opts.matchedLines) ? opts.matchedLines : [])) {
|
|
@@ -598,7 +615,7 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
598
615
|
: ["Stance", "Procedure", "State"];
|
|
599
616
|
for (const section of sections) {
|
|
600
617
|
const body = (pack.sections[section] || "").trim();
|
|
601
|
-
if (body) parts.push(`#### ${section}\n${body}`);
|
|
618
|
+
if (body) parts.push(`#### ${section}\n${section === "Stance" ? formatStanceSection(body) : body}`);
|
|
602
619
|
}
|
|
603
620
|
if (headline) {
|
|
604
621
|
const hidden = ["State", "Procedure", "Journal"].filter((s) => (pack.sections[s] || "").trim());
|
|
@@ -984,7 +1001,25 @@ function voiceReplyGuidance(opts = {}) {
|
|
|
984
1001
|
|
|
985
1002
|
async function buildOptionalRecallContext(prompt, opts = {}) {
|
|
986
1003
|
const collector = { packs: [], packDirs: [], entities: [], recall: null };
|
|
987
|
-
|
|
1004
|
+
// Compaction meta-turns: the summarizer's prompt is instructions about
|
|
1005
|
+
// summarizing, so matching packs against it is pure noise — skip recall.
|
|
1006
|
+
// The seed turn is the opposite: it opens a FRESH session, so recall is the
|
|
1007
|
+
// only way the packs active before compaction come back — match on the
|
|
1008
|
+
// runner-supplied recallQuery (condensed brief + open task titles), not the
|
|
1009
|
+
// seed prompt's boilerplate.
|
|
1010
|
+
if (opts.runContext?.purpose === "compact-summary") {
|
|
1011
|
+
return {
|
|
1012
|
+
dynamicContext: "",
|
|
1013
|
+
metadata: { status: "omitted", omitted: true, reason: "compaction_meta_turn" },
|
|
1014
|
+
legacy: collector,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const queryOverride = typeof opts.runContext?.recallQuery === "string" && opts.runContext.recallQuery.trim()
|
|
1018
|
+
? opts.runContext.recallQuery
|
|
1019
|
+
: null;
|
|
1020
|
+
const { userText, contextText } = queryOverride
|
|
1021
|
+
? { userText: stripRecallAnnouncements(queryOverride), contextText: "" }
|
|
1022
|
+
: recallMatchParts(prompt);
|
|
988
1023
|
let historyText = "";
|
|
989
1024
|
try {
|
|
990
1025
|
historyText = stripRecallAnnouncements(require("./transcripts").recentTranscriptText({ limit: 6 }, promptState(opts)));
|
|
@@ -1085,12 +1120,17 @@ function createPromptBuilder(dependencies = {}) {
|
|
|
1085
1120
|
throw new TypeError("recall builder returned an invalid result");
|
|
1086
1121
|
}
|
|
1087
1122
|
} catch (error) {
|
|
1123
|
+
// Fail-open by design (a broken recall must not block the turn), but
|
|
1124
|
+
// never silently: the error goes to the bot log here, and the turn
|
|
1125
|
+
// observer counts consecutive failures and alerts the owner (B5).
|
|
1126
|
+
try { console.error("[recall] builder failed:", error?.message || error); } catch (_) {}
|
|
1088
1127
|
recallResult = {
|
|
1089
1128
|
dynamicContext: "",
|
|
1090
1129
|
metadata: {
|
|
1091
1130
|
status: "omitted",
|
|
1092
1131
|
omitted: true,
|
|
1093
1132
|
reason: "optional_recall_unavailable",
|
|
1133
|
+
detail: String(error?.message || error).slice(0, 200),
|
|
1094
1134
|
},
|
|
1095
1135
|
legacy: { packs: [], packDirs: [], entities: [], recall: null },
|
|
1096
1136
|
};
|