@inetafrica/open-claudia 3.0.44 → 3.1.1

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/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
  };
@@ -23,6 +23,14 @@
23
23
  // and traversed directionally. Each edge carries a Hebbian `weight` bumped on
24
24
  // co-use and decayed over time; unlike pack edges there is no structural floor,
25
25
  // so a chain that stops being used eventually decays away and is pruned.
26
+ //
27
+ // Decay is computed ON READ (same doctrine as core/recall/graph.js): the stored
28
+ // `weight` is the value AS OF `last_reinforced`; reads map it through
29
+ // effectiveWeight() and reinforcement settles accrued decay before bumping.
30
+ // Nothing rewrites weights nightly — tend() only physically deletes edges whose
31
+ // effective weight has faded under `pruneBelow`. The old nightly decay()
32
+ // rewrite compounded (full age reapplied to an already-decayed weight each
33
+ // night), emptying chains in days; that path no longer exists.
26
34
 
27
35
  const fs = require("fs");
28
36
  const path = require("path");
@@ -37,8 +45,8 @@ const GRAPH_DB = process.env.TOOL_GRAPH_DB
37
45
 
38
46
  // Tunables (overridable via dream knobs later).
39
47
  const DEFAULTS = {
40
- halfLifeDays: 45, // Hebbian weight half-life for decay()
41
- pruneBelow: 0.15, // edges decayed under this are dropped (no structural floor)
48
+ halfLifeDays: 45, // Hebbian weight half-life for read-time decay
49
+ pruneBelow: 0.15, // edges whose effective weight falls under this are dead (no structural floor)
42
50
  };
43
51
 
44
52
  let _db = null;
@@ -82,6 +90,18 @@ function openDb() {
82
90
  )`);
83
91
  db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_pack ON pack_tool_edges (pack)");
84
92
  db.exec("CREATE INDEX IF NOT EXISTS pack_tool_edges_tool ON pack_tool_edges (tool)");
93
+ // v1 migration: legacy DBs carry weights the old nightly decay() rewrite
94
+ // had already over-decayed (compounding). Treat stored values as current
95
+ // and restart the clock — decay is read-time from here on.
96
+ try {
97
+ const uv = db.prepare("PRAGMA user_version").get() || {};
98
+ if (Number(uv.user_version || 0) < 1) {
99
+ const stamp = now();
100
+ db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
101
+ db.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(stamp);
102
+ db.exec("PRAGMA user_version = 1");
103
+ }
104
+ } catch (e) {}
85
105
  _db = db;
86
106
  return db;
87
107
  } catch (e) {
@@ -91,23 +111,49 @@ function openDb() {
91
111
 
92
112
  function now() { return new Date().toISOString(); }
93
113
 
94
- // Directed upsert of a follows-edge src→dst. `bump` adds to the current weight
95
- // (Hebbian) and stamps last_reinforced; with no bump it just ensures the edge
96
- // exists at `weight` (default 1).
114
+ // Read-time weight of an edge row: exponential decay of the stored weight since
115
+ // last_reinforced. Pure — storage is never touched. No structural floor here;
116
+ // an edge that was never reinforced (last_reinforced null) does not decay.
117
+ function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = DEFAULTS.halfLifeDays) {
118
+ if (!e || !Number.isFinite(e.weight)) return 0;
119
+ if (!e.last_reinforced) return e.weight;
120
+ const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
121
+ if (!(ageDays > 0)) return e.weight;
122
+ return e.weight * Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
123
+ }
124
+
125
+ // Rank rows by read-time weight, dropping ones already faded under pruneBelow
126
+ // (dead advice — physically deleted on the next tend()). Weights are rounded
127
+ // to 4dp so a seconds-old reinforcement still reads as its whole value.
128
+ function rankLive(rows, limit) {
129
+ const nowMs = Date.now();
130
+ return rows
131
+ .map((r) => ({ ...r, weight: Math.round(effectiveWeight(r, nowMs) * 1e4) / 1e4 }))
132
+ .filter((r) => r.weight >= DEFAULTS.pruneBelow)
133
+ .sort((a, b) => b.weight - a.weight
134
+ || String(b.last_reinforced || "").localeCompare(String(a.last_reinforced || "")))
135
+ .slice(0, Math.max(0, limit));
136
+ }
137
+
138
+ // Directed upsert of a follows-edge src→dst. `bump` settles the decay accrued
139
+ // since last_reinforced, adds to the effective weight (Hebbian) and restamps;
140
+ // with no bump it just ensures the edge exists at `weight` (default 1).
97
141
  function addFollow(src, dst, opts = {}) {
98
142
  const db = openDb();
99
143
  if (!db || !src || !dst || src === dst) return false;
100
144
  const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
101
145
  const weight = Number.isFinite(opts.weight) ? opts.weight : 1;
102
146
  try {
103
- const existing = db.prepare("SELECT weight FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
147
+ const existing = db.prepare("SELECT weight, last_reinforced FROM tool_edges WHERE src=? AND dst=?").get(src, dst);
104
148
  if (existing) {
105
- const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
106
149
  if (bump) {
107
150
  db.prepare("UPDATE tool_edges SET weight=?, last_reinforced=? WHERE src=? AND dst=?")
108
- .run(next, now(), src, dst);
151
+ .run(effectiveWeight(existing) + bump, now(), src, dst);
109
152
  } else {
110
- db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
153
+ const next = Math.max(existing.weight, weight);
154
+ if (next !== existing.weight) {
155
+ db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, src, dst);
156
+ }
111
157
  }
112
158
  } else {
113
159
  // No structural floor for tools: a brand-new edge is worth exactly its
@@ -148,14 +194,14 @@ function allEdges() {
148
194
  catch (e) { return []; }
149
195
  }
150
196
 
151
- // Tools that tend to follow `name`, strongest first. This is the surfaced
152
- // signal: "you ran X — you usually run these next".
197
+ // Tools that tend to follow `name`, strongest first (read-time weights). This
198
+ // is the surfaced signal: "you ran X — you usually run these next".
153
199
  function followers(name, limit = 5) {
154
200
  const db = openDb();
155
201
  if (!db || !name) return [];
156
202
  try {
157
- return db.prepare("SELECT dst AS name, weight FROM tool_edges WHERE src=? ORDER BY weight DESC LIMIT ?")
158
- .all(name, limit);
203
+ const rows = db.prepare("SELECT dst AS name, weight, last_reinforced FROM tool_edges WHERE src=?").all(name);
204
+ return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
159
205
  } catch (e) { return []; }
160
206
  }
161
207
 
@@ -164,8 +210,8 @@ function predecessors(name, limit = 5) {
164
210
  const db = openDb();
165
211
  if (!db || !name) return [];
166
212
  try {
167
- return db.prepare("SELECT src AS name, weight FROM tool_edges WHERE dst=? ORDER BY weight DESC LIMIT ?")
168
- .all(name, limit);
213
+ const rows = db.prepare("SELECT src AS name, weight, last_reinforced FROM tool_edges WHERE dst=?").all(name);
214
+ return rankLive(rows, limit).map((r) => ({ name: r.name, weight: r.weight }));
169
215
  } catch (e) { return []; }
170
216
  }
171
217
 
@@ -206,7 +252,13 @@ function parseToolRuns(command) {
206
252
  const out = [];
207
253
  const cmd = String(command || "");
208
254
  if (!/tool\s+(?:run|exec)\s+/i.test(cmd)) return out;
209
- for (const seg of cmd.split(/&&|\|\||[;\n]/)) {
255
+ // B17: split on ALL shell composition operators — &&, ||, |, &, ;, newline.
256
+ // The single pipe was missed, so `tool run a … | tool run b …` parsed as one
257
+ // segment and only the first tool earned a follows-edge. Separators inside
258
+ // quoted spans are neutralized first so a pipe in an argument doesn't split
259
+ // (quoted content never reaches the shape anyway — shapeCommand masks it).
260
+ const masked = cmd.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q.replace(/[;\n|&]/g, " "));
261
+ for (const seg of masked.split(/[;\n|&]+/)) {
210
262
  const m = seg.match(/\btool\s+(?:run|exec)\s+["']?([a-z0-9][\w.-]*)["']?\s*(.*)$/i);
211
263
  if (!m) continue;
212
264
  out.push({ name: m[1], shape: shapeCommand(`${m[1]} ${m[2] || ""}`) });
@@ -227,7 +279,8 @@ function recordPackTool(pack, tool, opts = {}) {
227
279
  try {
228
280
  const row = db.prepare("SELECT weight, intent, last_reinforced FROM pack_tool_edges WHERE pack=? AND tool=? AND command=?").get(pack, tool, command);
229
281
  if (row) {
230
- const next = bump ? row.weight + bump : Math.max(row.weight, weight);
282
+ // Bump settles accrued decay first (same invariant as addFollow).
283
+ const next = bump ? effectiveWeight(row) + bump : Math.max(row.weight, weight);
231
284
  const intent = (opts.intent != null && opts.intent !== "") ? String(opts.intent).slice(0, 200) : row.intent;
232
285
  db.prepare("UPDATE pack_tool_edges SET weight=?, intent=?, last_reinforced=? WHERE pack=? AND tool=? AND command=?")
233
286
  .run(next, intent, bump ? now() : row.last_reinforced, pack, tool, command);
@@ -243,15 +296,16 @@ function recordPackTool(pack, tool, opts = {}) {
243
296
  }
244
297
 
245
298
  // Strongest contextual tool usages recorded under a pack, for surfacing in that
246
- // pack's injected context. Ordered by reinforced weight, newest first as a
299
+ // pack's injected context. Ordered by read-time weight, newest first as a
247
300
  // tiebreak. One row per distinct command shape.
248
301
  function packTools(pack, limit = 2) {
249
302
  const db = openDb();
250
303
  if (!db || !pack) return [];
251
304
  try {
252
- return db.prepare(
253
- "SELECT tool, command, intent, weight FROM pack_tool_edges WHERE pack=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
254
- ).all(pack, limit);
305
+ const rows = db.prepare(
306
+ "SELECT tool, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE pack=?"
307
+ ).all(pack);
308
+ return rankLive(rows, limit).map((r) => ({ tool: r.tool, command: r.command, intent: r.intent, weight: r.weight }));
255
309
  } catch (e) { return []; }
256
310
  }
257
311
 
@@ -260,9 +314,10 @@ function toolPacks(tool, limit = 5) {
260
314
  const db = openDb();
261
315
  if (!db || !tool) return [];
262
316
  try {
263
- return db.prepare(
264
- "SELECT pack, command, intent, weight FROM pack_tool_edges WHERE tool=? ORDER BY weight DESC, last_reinforced DESC LIMIT ?"
265
- ).all(tool, limit);
317
+ const rows = db.prepare(
318
+ "SELECT pack, command, intent, weight, last_reinforced FROM pack_tool_edges WHERE tool=?"
319
+ ).all(tool);
320
+ return rankLive(rows, limit).map((r) => ({ pack: r.pack, command: r.command, intent: r.intent, weight: r.weight }));
266
321
  } catch (e) { return []; }
267
322
  }
268
323
 
@@ -280,53 +335,25 @@ function allPackToolEdges() {
280
335
  catch (e) { return []; }
281
336
  }
282
337
 
283
- // Exponential time decay. Tools have no structural floor, so edges decay toward
284
- // zero and are pruned once under `pruneBelow` — a chain that fell out of use
285
- // disappears instead of lingering as stale advice. Applies to both the tool→tool
286
- // follows-edges and the pack↔tool contextual edges (same physics).
287
- function decay({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
338
+ // Physically delete edges whose EFFECTIVE weight has faded under `pruneBelow` —
339
+ // a chain that fell out of use disappears instead of lingering as stale advice.
340
+ // Weights themselves are never rewritten (decay-on-read); this is pure garbage
341
+ // collection. Applies to both the tool→tool follows-edges and the pack↔tool
342
+ // contextual edges (same physics). Never-reinforced ensure-edges don't fade.
343
+ function prune({ halfLifeDays = DEFAULTS.halfLifeDays, pruneBelow = DEFAULTS.pruneBelow } = {}) {
288
344
  const db = openDb();
289
- if (!db) return { decayed: 0, pruned: 0 };
345
+ if (!db) return { pruned: 0 };
290
346
  const nowMs = Date.now();
291
- const ln2 = Math.log(2);
292
- let decayed = 0, pruned = 0;
293
- const decayFactor = (lastReinforced) => {
294
- if (!lastReinforced) return null;
295
- const ageDays = (nowMs - Date.parse(lastReinforced)) / 86400000;
296
- if (!(ageDays > 0)) return null;
297
- return Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
298
- };
347
+ let pruned = 0;
299
348
  for (const e of allEdges()) {
300
- const factor = decayFactor(e.last_reinforced);
301
- if (factor === null) continue;
302
- const next = e.weight * factor;
303
- if (next < pruneBelow) {
304
- if (removeEdge(e.src, e.dst)) pruned++;
305
- continue;
306
- }
307
- if (Math.abs(next - e.weight) > 1e-6) {
308
- try {
309
- db.prepare("UPDATE tool_edges SET weight=? WHERE src=? AND dst=?").run(next, e.src, e.dst);
310
- decayed++;
311
- } catch (e2) {}
312
- }
349
+ if (!e.last_reinforced) continue;
350
+ if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removeEdge(e.src, e.dst)) pruned++;
313
351
  }
314
352
  for (const e of allPackToolEdges()) {
315
- const factor = decayFactor(e.last_reinforced);
316
- if (factor === null) continue;
317
- const next = e.weight * factor;
318
- if (next < pruneBelow) {
319
- if (removePackTool(e.pack, e.tool, e.command)) pruned++;
320
- continue;
321
- }
322
- if (Math.abs(next - e.weight) > 1e-6) {
323
- try {
324
- db.prepare("UPDATE pack_tool_edges SET weight=? WHERE pack=? AND tool=? AND command=?").run(next, e.pack, e.tool, e.command);
325
- decayed++;
326
- } catch (e2) {}
327
- }
353
+ if (!e.last_reinforced) continue;
354
+ if (effectiveWeight(e, nowMs, halfLifeDays) < pruneBelow && removePackTool(e.pack, e.tool, e.command)) pruned++;
328
355
  }
329
- return { decayed, pruned };
356
+ return { pruned };
330
357
  }
331
358
 
332
359
  // Drop edges whose endpoints are no longer registered tools (a tool was removed
@@ -361,13 +388,14 @@ function pruneOrphans(toolsLib, packsLib) {
361
388
  return removed;
362
389
  }
363
390
 
364
- // Nightly maintenance (called by the dream): decay reinforced weights and drop
365
- // orphaned/faded edges. Deterministic + safe — no model needed. Pass
366
- // { packsLib } to also prune pack↔tool edges whose pack was removed.
391
+ // Nightly maintenance (called by the dream): drop faded and orphaned edges.
392
+ // No weight pass — decay is applied on read. Deterministic + safe — no model
393
+ // needed. Pass { packsLib } to also prune pack↔tool edges whose pack was
394
+ // removed.
367
395
  function tend(toolsLib, opts = {}) {
368
- const { decayed, pruned } = decay(opts);
396
+ const { pruned } = prune(opts);
369
397
  const orphaned = toolsLib ? pruneOrphans(toolsLib, opts.packsLib) : 0;
370
- return { decayed, pruned: pruned + orphaned, ...stats() };
398
+ return { pruned: pruned + orphaned, ...stats() };
371
399
  }
372
400
 
373
401
  function stats() {
@@ -384,7 +412,7 @@ module.exports = {
384
412
  DEFAULTS, GRAPH_DB,
385
413
  available, openDb,
386
414
  addFollow, reinforceSequence, removeEdge, allEdges,
387
- followers, predecessors, decay, pruneOrphans, tend, stats,
415
+ followers, predecessors, effectiveWeight, prune, pruneOrphans, tend, stats,
388
416
  shapeCommand, parseToolRuns, recordPackTool, packTools, toolPacks,
389
417
  removePackTool, allPackToolEdges,
390
418
  _resetForTest,