@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
@@ -4,6 +4,7 @@ const {
4
4
  normalizeDecoderError,
5
5
  normalizedUsage,
6
6
  providerErrorFlags,
7
+ refusalDetailsFromMessage,
7
8
  } = require("./events");
8
9
 
9
10
  function createClaudeParserState() {
@@ -14,6 +15,7 @@ function createClaudeParserState() {
14
15
  tools: new Map(),
15
16
  seenUsage: new Set(),
16
17
  totalCostUsd: 0,
18
+ refusal: null,
17
19
  };
18
20
  }
19
21
 
@@ -30,6 +32,27 @@ function claudeErrorMessage(rawEvent) {
30
32
  return "Claude provider failed";
31
33
  }
32
34
 
35
+ function captureRefusal(state, stopReason, stopDetails) {
36
+ if (stopReason !== "refusal") return;
37
+ state.refusal = {
38
+ category: stopDetails?.category ?? null,
39
+ explanation: stopDetails?.explanation ?? null,
40
+ };
41
+ }
42
+
43
+ function claudeFailureEvent(message, state) {
44
+ const flags = providerErrorFlags(message);
45
+ const event = { type: "error", message, ...flags };
46
+ if (flags.refusal || state.refusal) {
47
+ const parsed = refusalDetailsFromMessage(message);
48
+ event.refusal = true;
49
+ event.refusalCategory = state.refusal?.category ?? null;
50
+ event.refusalExplanation = state.refusal?.explanation || parsed.explanation || null;
51
+ event.requestId = parsed.requestId;
52
+ }
53
+ return event;
54
+ }
55
+
33
56
  function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
34
57
  if (!rawEvent || typeof rawEvent !== "object") return [];
35
58
  const decoderFailure = normalizeDecoderError(rawEvent);
@@ -41,6 +64,9 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
41
64
  }
42
65
 
43
66
  if (rawEvent.type === "stream_event") {
67
+ if (rawEvent.event?.type === "message_delta") {
68
+ captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
69
+ }
44
70
  const delta = rawEvent.event?.type === "content_block_delta" ? rawEvent.event.delta : null;
45
71
  if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
46
72
  state.sawText = true;
@@ -51,6 +77,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
51
77
 
52
78
  if (rawEvent.type === "assistant") {
53
79
  const events = [];
80
+ captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
54
81
  for (const block of rawEvent.message?.content || []) {
55
82
  if (block?.type === "text" && typeof block.text === "string" && block.text) {
56
83
  state.sawText = true;
@@ -95,8 +122,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
95
122
  state.terminal = true;
96
123
  if (Number.isFinite(rawEvent.total_cost_usd)) state.totalCostUsd = rawEvent.total_cost_usd;
97
124
  if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
98
- const message = claudeErrorMessage(rawEvent);
99
- return [{ type: "error", message, ...providerErrorFlags(message) }];
125
+ return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
100
126
  }
101
127
  const events = [];
102
128
  const session = sessionEvent(rawEvent.session_id, state);
@@ -104,12 +130,19 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
104
130
  const usage = normalizedUsage(rawEvent.usage, "turn", state.seenUsage);
105
131
  if (usage) events.push(usage);
106
132
  const hasStructuredOutput = Object.prototype.hasOwnProperty.call(rawEvent, "structured_output");
133
+ const resultText = typeof rawEvent.result === "string" ? rawEvent.result : "";
134
+ if (state.refusal && !hasStructuredOutput && !state.sawText && !resultText.trim()) {
135
+ // A refusal the CLI reports as a "successful" empty turn would otherwise
136
+ // surface as "(no output)" — fail it with the real reason instead.
137
+ events.push(claudeFailureEvent('The model refused this request (stop_reason "refusal").', state));
138
+ return events;
139
+ }
107
140
  const structuredText = hasStructuredOutput ? JSON.stringify(rawEvent.structured_output) : "";
108
141
  events.push({
109
142
  type: "result",
110
143
  text: hasStructuredOutput
111
144
  ? (structuredText === undefined ? "" : structuredText)
112
- : (state.sawText ? "" : (typeof rawEvent.result === "string" ? rawEvent.result : "")),
145
+ : (state.sawText ? "" : resultText),
113
146
  sessionId: rawEvent.session_id || state.sessionId || null,
114
147
  });
115
148
  return events;
@@ -131,9 +131,19 @@ function providerErrorFlags(message) {
131
131
  return {
132
132
  authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
133
133
  usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit/.test(text),
134
+ refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
134
135
  };
135
136
  }
136
137
 
138
+ // The CLI flattens the API's stop_details into the error text; recover the
139
+ // human-readable pieces so the chat message can lead with the actual reason.
140
+ function refusalDetailsFromMessage(message) {
141
+ const text = String(message || "");
142
+ const requestId = text.match(/\breq_[A-Za-z0-9]+\b/)?.[0] || null;
143
+ const explanation = text.match(/This request triggered[^.]+\./i)?.[0]?.trim() || null;
144
+ return { requestId, explanation };
145
+ }
146
+
137
147
  function normalizeDecoderError(rawEvent) {
138
148
  if (!rawEvent || rawEvent.type !== "decoder.error") return null;
139
149
  // Warning, not error: a single oversized or malformed record is skippable.
@@ -171,6 +181,7 @@ module.exports = {
171
181
  normalizeDecoderError,
172
182
  normalizedUsage,
173
183
  providerErrorFlags,
184
+ refusalDetailsFromMessage,
174
185
  selectTerminalText,
175
186
  usageFingerprint,
176
187
  };
@@ -150,18 +150,34 @@ function maybeSync(packsLib, entitiesLib) {
150
150
  // seed — with a 147-pack corpus that is virtually every turn (measured: gated
151
151
  // 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
152
152
  // skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
153
- // seeds — short command-like turns (≤4 words): keyword seeds are precise
154
- // enough; inject user-origin seeds directly and skip graph + walker
155
- // (the classic baseline, ~ms instead of ~13s).
153
+ // seeds — short command-like turns (≤4 words after any pleasantry prefix):
154
+ // keyword seeds are precise enough; inject user-origin seeds directly
155
+ // and skip graph + walker (the classic baseline, ~ms instead of ~13s).
156
156
  // full — substantive turns: graph expansion + walker judgement.
157
157
  const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
158
158
 
159
+ // B2: a pleasantry PREFIX must not hide a real request from the gate — "ok
160
+ // deploy kazee now" is a deploy command wearing an ack, yet the old ≤4-word
161
+ // rule skipped recall for it entirely. Strip leading pleasantry tokens
162
+ // (stacked ones too) plus separators and tier what remains; a pure pleasantry
163
+ // chain strips to "" and skips regardless of its length.
164
+ function stripPleasantryPrefix(t) {
165
+ let s = t;
166
+ for (;;) {
167
+ const m = PLEASANTRY_RE.exec(s);
168
+ if (!m) return s;
169
+ s = s.slice(m[0].length).replace(/^[\s,.!?;:—–-]+/, "");
170
+ if (!s) return "";
171
+ }
172
+ }
173
+
159
174
  function recallTier(userText, seedCount) {
160
175
  const t = String(userText || "").trim().toLowerCase();
161
176
  if (!t) return "skip";
162
- const words = t.split(/\s+/).filter(Boolean);
163
177
  if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
164
- if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
178
+ const core = stripPleasantryPrefix(t);
179
+ if (!core) return "skip"; // pure pleasantry/ack chain
180
+ const words = core.split(/\s+/).filter(Boolean);
165
181
  if (words.length <= 2 && seedCount === 0) return "skip";
166
182
  if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
167
183
  return "full";
@@ -325,7 +341,7 @@ function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs
325
341
  pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
326
342
  }
327
343
  }
328
- pairs.sort((x, y) => y.strength - x.strength || (x.a + "" + x.b).localeCompare(y.a + "" + y.b));
344
+ pairs.sort((x, y) => y.strength - x.strength || (x.a + "\u0000" + x.b).localeCompare(y.a + "\u0000" + y.b));
329
345
  return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
330
346
  }
331
347
 
@@ -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
  }
@@ -97,9 +97,14 @@ function captureRunContext({
97
97
  }
98
98
  const project = projectSnapshot(state, cwd, opts.project);
99
99
  const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
100
- const admittedSessionId = opts.fresh
100
+ const requestedSessionId = opts.fresh
101
101
  ? null
102
102
  : (opts.resumeSessionId || activeSessionId || null);
103
+ // A quarantined session's provider-side context tripped a safety refusal;
104
+ // resuming would re-send the flagged context and refuse again. Force the
105
+ // turn fresh instead (explicit resume requests included).
106
+ const quarantinedResume = !!(requestedSessionId && state.quarantinedSessions?.[requestedSessionId]);
107
+ const admittedSessionId = quarantinedResume ? null : requestedSessionId;
103
108
  const origin = serializableOrigin(opts.origin, store || {});
104
109
  const resolvedChannelId = channelId != null
105
110
  ? String(channelId)
@@ -128,9 +133,10 @@ function captureRunContext({
128
133
  cwd: cwd || project.dir || null,
129
134
  replyToMsgId: replyToMsgId == null ? null : String(replyToMsgId),
130
135
  userPrompt: prompt,
131
- fresh: !!opts.fresh,
132
- continueSession: !!opts.continueSession,
133
- resumeSessionId: opts.resumeSessionId || null,
136
+ fresh: !!opts.fresh || quarantinedResume,
137
+ continueSession: quarantinedResume ? false : !!opts.continueSession,
138
+ resumeSessionId: quarantinedResume ? null : (opts.resumeSessionId || null),
139
+ quarantinedResume,
134
140
  skipAutoCompact: !!opts.skipAutoCompact,
135
141
  timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
136
142
  maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,