@inetafrica/open-claudia 2.6.58 → 2.6.59

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/dream.js CHANGED
@@ -23,6 +23,9 @@ const daySeeds = require("./day-seeds");
23
23
  const packGuard = require("./pack-guard");
24
24
  const persona = require("./persona");
25
25
  const { spawnSubagent } = require("./subagent");
26
+ const recallMetrics = require("./recall/metrics");
27
+ const recallTuning = require("./recall/tuning");
28
+ const recallGraphLib = require("./recall/graph");
26
29
 
27
30
  // Graph surgery + threshold tuning is the highest-judgment work in the recall
28
31
  // system, so the dream runs on the best available high tier. Honour an explicit
@@ -107,11 +110,134 @@ function clip(s, n) {
107
110
  return t.length > n ? t.slice(0, n) + "\n…[truncated]" : t;
108
111
  }
109
112
 
110
- function buildDreamPrompt() {
113
+ // --- Dream state: last run / last full sweep / metrics snapshot for windowed
114
+ // health deltas / pending knob-change awaiting its rollback check.
115
+ const DREAM_STATE_FILE = path.join(CONFIG_DIR, "dream-state.json");
116
+ const FULL_SWEEP_DAYS = Number(process.env.DREAM_FULL_SWEEP_DAYS || 30);
117
+
118
+ function readDreamState() {
119
+ try { return JSON.parse(fs.readFileSync(DREAM_STATE_FILE, "utf8")) || {}; } catch (e) { return {}; }
120
+ }
121
+ function writeDreamState(s) {
122
+ try { fs.writeFileSync(DREAM_STATE_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
123
+ }
124
+
125
+ // Windowed metric deltas between two summary snapshots (counters only grow).
126
+ const DELTA_KEYS = ["turns", "gatedTurns", "seedsOnlyTurns", "seedsTotal", "activatedTotal", "keptTotal",
127
+ "rescueTurns", "rescues", "opens", "reviewerUses", "reviewerSkipped", "autoKept", "autoDropped",
128
+ "shadowChecks", "shadowAgree", "latencyMsTotal", "costUsd"];
129
+ function summaryDelta(cur, prev) {
130
+ const c = cur || {}, p = prev || {};
131
+ const d = {};
132
+ for (const k of DELTA_KEYS) d[k] = Math.max(0, (c[k] || 0) - (p[k] || 0));
133
+ d.byTier = {};
134
+ for (const [t, v] of Object.entries(c.byTier || {})) {
135
+ const pv = (p.byTier || {})[t] || { n: 0, ms: 0 };
136
+ d.byTier[t] = { n: Math.max(0, (v.n || 0) - pv.n), ms: Math.max(0, (v.ms || 0) - pv.ms) };
137
+ }
138
+ return d;
139
+ }
140
+ function healthOf(delta) {
141
+ const turns = delta.turns || 0;
142
+ const used = (delta.opens || 0) + (delta.reviewerUses || 0);
143
+ return {
144
+ turns,
145
+ rescueRate: turns ? (delta.rescueTurns || 0) / turns : 0,
146
+ noiseRate: delta.keptTotal ? Math.max(0, 1 - used / delta.keptTotal) : 0,
147
+ avgLatencyMs: turns ? Math.round((delta.latencyMsTotal || 0) / turns) : 0,
148
+ };
149
+ }
150
+
151
+ // --- Algorithmic pre-computation for the consolidation prompt. Instead of
152
+ // asking the model to discover duplicates in a wall of ~130 full pack bodies,
153
+ // deterministic code picks (a) which packs deserve a full dump this night and
154
+ // (b) which look like merge candidates — the model judges pre-computed
155
+ // clusters, everything else rides along as a one-line evidence row. Cuts the
156
+ // nightly prompt by ~70% with no accuracy loss: an untouched pack was already
157
+ // judged by a previous dream, and merge candidates are chosen by content
158
+ // similarity over the WHOLE corpus regardless of touch date.
159
+ function dreamTokens(s) {
160
+ return new Set(String(s || "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
161
+ }
162
+ function packSim(a, b) {
163
+ const ta = dreamTokens(`${a.name} ${a.description} ${a.tags.join(" ")}`);
164
+ const tb = dreamTokens(`${b.name} ${b.description} ${b.tags.join(" ")}`);
165
+ if (!ta.size || !tb.size) return 0;
166
+ let inter = 0;
167
+ for (const w of ta) if (tb.has(w)) inter++;
168
+ return inter / (ta.size + tb.size - inter);
169
+ }
170
+ function mergeClusters(allPacks, { threshold = 0.5, maxClusters = 6, maxMembers = 4 } = {}) {
171
+ const parent = new Map(allPacks.map((p) => [p.dir, p.dir]));
172
+ const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
173
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
174
+ for (let i = 0; i < allPacks.length; i++) {
175
+ for (let j = i + 1; j < allPacks.length; j++) {
176
+ const a = allPacks[i], b = allPacks[j];
177
+ if ((a.kind === "ability") !== (b.kind === "ability")) continue; // ability/context never merge
178
+ if (packSim(a, b) >= threshold) union(a.dir, b.dir);
179
+ }
180
+ }
181
+ const groups = new Map();
182
+ for (const p of allPacks) {
183
+ const r = find(p.dir);
184
+ if (!groups.has(r)) groups.set(r, []);
185
+ groups.get(r).push(p.dir);
186
+ }
187
+ return [...groups.values()]
188
+ .filter((g) => g.length >= 2)
189
+ .map((g) => g.slice(0, maxMembers))
190
+ .slice(0, maxClusters);
191
+ }
192
+
193
+ // Which packs get their FULL body in tonight's prompt: touched since the last
194
+ // dream, their 1-hop graph neighbours, every shared-concern pack (few, and
195
+ // they govern), and all merge-cluster members. On a full sweep: everything.
196
+ function selectDumpDirs(allPacks, { sinceTs, fullSweep, clusters }) {
197
+ if (fullSweep || !sinceTs) return new Set(allPacks.map((p) => p.dir));
198
+ const dump = new Set();
199
+ for (const p of allPacks) {
200
+ if ((p.updated && p.updated > sinceTs) || (p.last_used && p.last_used > sinceTs)) dump.add(p.dir);
201
+ if (recallGraphLib.isSharedConcern(p)) dump.add(p.dir);
202
+ }
203
+ try {
204
+ if (recallGraphLib.available()) {
205
+ for (const dir of [...dump]) {
206
+ for (const nb of recallGraphLib.neighbors(`pack:${dir}`)) {
207
+ if (nb.node.startsWith("pack:")) dump.add(nb.node.slice(5));
208
+ }
209
+ }
210
+ }
211
+ } catch (e) {}
212
+ for (const g of clusters || []) for (const dir of g) dump.add(dir);
213
+ return dump;
214
+ }
215
+
216
+ function evidenceLines(ev) {
217
+ if (!ev || !ev.summary || !(ev.summary.turns > 0)) return "(no recall telemetry yet)";
218
+ const out = [];
219
+ if (ev.neverKept.length) {
220
+ out.push("Surfaced repeatedly but NEVER kept or used (recall noise — prime retag/archive candidates):");
221
+ for (const n of ev.neverKept.slice(0, 12)) out.push(`- ${n.id} (surfaced ${n.seen}×${n.act ? `, ${n.act}× via graph` : ""})`);
222
+ }
223
+ if (ev.topRescued.length) {
224
+ out.push("Frequently RESCUED by the graph (keyword match misses them — their name/description/tags don't match how they're asked about; retag these):");
225
+ for (const n of ev.topRescued.slice(0, 8)) out.push(`- ${n.id} (rescued ${n.rescues}×)`);
226
+ }
227
+ return out.join("\n") || "(no strong signals yet)";
228
+ }
229
+
230
+ function buildDreamPrompt({ sinceTs = null, fullSweep = true, clusters = [], evidence = null, windowDelta = null } = {}) {
111
231
  const allPacks = packs.listPacks();
112
232
  const allEntities = entities.listEntities();
113
233
 
114
- const packDump = allPacks.map((p) => clip([
234
+ const dumpDirs = selectDumpDirs(allPacks, { sinceTs, fullSweep, clusters });
235
+ const nodeHist = (() => { try { return recallMetrics.nodeStats().nodes || {}; } catch (e) { return {}; } })();
236
+
237
+ const fullPacks = allPacks.filter((p) => dumpDirs.has(p.dir));
238
+ const rowPacks = allPacks.filter((p) => !dumpDirs.has(p.dir));
239
+
240
+ const packDump = fullPacks.map((p) => clip([
115
241
  `=== pack: ${p.dir}${p.parent ? ` (parent: ${p.parent})` : ""}${p.kind === "ability" ? " [ABILITY]" : ""}`,
116
242
  `name: ${p.name}`,
117
243
  `description: ${p.description}`,
@@ -123,6 +249,24 @@ function buildDreamPrompt() {
123
249
  `Journal:\n${p.sections.Journal}`,
124
250
  ].join("\n"), MAX_PACK_CHARS)).join("\n\n") || "(none)";
125
251
 
252
+ // One evidence row per untouched pack — enough for retag/parents/archive
253
+ // judgement (name, tags, usage, recall stats) without the body cost.
254
+ const packIndex = rowPacks.map((p) => {
255
+ const h = nodeHist[`pack:${p.dir}`] || {};
256
+ const idleDays = p.last_used ? Math.round((Date.now() - Date.parse(p.last_used)) / 86400000) : null;
257
+ return `- ${p.dir}${p.kind === "ability" ? " [ABILITY]" : ""}${p.parent ? ` (parent: ${p.parent})` : ""} | ${p.name} | tags: ${p.tags.join(", ") || "—"} | used ${p.usage_count || 0}×${idleDays !== null ? `, idle ${idleDays}d` : ", never used"} | recall: surfaced ${h.seen || 0}× kept ${h.kept || 0}×`;
258
+ }).join("\n");
259
+
260
+ const clusterBlock = (clusters || []).length
261
+ ? clusters.map((g, i) => `${i + 1}. ${g.join(" + ")}`).join("\n")
262
+ : "(none found)";
263
+
264
+ const win = windowDelta ? healthOf(windowDelta) : null;
265
+ const evidenceBlock = [
266
+ win ? `Window since last dream: ${win.turns} turns, rescue rate ${(win.rescueRate * 100).toFixed(0)}% (kept nodes no keyword seed found — the graph saved them), noise ${(win.noiseRate * 100).toFixed(0)}% (kept but never opened/used), avg recall latency ${win.avgLatencyMs}ms.` : "",
267
+ evidenceLines(evidence),
268
+ ].filter(Boolean).join("\n");
269
+
126
270
  const entityDump = allEntities.map((e) => clip([
127
271
  `=== entity: ${e.slug} (${e.type}${e.aliases.length ? `, aka ${e.aliases.join(", ")}` : ""})`,
128
272
  `description: ${e.description}`,
@@ -135,13 +279,27 @@ function buildDreamPrompt() {
135
279
  `- [${l.id}] "${l.text}"${l.src ? ` (src: ${l.src})` : ""} — reinforced ${l.reinforced || 0}×, since ${(l.created || "").slice(0, 10)}, origin ${l.origin || "user"}`
136
280
  ).join("\n") || "(none)";
137
281
 
138
- return `You are the dream pass of a personal AI assistant called Open Claudia — the overnight consolidation of her long-term memory. Below is her entire memory: context packs (living topic documents), entity notes (people/places/projects/orgs/systems), and her always-loaded lessons, plus her current persona.
282
+ const knobBlock = recallTuning.knobTable().map((k) =>
283
+ `- ${k.name}: current ${k.current}${k.values ? ` (one of ${k.values.join("/")})` : ` (bounds ${k.min}–${k.max}, default ${k.default})`}${k.pinned ? " [PINNED]" : ""}`
284
+ ).join("\n");
139
285
 
140
- Today: ${new Date().toISOString().slice(0, 10)}
286
+ return `You are the dream pass of a personal AI assistant called Open Claudia — the overnight consolidation of her long-term memory. Below is her memory: context packs (living topic documents), entity notes (people/places/projects/orgs/systems), and her always-loaded lessons, plus her current persona.
287
+
288
+ Today: ${new Date().toISOString().slice(0, 10)}${fullSweep ? " (FULL SWEEP — every pack shown in full)" : ` (delta night — packs touched since ${String(sinceTs).slice(0, 10)}, their graph neighbours, shared concerns, and merge candidates are shown in full; the rest as index rows)`}
141
289
 
142
- CONTEXT PACKS:
290
+ Some packs are SHARED CONCERNS (tagged concern/shared/cross-cutting): standing cross-cutting rules — change control, credential handling, release hygiene — that govern many projects via [[links]] and governed-by graph edges. They are rarely opened directly, so LOW USAGE ON A CONCERN IS NORMAL: never archive one for coldness, and never merge one into a project pack (or vice versa) — that would silently detach every governed project.
291
+
292
+ CONTEXT PACKS (full):
143
293
 
144
294
  ${packDump}
295
+ ${packIndex ? `\nCONTEXT PACK INDEX (untouched since last dream — summaries only; valid targets for retag/parents/archive, but merges ONLY among the fully-shown packs above):\n\n${packIndex}\n` : ""}
296
+ MERGE CANDIDATES (pre-computed by content similarity — judge these; do not hunt for others):
297
+
298
+ ${clusterBlock}
299
+
300
+ RECALL EVIDENCE (live usage telemetry — ground consolidation decisions in these numbers):
301
+
302
+ ${evidenceBlock}
145
303
 
146
304
  ENTITIES:
147
305
 
@@ -167,6 +325,9 @@ Your job — decide what consolidation, if any, is warranted:
167
325
  8. lessons: keep the always-loaded lessons few and sharp — they cost context on EVERY turn. dedupe near-identical lessons (op "remove" the weaker, keep the better wording; or op "edit" to merge two into one tight line); tighten a clumsy lesson with op "edit". If the count exceeds the cap of ${lessons.MAX_LESSONS}, demote the weakest down to the cap — prefer lessons that are low-reinforced AND whose fact is safely captured in the pack named in their (src) (they will still surface by topic-match), and NEVER remove a frequently-reinforced lesson (those are actively preventing a repeat mistake). Even under the cap you may remove a lesson clearly redundant with its source pack that has stayed at 0 reinforcements for a long time, but be conservative — when in doubt, keep it. Reference lessons by their exact current text.
168
326
  9. persona: evolve the persona GENTLY — keep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
169
327
  10. report: a short chat message to the owner, written AS Open Claudia in first person — warm, a little playful, a few emojis, mobile-friendly (2-6 short lines). Say what you tidied and why it helps. If you changed nothing, say the memory is in good shape, charmingly.
328
+ 11. tuning: OPTIONALLY propose ONE bounded recall-knob change, justified ONLY by the RECALL EVIDENCE numbers (e.g. rescue rate persistently low → raise walkerMaxCandidates; latency high while rescue is flat → lower it; seeds tier missing context on short asks → raise seedsTierMaxWords). Knobs and bounds:
329
+ ${knobBlock}
330
+ A knob marked PINNED is operator-controlled — never propose it. Most nights the right answer is null: change nothing without clear evidence, never more than one knob, and prefer small steps (the system auto-rolls-back a change that worsens rescue/noise).
170
331
 
171
332
  Hard rules:
172
333
  - Never invent packs/entities not listed above; reference them by exact dir/slug.
@@ -184,7 +345,9 @@ Reply with ONLY a JSON object, no prose, no code fences:
184
345
  "archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
185
346
  "lessons": [{"op": "remove", "text": "<exact current lesson text>", "reason": "<duplicate of … / safely in pack … / cold>"}],
186
347
  "persona": null,
348
+ "tuning": null,
187
349
  "report": "<chat message>"}
350
+ (a tuning proposal looks like {"knob": "<name>", "to": <value>, "reason": "<one line grounded in the evidence>"})
188
351
  (a lesson "edit" looks like {"op": "edit", "text": "<exact current lesson text>", "to": "<tightened wording>"})`;
189
352
  }
190
353
 
@@ -206,6 +369,7 @@ function parseDream(text) {
206
369
  archive: arr("archive"),
207
370
  lessons: arr("lessons"),
208
371
  persona: typeof obj.persona === "string" ? obj.persona : null,
372
+ tuning: obj.tuning && typeof obj.tuning === "object" && obj.tuning.knob ? obj.tuning : null,
209
373
  report: typeof obj.report === "string" ? obj.report.trim() : "",
210
374
  };
211
375
  } catch (e) {
@@ -300,6 +464,14 @@ function applyDream(decision, backupRoot) {
300
464
  lines.push(`🧩 Skipped merge into ${m.into} — won't mix a reusable ability with project packs.`);
301
465
  continue;
302
466
  }
467
+ // Shared concerns govern many projects via edges; merging one into a
468
+ // project pack (or vice versa) would silently detach every governed
469
+ // project. Concern↔concern merges remain legal.
470
+ const intoIsConcern = recallGraphLib.isSharedConcern(into);
471
+ if (from.some((d) => recallGraphLib.isSharedConcern(packs.readPack(d)) !== intoIsConcern)) {
472
+ lines.push(`🛡️ Skipped merge into ${m.into} — won't mix a shared concern with a project pack.`);
473
+ continue;
474
+ }
303
475
  const mGuard = packGuard.scanSections({ stance: m.stance, procedure: m.procedure, state: m.state, journal: m.journal });
304
476
  if (mGuard.flagged) {
305
477
  console.warn(`[dream] guard blocked merge into ${m.into} (${mGuard.kind} in ${mGuard.section})`);
@@ -425,6 +597,10 @@ function applyDream(decision, backupRoot) {
425
597
  const pack = packs.readPack(dir);
426
598
  if (!pack) continue;
427
599
  if (allPacks.some((p) => p.parent === dir)) continue; // never orphan children
600
+ if (recallGraphLib.isSharedConcern(pack)) {
601
+ lines.push(`🛡️ Left ${dir} alone — shared concerns are rarely opened directly; coldness there is normal.`);
602
+ continue;
603
+ }
428
604
  const ageDays = pack.created ? (Date.now() - Date.parse(pack.created)) / 86400000 : Infinity;
429
605
  const idleDays = pack.last_used ? (Date.now() - Date.parse(pack.last_used)) / 86400000 : ageDays;
430
606
  const uses = Number(pack.usage_count) || 0;
@@ -680,7 +856,9 @@ function writeDreamReport(data) {
680
856
  if (data.introspection.proposed) {
681
857
  sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
682
858
  }
859
+ if (data.healthNote) sec.push("", "## Memory health", data.healthNote);
683
860
  if (data.graphNote) sec.push("", data.graphNote);
861
+ if (data.episodicNote) sec.push("", data.episodicNote);
684
862
  if (data.toolNote) sec.push("", data.toolNote);
685
863
  if (data.staleNote) sec.push("", data.staleNote);
686
864
  if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
@@ -738,6 +916,72 @@ function nestPrefixFamilies(backupRoot) {
738
916
  return { lines, filed };
739
917
  }
740
918
 
919
+ // Journal backfill dedupe. The runtime guard (packs.isNearDupJournal) stops
920
+ // NEW near-duplicate journal lines, but entries stacked before it shipped are
921
+ // still crowding real history out of capped Journals. Same similarity test,
922
+ // applied retroactively: walk each Journal oldest→newest keeping a line only
923
+ // if it isn't a near-dup of the last 5 kept. Backed up, capped per night.
924
+ function journalBackfillDedupe(backupRoot, { maxPacks = 12 } = {}) {
925
+ const lines = [];
926
+ let cleaned = 0, removed = 0;
927
+ for (const meta of packs.listPacks()) {
928
+ if (cleaned >= maxPacks) break;
929
+ try {
930
+ const pack = packs.readPack(meta.dir);
931
+ const entries = (pack?.sections?.Journal || "").split("\n").filter((l) => l.trim());
932
+ if (entries.length < 3) continue;
933
+ const keep = [];
934
+ for (const e of entries) {
935
+ if (packs.isNearDupJournal(e, keep.slice(-5))) continue;
936
+ keep.push(e);
937
+ }
938
+ if (keep.length === entries.length) continue;
939
+ if (backupRoot) backupPack(meta.dir, backupRoot);
940
+ pack.sections.Journal = keep.join("\n");
941
+ pack.updated = new Date().toISOString();
942
+ packs.writePack(pack);
943
+ cleaned++;
944
+ removed += entries.length - keep.length;
945
+ } catch (e) { console.warn(`[dream] journal dedupe failed for ${meta.dir}: ${e.message}`); }
946
+ }
947
+ if (removed) lines.push(`🧽 Collapsed ${removed} near-duplicate journal line${removed === 1 ? "" : "s"} across ${cleaned} pack${cleaned === 1 ? "" : "s"}`);
948
+ return { lines, cleaned, removed };
949
+ }
950
+
951
+ // Co-rescue edges: two nodes repeatedly rescued TOGETHER by graph expansion
952
+ // are missing a direct edge — wire one deterministically so future traversal
953
+ // reaches them in one hop instead of relying on repeated rescues.
954
+ function coRescueEdgesPhase(ev, { cap = 10 } = {}) {
955
+ const lines = [];
956
+ let wired = 0;
957
+ if (!recallGraphLib.available()) return { lines, wired };
958
+ for (const p of (ev?.coRescuePairs || []).slice(0, cap)) {
959
+ try { recallGraphLib.reinforce(p.a, p.b, 1); wired++; } catch (e) {}
960
+ }
961
+ if (wired) lines.push(`🕸 Wired ${wired} co-rescue edge${wired === 1 ? "" : "s"} — nodes that kept getting rescued together are now directly linked`);
962
+ return { lines, wired };
963
+ }
964
+
965
+ // Negative Hebbian: a node that keeps arriving via graph expansion but is
966
+ // never kept and never used is firing wrongly — ease every edge pulling it
967
+ // in. Only targets nodes with repeated graph arrivals (act ≥ 3): pure seed
968
+ // noise is a retag/archive problem the model handles with the evidence lines.
969
+ function negativeHebbianPhase(ev, { cap = 12 } = {}) {
970
+ const lines = [];
971
+ let nodes = 0, weakened = 0, pruned = 0;
972
+ if (!recallGraphLib.available()) return { lines, nodes, weakened, pruned };
973
+ for (const cand of ev?.neverKept || []) {
974
+ if (nodes >= cap) break;
975
+ if ((cand.act || 0) < 3) continue;
976
+ try {
977
+ const r = recallGraphLib.weakenNode(cand.id);
978
+ if (r.weakened || r.pruned) { nodes++; weakened += r.weakened; pruned += r.pruned; }
979
+ } catch (e) {}
980
+ }
981
+ if (nodes) lines.push(`🪶 Eased graph pull on ${nodes} surfaced-but-never-used node${nodes === 1 ? "" : "s"} (${weakened} edges weakened, ${pruned} pruned)`);
982
+ return { lines, nodes, weakened, pruned };
983
+ }
984
+
741
985
  async function runDream({ trigger = "manual" } = {}) {
742
986
  if (!enabled()) return { skipped: "dream is disabled (DREAM=off)" };
743
987
  if (_dreaming) return { skipped: "a dream is already in progress" };
@@ -747,6 +991,50 @@ async function runDream({ trigger = "manual" } = {}) {
747
991
 
748
992
  _dreaming = true;
749
993
  try {
994
+ const nowISO = new Date().toISOString();
995
+ const dreamState = readDreamState();
996
+ const fullSweep = !dreamState.lastFullSweep ||
997
+ (Date.now() - Date.parse(dreamState.lastFullSweep)) / 86400000 >= FULL_SWEEP_DAYS;
998
+ let ev = null, windowDelta = null, clusters = [];
999
+ try { ev = recallMetrics.evidence(); } catch (e) {}
1000
+ try { windowDelta = summaryDelta(ev?.summary, dreamState.summarySnapshot); } catch (e) {}
1001
+ try { clusters = mergeClusters(packs.listPacks()); } catch (e) {}
1002
+
1003
+ // Closed-loop knob governance — deterministic, BEFORE the model pass so a
1004
+ // bad change gets rolled back even on nights the model call fails.
1005
+ const knobLines = [];
1006
+ try {
1007
+ const pending = dreamState.pendingKnobCheck;
1008
+ if (pending && windowDelta && (windowDelta.turns || 0) >= 30) {
1009
+ const h = healthOf(windowDelta);
1010
+ const base = pending.baseline || {};
1011
+ const rescueDown = base.rescueRate != null && h.rescueRate < base.rescueRate - Math.max(0.02, base.rescueRate * 0.2);
1012
+ const noiseUp = base.noiseRate != null && h.noiseRate > base.noiseRate + Math.max(0.02, base.noiseRate * 0.2);
1013
+ if (rescueDown || noiseUp) {
1014
+ const r = recallTuning.revert(pending.knob, {
1015
+ reason: `${rescueDown ? "rescue rate fell" : "noise rose"} over ${windowDelta.turns} turns (rescue ${(base.rescueRate * 100).toFixed(0)}%→${(h.rescueRate * 100).toFixed(0)}%, noise ${(base.noiseRate * 100).toFixed(0)}%→${(h.noiseRate * 100).toFixed(0)}%)`,
1016
+ });
1017
+ if (r) knobLines.push(`🎛 Rolled back ${pending.knob} to ${r.to} — the change made things worse (${rescueDown ? "rescue rate fell" : "noise rose"} over ${windowDelta.turns} turns).`);
1018
+ } else {
1019
+ knobLines.push(`🎛 Keeping ${pending.knob} at ${recallTuning.get(pending.knob)} — window healthy after ${windowDelta.turns} turns.`);
1020
+ }
1021
+ dreamState.pendingKnobCheck = null;
1022
+ }
1023
+ // Cascade trust check: sampled shadow judgements disagreeing with the
1024
+ // judge means the deterministic verdicts are drifting — switch it off
1025
+ // and let the dream re-enable it later if evidence improves.
1026
+ if (windowDelta && (windowDelta.shadowChecks || 0) >= 20 && recallTuning.get("cascade") !== "off") {
1027
+ const agree = (windowDelta.shadowAgree || 0) / windowDelta.shadowChecks;
1028
+ if (agree < 0.8) {
1029
+ const r = recallTuning.set("cascade", "off", {
1030
+ reason: `shadow agreement ${Math.round(agree * 100)}% over ${windowDelta.shadowChecks} checks (<80%)`,
1031
+ evidence: "dream cascade trust check",
1032
+ });
1033
+ if (r) knobLines.push(`🎛 Paused the walker cascade — shadow samples agreed with the judge only ${Math.round(agree * 100)}% of the time.`);
1034
+ }
1035
+ }
1036
+ } catch (e) { console.warn(`[dream] knob governance failed: ${e.message}`); }
1037
+
750
1038
  // Best-effort consolidation: on a large corpus this call can time out or
751
1039
  // return unreadable JSON. That must NOT abort the dream — the deterministic
752
1040
  // phases below (prefix nesting, graph tend) need no model and are the
@@ -755,7 +1043,7 @@ async function runDream({ trigger = "manual" } = {}) {
755
1043
  let decision = null;
756
1044
  let modelFailNote = "";
757
1045
  try {
758
- const { text } = await spawnSubagent(buildDreamPrompt(), {
1046
+ const { text } = await spawnSubagent(buildDreamPrompt({ sinceTs: dreamState.lastRun || null, fullSweep, clusters, evidence: ev, windowDelta }), {
759
1047
  model: DREAM_MODEL,
760
1048
  effort: DREAM_EFFORT,
761
1049
  timeoutMs: dreamTimeoutMs(packCount),
@@ -770,13 +1058,36 @@ async function runDream({ trigger = "manual" } = {}) {
770
1058
 
771
1059
  const backupRoot = makeBackupRoot();
772
1060
  const applied = applyDream(decision, backupRoot);
1061
+
1062
+ // Model-proposed knob change: bounded by tuning.js, one per night, and a
1063
+ // baseline snapshot is kept so the next dream can auto-roll-back.
1064
+ if (decision.tuning && decision.tuning.knob) {
1065
+ try {
1066
+ const t = decision.tuning;
1067
+ const r = recallTuning.set(t.knob, t.to, { reason: String(t.reason || "dream proposal").slice(0, 200), evidence: "dream consolidation" });
1068
+ if (r) {
1069
+ knobLines.push(`🎛 Tuned ${r.knob}: ${r.from} → ${r.to} — ${clip(String(t.reason || ""), 140)}`);
1070
+ dreamState.pendingKnobCheck = { ts: nowISO, knob: r.knob, from: r.from, to: r.to, baseline: healthOf(windowDelta || {}) };
1071
+ }
1072
+ } catch (e) { console.warn(`[dream] tuning apply failed: ${e.message}`); }
1073
+ }
1074
+
773
1075
  // Deterministic hierarchy backfill (kazee-people-* → kazee-people), after the
774
1076
  // model's parents/umbrellas decision so it only fills the obvious prefix
775
1077
  // families left flat. Backed up + announced like everything else.
776
1078
  let nestLines = [];
777
1079
  try { nestLines = nestPrefixFamilies(backupRoot).lines; }
778
1080
  catch (e) { console.warn(`[dream] nesting pass failed: ${e.message}`); }
779
- const consolidationLines = applied.concat(nestLines);
1081
+ // Evidence-driven deterministic phases — no model involved, so they run
1082
+ // (and matter most) even on nights the consolidation call fails.
1083
+ let journalLines = [], coRescueLines = [], hebbianLines = [];
1084
+ try { journalLines = journalBackfillDedupe(backupRoot).lines; }
1085
+ catch (e) { console.warn(`[dream] journal dedupe failed: ${e.message}`); }
1086
+ try { coRescueLines = coRescueEdgesPhase(ev).lines; }
1087
+ catch (e) { console.warn(`[dream] co-rescue pass failed: ${e.message}`); }
1088
+ try { hebbianLines = negativeHebbianPhase(ev).lines; }
1089
+ catch (e) { console.warn(`[dream] negative-hebbian pass failed: ${e.message}`); }
1090
+ const consolidationLines = applied.concat(nestLines, journalLines, coRescueLines, hebbianLines, knobLines);
780
1091
  let report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
781
1092
  if (modelFailNote) {
782
1093
  report = `Heads up — ${modelFailNote}, so I skipped the AI-led merges this round but still ran the automatic filing + graph upkeep.${report ? " " + report : ""}`;
@@ -820,12 +1131,22 @@ async function runDream({ trigger = "manual" } = {}) {
820
1131
  // weights, prune orphans. Deterministic + safe, runs every dream.
821
1132
  let graphNote = "";
822
1133
  try {
823
- const g = require("./recall/graph").tend(packs, entities);
1134
+ const g = recallGraphLib.tend(packs, entities, { halfLifeDays: recallTuning.get("decayHalfLifeDays") });
824
1135
  if (g.synced || g.decayed || g.pruned) {
825
1136
  graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, decayed ${g.decayed}, pruned ${g.pruned}).`;
826
1137
  }
827
1138
  } catch (e) { /* graph is best-effort */ }
828
1139
 
1140
+ // Episodic index upkeep: index any transcript turns written since the last
1141
+ // sweep and drop rows for transcript files deleted from disk.
1142
+ let episodicNote = "";
1143
+ try {
1144
+ const t = require("./transcript-index").tend();
1145
+ if (t.available && (t.added || t.prunedFiles)) {
1146
+ episodicNote = `🎞 Episodic index: ${t.added} new turn${t.added === 1 ? "" : "s"} indexed${t.prunedFiles ? `, ${t.prunedFiles} stale transcript${t.prunedFiles === 1 ? "" : "s"} pruned` : ""}.`;
1147
+ }
1148
+ } catch (e) { /* episodic index is best-effort */ }
1149
+
829
1150
  // Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
830
1151
  // prune faded/orphaned ones) and flag tools whose --pack link now dangles.
831
1152
  // Read-only toward the tools themselves — a tool is never auto-deleted (that's
@@ -877,11 +1198,59 @@ async function runDream({ trigger = "manual" } = {}) {
877
1198
  const staleNote = staleTaskReport();
878
1199
  const dreamLines = consolidationLines.concat(introApplied);
879
1200
 
1201
+ // Memory-health line: the window's KPIs since the previous dream, so every
1202
+ // morning report doubles as a harness performance readout.
1203
+ let healthNote = "";
1204
+ try {
1205
+ if (windowDelta && windowDelta.turns > 0) {
1206
+ const h = healthOf(windowDelta);
1207
+ const tiers = Object.entries(windowDelta.byTier || {})
1208
+ .filter(([, v]) => v.n > 0)
1209
+ .map(([t, v]) => `${t} ${v.n}× (${(v.ms / v.n / 1000).toFixed(1)}s)`).join(", ");
1210
+ const bits = [
1211
+ `${windowDelta.turns} recall turns`,
1212
+ `rescue ${(h.rescueRate * 100).toFixed(0)}%`,
1213
+ `noise ${(h.noiseRate * 100).toFixed(0)}%`,
1214
+ `avg ${(h.avgLatencyMs / 1000).toFixed(1)}s`,
1215
+ ];
1216
+ if (tiers) bits.push(`tiers: ${tiers}`);
1217
+ if (windowDelta.autoKept || windowDelta.autoDropped) bits.push(`cascade kept ${windowDelta.autoKept || 0}/dropped ${windowDelta.autoDropped || 0} without the judge`);
1218
+ if (windowDelta.shadowChecks) bits.push(`shadow agreement ${Math.round(100 * (windowDelta.shadowAgree || 0) / windowDelta.shadowChecks)}%`);
1219
+ if (windowDelta.reviewerSkipped) bits.push(`reviewer skipped ${windowDelta.reviewerSkipped} empty turn${windowDelta.reviewerSkipped === 1 ? "" : "s"}`);
1220
+ if (windowDelta.costUsd) bits.push(`recall spend $${windowDelta.costUsd.toFixed(2)}`);
1221
+ healthNote = `📊 Memory health since last dream: ${bits.join(" · ")}.`;
1222
+ }
1223
+ } catch (e) {}
1224
+
1225
+ // Version-stamped KPI snapshot — one line per dream night, the series the
1226
+ // `open-claudia kpi` report charts across versions/models.
1227
+ try {
1228
+ recallMetrics.appendKpi({
1229
+ trigger, fullSweep,
1230
+ models: {
1231
+ walker: process.env.RECALL_DISCOVERER_MODEL || "haiku",
1232
+ reviewer: process.env.PACK_REVIEW_MODEL || "claude-sonnet-4-6",
1233
+ dream: DREAM_MODEL,
1234
+ },
1235
+ window: windowDelta || null,
1236
+ health: windowDelta ? healthOf(windowDelta) : null,
1237
+ knobs: Object.fromEntries(recallTuning.knobTable().map((k) => [k.name, k.current])),
1238
+ });
1239
+ } catch (e) {}
1240
+
1241
+ // Persist dream state: windowing snapshot, sweep stamp, pending knob check.
1242
+ try {
1243
+ dreamState.lastRun = nowISO;
1244
+ if (fullSweep) dreamState.lastFullSweep = nowISO;
1245
+ if (ev?.summary) dreamState.summarySnapshot = ev.summary;
1246
+ writeDreamState(dreamState);
1247
+ } catch (e) { console.warn(`[dream] state write failed: ${e.message}`); }
1248
+
880
1249
  const reportPath = writeDreamReport({
881
1250
  model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
882
1251
  consolidation: { report: decision.report, lines: consolidationLines },
883
1252
  introspection: { report: introReport, lines: introApplied, proposed: introProposed },
884
- graphNote, toolNote, staleNote, backupRoot,
1253
+ healthNote, graphNote, episodicNote, toolNote, staleNote, backupRoot,
885
1254
  });
886
1255
 
887
1256
  // Richer chat summary: consolidation + introspection narrative + what
@@ -889,19 +1258,21 @@ async function runDream({ trigger = "manual" } = {}) {
889
1258
  const parts = [];
890
1259
  if (report) parts.push(`💤 ${report}`);
891
1260
  if (introReport) parts.push(`🪞 ${introReport}`);
1261
+ if (healthNote) parts.push(healthNote);
892
1262
  if (dreamLines.length) parts.push(dreamLines.join("\n"));
893
1263
  if (introProposed) {
894
1264
  const n = (introProposed.lessons_add?.length || 0) + (introProposed.doc_edits?.length || 0) + (introProposed.entity_edits?.length || 0) + (introProposed.ideas?.length || 0);
895
1265
  if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
896
1266
  }
897
1267
  if (graphNote) parts.push(graphNote);
1268
+ if (episodicNote) parts.push(episodicNote);
898
1269
  if (toolNote) parts.push(toolNote);
899
1270
  if (staleNote) parts.push(staleNote);
900
1271
  if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
901
1272
  if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
902
1273
  const message = parts.length ? parts.join("\n\n") : "";
903
1274
 
904
- return { applied: dreamLines, report, introReport, message, staleNote, graphNote, toolNote, reportPath, trigger };
1275
+ return { applied: dreamLines, report, introReport, message, healthNote, staleNote, graphNote, episodicNote, toolNote, reportPath, trigger, fullSweep };
905
1276
  } finally {
906
1277
  _dreaming = false;
907
1278
  }
@@ -937,6 +1308,8 @@ function initDream(adapters) {
937
1308
  module.exports = {
938
1309
  runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
939
1310
  buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
1311
+ summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe,
1312
+ coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
940
1313
  enabled, summaryEnabled, introspectEnabled, selfApplyEnabled,
941
- DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS,
1314
+ DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS, DREAM_STATE_FILE, FULL_SWEEP_DAYS,
942
1315
  };
@@ -294,12 +294,40 @@ function applyLessonAction(l) {
294
294
  return { kind: "skipped", reason: r.reason || "not added" };
295
295
  }
296
296
 
297
+ // Correction/preference language: a short turn carrying any of these may hold
298
+ // a Stance-worthy signal, so it always goes to the reviewer regardless of the
299
+ // deterministic skip below.
300
+ const PREFERENCE_RE = /\b(no|not|wrong|actually|instead|don'?t|never|always|prefer|remember|rule|stop|from now on|should)\b/i;
301
+
302
+ // Deterministic reviewer skip-gate. Only skips what is PROVABLY empty:
303
+ // short — combined turn text under the floor (pre-existing behaviour).
304
+ // trivial — recall tier "skip": pleasantry/ack turns.
305
+ // lookup — seeds-tier command turn where the harness AFFIRMED no write-ish
306
+ // tool ran (signals.wrote === false), the exchange is small, and
307
+ // the user text carries no correction/preference language. A pure
308
+ // status lookup has nothing to journal; anything ambiguous
309
+ // (wrote undefined, long reply, preference words) still reviews —
310
+ // accuracy first, the gate only trims provable no-ops.
311
+ function shouldSkipReview({ userText, assistantText, signals = {} }) {
312
+ const combined = (String(userText || "") + String(assistantText || "")).trim();
313
+ if (combined.length < MIN_TURN_CHARS) return "short";
314
+ if (signals.tier === "skip") return "trivial";
315
+ if (signals.tier === "seeds" && signals.wrote === false && combined.length < 1500
316
+ && !PREFERENCE_RE.test(String(userText || ""))) return "lookup";
317
+ return null;
318
+ }
319
+
297
320
  // Fire-and-forget. `announce` is an async (text) => void bound to the
298
321
  // originating channel; failures are logged, never thrown into the turn.
299
- function reviewTurn({ userText, assistantText, channelId, announce }) {
322
+ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
300
323
  if (!enabled()) return;
301
- const combined = (String(userText || "") + String(assistantText || "")).trim();
302
- if (combined.length < MIN_TURN_CHARS) return;
324
+ const skip = shouldSkipReview({ userText, assistantText, signals });
325
+ if (skip) {
326
+ if (skip !== "short") { // count only the new gate's savings, not the old floor
327
+ try { require("./recall/metrics").logReviewerSkip(); } catch (e) {}
328
+ }
329
+ return;
330
+ }
303
331
 
304
332
  const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")));
305
333
 
@@ -379,4 +407,4 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
379
407
  });
380
408
  }
381
409
 
382
- module.exports = { reviewTurn, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
410
+ module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };