@inetafrica/open-claudia 2.6.57 → 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/packs.js CHANGED
@@ -343,8 +343,35 @@ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
343
343
  } catch (e) {}
344
344
  }
345
345
 
346
+ // Near-duplicate check for journal appends. The reviewer runs after EVERY
347
+ // substantial turn, so a long working session on one topic used to stack
348
+ // dozens of same-day entries that only differ in phrasing (observed: 28 in a
349
+ // day on one pack) — crowding real history out of the capped Journal. Token
350
+ // Jaccard against the most recent entries; dates stripped before comparing.
351
+ function journalTokens(s) {
352
+ return new Set(
353
+ String(s || "").toLowerCase()
354
+ .replace(/^-\s*\[[^\]]*\]\s*/, "")
355
+ .replace(/[^a-z0-9\s]/g, " ")
356
+ .split(/\s+/).filter((w) => w.length > 1)
357
+ );
358
+ }
359
+
360
+ function isNearDupJournal(line, recentEntries) {
361
+ const a = journalTokens(line);
362
+ if (!a.size) return false;
363
+ for (const e of recentEntries) {
364
+ const b = journalTokens(e);
365
+ if (!b.size) continue;
366
+ let inter = 0;
367
+ for (const w of a) if (b.has(w)) inter++;
368
+ if (inter / (a.size + b.size - inter) >= 0.75) return true;
369
+ }
370
+ return false;
371
+ }
372
+
346
373
  // Apply a reviewer mutation. Only supplied fields change; journal entries
347
- // append (capped); state replaces (it represents "current truth").
374
+ // append (capped + deduped); state replaces (it represents "current truth").
348
375
  function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
349
376
  const pack = readPack(dir);
350
377
  if (!pack) throw new Error(`no pack: ${dir}`);
@@ -370,9 +397,14 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
370
397
  if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
371
398
  if (typeof journal === "string" && journal.trim()) {
372
399
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
373
- entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
374
- pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
375
- applied.push("Journal");
400
+ const line = journal.trim().replace(/\n+/g, " ");
401
+ if (isNearDupJournal(line, entries.slice(-5))) {
402
+ pack.journalDeduped = true;
403
+ } else {
404
+ entries.push(`- [${today()}] ${line}`);
405
+ pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
406
+ applied.push("Journal");
407
+ }
376
408
  }
377
409
  writePack(pack);
378
410
  if (applied.length) setProvenance(dir, applied, origin);
@@ -590,7 +622,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
590
622
 
591
623
  module.exports = {
592
624
  PACKS_DIR, SECTIONS, slugify,
593
- listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
625
+ listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
594
626
  listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
595
627
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
596
628
  archivePack, restorePack, listArchived,
@@ -17,6 +17,7 @@
17
17
 
18
18
  const graph = require("./graph");
19
19
  const metrics = require("./metrics");
20
+ const tuning = require("./tuning");
20
21
  const { spawnSubagent } = require("../subagent");
21
22
  const warmWalker = require("./warm-walker");
22
23
  const toolsLib = require("../tools");
@@ -26,12 +27,24 @@ const toolsLib = require("../tools");
26
27
  // entity-only). Optional — absent on old node where node:sqlite is missing.
27
28
  let toolGraph = null;
28
29
  try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
30
+ // Episodic memory: FTS over past transcripts (same optionality as the graphs).
31
+ let transcriptIndex = null;
32
+ try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
29
33
 
30
34
  const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
31
35
  const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
32
36
  const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
37
+ // Sizing/tier knobs (walker candidate cap, episode limit, seeds-tier width,
38
+ // cascade on/off) live in recall/tuning.js — dream-adjustable within hard
39
+ // bounds, env pins still win. The walker candidate cap exists because uncapped,
40
+ // a wordy turn seeds 15-40 nodes × 600-char excerpts and the walker prompt
41
+ // measured as THE dominant recall latency (p50 ~13.5s even warm).
42
+ const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
33
43
  const EXCERPT_CHARS = 600;
34
44
  const CONTEXT_CLIP = 3000;
45
+ // Fraction of cascade auto-verdicts also sent to the judge as an accuracy
46
+ // audit — divergence is logged and the dream disables the cascade if it drifts.
47
+ const SHADOW_RATE = Number(process.env.RECALL_CASCADE_SHADOW_RATE || 0.1);
35
48
 
36
49
  let _lastSync = 0;
37
50
  const SYNC_INTERVAL_MS = 60000;
@@ -45,16 +58,42 @@ function maybeSync(packsLib, entitiesLib) {
45
58
  try { graph.syncFromCorpus(packsLib, entitiesLib); } catch (e) {}
46
59
  }
47
60
 
48
- // Cheap pre-gate: terse acknowledgements / pure pleasantries don't need recall.
49
- function needsRecall(userText, seedCount) {
61
+ // Tiered pre-gate. The old boolean gate returned true whenever FTS found ANY
62
+ // seed — with a 147-pack corpus that is virtually every turn (measured: gated
63
+ // 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
64
+ // skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
65
+ // seeds — short command-like turns (≤4 words): keyword seeds are precise
66
+ // enough; inject user-origin seeds directly and skip graph + walker
67
+ // (the classic baseline, ~ms instead of ~13s).
68
+ // full — substantive turns: graph expansion + walker judgement.
69
+ 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/;
70
+
71
+ function recallTier(userText, seedCount) {
50
72
  const t = String(userText || "").trim().toLowerCase();
51
- if (!t) return false;
52
- if (seedCount > 0) return true; // FTS already found something — never gate it out
53
- // No seeds: only bother with graph/walker work for a substantive message.
73
+ if (!t) return "skip";
54
74
  const words = t.split(/\s+/).filter(Boolean);
55
- if (words.length <= 2) return false;
56
- if (/^(ok|okay|thanks|thank you|yes|yep|no|nope|cool|nice|great|done|sure|got it|k)\b/.test(t)) return false;
57
- return true;
75
+ if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
76
+ if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
77
+ if (words.length <= 2 && seedCount === 0) return "skip";
78
+ if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
79
+ return "full";
80
+ }
81
+
82
+ // Evidence-backed pre-verdict for one candidate, from its rolling usage stats.
83
+ // Conservative by design: auto-drop needs a long never-kept/never-used streak;
84
+ // auto-keep needs a high keep-rate AND at least one real use (open/reviewer).
85
+ // Anything without strong history returns null and goes to the judge.
86
+ function cascadeVerdict(st) {
87
+ if (!st) return null;
88
+ const seen = st.seen || 0, kept = st.kept || 0, used = (st.open || 0) + (st.rev || 0);
89
+ if (seen >= 6 && kept === 0 && used === 0) return "drop";
90
+ if (seen >= 5 && kept / seen >= 0.8 && used >= 1) return "keep";
91
+ return null;
92
+ }
93
+
94
+ // Back-compat boolean view of the tier (exported/tested API).
95
+ function needsRecall(userText, seedCount) {
96
+ return recallTier(userText, seedCount) !== "skip";
58
97
  }
59
98
 
60
99
  function idFor(m) { return m.dir ? `pack:${m.dir}` : `entity:${m.slug}`; }
@@ -95,6 +134,7 @@ const WALKER_SYSTEM = [
95
134
  "Decide which nodes are GENUINELY relevant to what the user is doing now.",
96
135
  "Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
97
136
  "Some candidates are reusable tools (id starts `tool:`). Keep a tool ONLY if running it would actually help with THIS task; drop tools merely related by keyword.",
137
+ "Some candidates are past-conversation episodes (id starts `episode:`). Keep one ONLY if that past exchange holds a decision, method, or outcome directly reusable for the current message — not mere topic overlap.",
98
138
  "For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
99
139
  'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
100
140
  ].join("\n");
@@ -102,20 +142,25 @@ const WALKER_SYSTEM = [
102
142
  // Run the walker model on the prompt. Prefer the warm (reused) process for
103
143
  // low latency; on any warm-path error fall back to a cold spawn — identical
104
144
  // model/prompt/contract, so quality is unchanged and recall never silently
105
- // degrades to the classic engine.
145
+ // degrades to the classic engine. Returns { text, costUsd, source }.
106
146
  async function runWalker(prompt) {
107
147
  const opts = { model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS };
108
148
  if (warmWalker.isEnabled()) {
109
149
  try {
110
- return await warmWalker.walkWarm(prompt, opts);
150
+ const r = await warmWalker.walkWarm(prompt, opts);
151
+ return { text: r.text, costUsd: r.costUsd, source: "warm" };
111
152
  } catch (e) { /* fall back to cold spawn below */ }
112
153
  }
113
154
  const { text } = await spawnSubagent(prompt, opts);
114
- return text;
155
+ return { text, costUsd: null, source: "cold" };
115
156
  }
116
157
 
158
+ // Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
159
+ // kept null means fail-open (walker off/failed/unparseable).
117
160
  async function walk(userText, contextText, candidates) {
118
- if (!WALKER_ENABLED || candidates.length === 0) return null;
161
+ if (!WALKER_ENABLED || candidates.length === 0) {
162
+ return { kept: null, costUsd: null, source: "off", walkerMs: 0 };
163
+ }
119
164
  const lines = candidates.map((c) => {
120
165
  const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
121
166
  return `- ${c.id}: ${c.name}${tag}${c.description ? `\n ${c.description}` : ""}${c.excerpt ? `\n excerpt: ${c.excerpt.replace(/\n/g, " ")}` : ""}`;
@@ -130,12 +175,14 @@ async function walk(userText, contextText, candidates) {
130
175
  "",
131
176
  'Reply ONLY with the JSON array of kept nodes and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
132
177
  ].join("\n");
178
+ const t0 = Date.now();
133
179
  try {
134
- const text = await runWalker(prompt);
180
+ const { text, costUsd, source } = await runWalker(prompt);
181
+ const walkerMs = Date.now() - t0;
135
182
  const match = String(text || "").match(/\[[\s\S]*\]/);
136
- if (!match) return null;
183
+ if (!match) return { kept: null, costUsd, source, walkerMs };
137
184
  const arr = JSON.parse(match[0]);
138
- if (!Array.isArray(arr)) return null;
185
+ if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs };
139
186
  const known = new Set(candidates.map((c) => c.id));
140
187
  const out = new Map();
141
188
  for (const item of arr) {
@@ -143,9 +190,9 @@ async function walk(userText, contextText, candidates) {
143
190
  out.set(item.id, String(item.why || "").slice(0, 120));
144
191
  }
145
192
  }
146
- return out;
193
+ return { kept: out, costUsd, source, walkerMs };
147
194
  } catch (e) {
148
- return null;
195
+ return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0 };
149
196
  }
150
197
  }
151
198
 
@@ -154,8 +201,6 @@ async function run(ctx) {
154
201
  const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
155
202
  const started = Date.now();
156
203
 
157
- maybeSync(packsLib, entitiesLib);
158
-
159
204
  // 1+2: seed via FTS (user words + context), same as classic.
160
205
  let packSeeds = [];
161
206
  let entSeeds = [];
@@ -185,32 +230,55 @@ async function run(ctx) {
185
230
  } catch (e) {}
186
231
 
187
232
  const seedCount = packSeeds.length + entSeeds.length + toolSeedByName.size;
233
+ const seedMs = Date.now() - started;
188
234
 
189
- // 1: pre-gate.
190
- if (!needsRecall(userText, seedCount)) {
191
- metrics.logTurn({ engine: "discoverer", query: userText, gated: true, latencyMs: Date.now() - started });
192
- return { packBlock: "", entityBlock: "", toolBlock: "", packMatches: [], entityMatches: [], toolMatches: [], why: {}, gated: true };
235
+ // 1: tiered pre-gate.
236
+ const tier = recallTier(userText, seedCount);
237
+ if (tier === "skip") {
238
+ metrics.logTurn({ engine: "discoverer", model: WALKER_MODEL, query: userText, gated: true, tier, latencyMs: Date.now() - started });
239
+ return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true, tier };
193
240
  }
194
241
 
195
- // 3: spreading activation from seeds across the graph.
242
+ // 3: spreading activation from seeds across the graph (full tier only — the
243
+ // seeds tier injects keyword seeds directly, no graph, no walker).
196
244
  const seedNodes = [
197
245
  ...packSeeds.map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
198
246
  ...entSeeds.map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
199
247
  ];
200
248
  const seedIds = new Set(seedNodes.map((s) => s.id));
201
249
  let activated = new Map();
202
- try { activated = graph.expand(seedNodes, {}); } catch (e) {}
250
+ const expandStarted = Date.now();
251
+ if (tier === "full") {
252
+ maybeSync(packsLib, entitiesLib);
253
+ try { activated = graph.expand(seedNodes, {}); } catch (e) {}
254
+ }
255
+ const expandMs = Date.now() - expandStarted;
203
256
 
204
- // 4: assemble candidates (seeds + activated), build excerpts, run the walker.
205
- const candIds = new Set(seedIds);
206
- for (const id of activated.keys()) candIds.add(id);
257
+ // 4: assemble candidates (seeds + activated) in priority order user seeds
258
+ // by score, then context seeds, then graph-activated by activation — capped
259
+ // at MAX_WALKER_CANDIDATES so the walker prompt stays small and fast. Only
260
+ // the full tier pays for excerpts; the seeds tier never reads them.
261
+ const seedRank = new Map();
262
+ for (const m of packSeeds) seedRank.set(`pack:${m.dir}`, { score: m.score || 2, origin: m.origin });
263
+ for (const m of entSeeds) seedRank.set(`entity:${m.slug}`, { score: m.score || 2, origin: m.origin });
264
+ const orderedIds = [...seedIds].sort((a, b) => {
265
+ const ra = seedRank.get(a) || {}, rb = seedRank.get(b) || {};
266
+ const ua = ra.origin === "user" ? 0 : 1, ub = rb.origin === "user" ? 0 : 1;
267
+ return ua - ub || (rb.score || 0) - (ra.score || 0);
268
+ });
269
+ for (const [id] of [...activated.entries()].sort((x, y) => (y[1].activation || 0) - (x[1].activation || 0))) {
270
+ if (!seedIds.has(id)) orderedIds.push(id);
271
+ }
207
272
  const candidates = [];
208
- for (const id of candIds) {
209
- const base = excerptFor(id, packsLib, entitiesLib);
210
- if (!base) continue;
211
- const act = activated.get(id);
212
- candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
273
+ if (tier === "full") {
274
+ for (const id of orderedIds.slice(0, tuning.get("walkerMaxCandidates"))) {
275
+ const base = excerptFor(id, packsLib, entitiesLib);
276
+ if (!base) continue;
277
+ const act = activated.get(id);
278
+ candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
279
+ }
213
280
  }
281
+ const candIds = new Set(tier === "full" ? candidates.map((c) => c.id) : seedIds);
214
282
 
215
283
  // Tool candidates = direct tool seeds + tools that belong to any candidate
216
284
  // pack (declared via the tool's `pack:` header, or used-in-topic per the
@@ -221,7 +289,7 @@ async function run(ctx) {
221
289
  for (const id of candIds) if (id.startsWith("pack:")) candPackDirs.add(id.slice(5));
222
290
  const toolCand = new Map();
223
291
  for (const [name, t] of toolSeedByName) toolCand.set(name, { name, description: t.description, via: null });
224
- if (candPackDirs.size) {
292
+ if (tier === "full" && candPackDirs.size) {
225
293
  try {
226
294
  const byPack = new Map();
227
295
  for (const t of toolsLib.listTools()) {
@@ -247,19 +315,95 @@ async function run(ctx) {
247
315
  } catch (e) {}
248
316
  }
249
317
  }
250
- for (const c of toolCand.values()) {
251
- const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
252
- if (!base) continue;
253
- candidates.push({ ...base, activated: !!c.via, via: c.via });
318
+ if (tier === "full") {
319
+ for (const c of toolCand.values()) {
320
+ const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
321
+ if (!base) continue;
322
+ candidates.push({ ...base, activated: !!c.via, via: c.via });
323
+ }
324
+ }
325
+
326
+ // Episodic hop: FTS over past transcripts turns up prior sessions on this
327
+ // topic; the walker judges them like any node. This is the bridge from
328
+ // knowledge (packs) to episodes (what we actually did last time) — kept ones
329
+ // inject as a snippet plus a pointer to reopen the conversation window.
330
+ let episodeCands = [];
331
+ if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
332
+ try {
333
+ const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
334
+ episodeCands = (hits || []).map((h) => {
335
+ const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
336
+ return {
337
+ id: `episode:${require("path").basename(String(h.file || ""), ".jsonl")}:${h.line}`,
338
+ name: `Past conversation${h.project ? ` (${h.project})` : ""}${h.ts ? ` ${String(h.ts).slice(0, 10)}` : ""}`,
339
+ description: "",
340
+ excerpt: clip(String(h.snip || "").replace(/>>|<</g, ""), EXCERPT_CHARS),
341
+ activated: true,
342
+ via: "transcript",
343
+ _terms: terms,
344
+ };
345
+ });
346
+ candidates.push(...episodeCands);
347
+ } catch (e) { /* episodic recall is best-effort */ }
348
+ }
349
+
350
+ // Walker cascade: candidates with strong usage history get an evidence-backed
351
+ // auto-verdict and skip the judge — the model reads only the ambiguous middle
352
+ // (fewer tokens, less noise in the prompt). A random SHADOW_RATE slice of
353
+ // auto-decided candidates is judged anyway and compared, so the shortcut is
354
+ // continuously audited against the model it replaces; on shadowed candidates
355
+ // the judge's verdict wins. Top-3 ranked candidates and episodes (no stable
356
+ // history) always go to the judge.
357
+ const cascadeOn = tier === "full" && tuning.get("cascade") !== "off";
358
+ const autoKept = new Map(); // id -> canned why (honest provenance)
359
+ const autoDropped = [];
360
+ const shadowVerdicts = new Map(); // id -> auto verdict, also sent to judge
361
+ let judgeCands = candidates;
362
+ if (cascadeOn && candidates.length) {
363
+ let nodeHist = {};
364
+ try { nodeHist = metrics.nodeStats().nodes || {}; } catch (e) {}
365
+ const protectedIds = new Set(orderedIds.slice(0, 3));
366
+ judgeCands = [];
367
+ for (const c of candidates) {
368
+ const st = c.id.startsWith("episode:") || protectedIds.has(c.id) ? null : nodeHist[c.id];
369
+ const v = cascadeVerdict(st);
370
+ if (!v) { judgeCands.push(c); continue; }
371
+ if (Math.random() < SHADOW_RATE) { shadowVerdicts.set(c.id, v); judgeCands.push(c); continue; }
372
+ if (v === "keep") autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
373
+ else autoDropped.push(c.id);
374
+ }
254
375
  }
255
376
 
256
- const whyById = (await walk(userText, contextText || fullContext, candidates)) || null;
377
+ const walkRes = await walk(userText, contextText || fullContext, judgeCands);
378
+ const whyById = walkRes.kept;
257
379
 
258
- // Decide the kept set. Walker result wins; on fail-open keep the user-origin
259
- // seeds only (classic baseline) and drop graph-expanded/context guesses.
380
+ let shadowChecks = 0, shadowAgree = 0;
381
+ if (whyById && shadowVerdicts.size) {
382
+ for (const [id, v] of shadowVerdicts) {
383
+ const agree = (v === "keep") === whyById.has(id);
384
+ shadowChecks++;
385
+ if (agree) shadowAgree++;
386
+ metrics.logShadow(agree);
387
+ }
388
+ }
389
+
390
+ // Decide the kept set. Walker result + evidence auto-keeps win; on walker
391
+ // fail-open (full tier) keep the strongest 3 user-origin seeds only — an
392
+ // unjudged tool or episode is a guess, not a memory (measured: a failed walk
393
+ // once kept 15 unjudged seeds including WiFi tools for a dream question). The
394
+ // seeds tier keeps its classic contract: all user-origin keyword seeds.
395
+ let whyMap = whyById ? new Map(whyById) : null;
396
+ if (whyMap) for (const [id, why] of autoKept) whyMap.set(id, why);
260
397
  let keptIds;
261
- if (whyById) {
262
- keptIds = new Set(whyById.keys());
398
+ if (whyMap) {
399
+ keptIds = new Set(whyMap.keys());
400
+ } else if (tier === "full") {
401
+ const rankedUserSeeds = [
402
+ ...packSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
403
+ ...entSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
404
+ ].sort((a, b) => b.score - a.score).slice(0, 3).map((s) => s.id);
405
+ keptIds = new Set([...autoKept.keys(), ...rankedUserSeeds]);
406
+ if (autoKept.size) whyMap = new Map(autoKept);
263
407
  } else {
264
408
  keptIds = new Set(
265
409
  [...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
@@ -293,9 +437,9 @@ async function run(ctx) {
293
437
  // 5: render via shared builders, then weave in the why-bullets.
294
438
  let packBlock = buildPackBlock(finalPacks, budget);
295
439
  const entityBlock = buildEntityBlock(finalEnts, budget);
296
- if (packBlock && whyById) {
440
+ if (packBlock && whyMap) {
297
441
  const bullets = finalPacks
298
- .map((m) => ({ m, why: whyById.get(`pack:${m.dir}`) }))
442
+ .map((m) => ({ m, why: whyMap.get(`pack:${m.dir}`) }))
299
443
  .filter((x) => x.why)
300
444
  .map((x) => `- ${x.m.name || x.m.dir}: ${x.why}`);
301
445
  if (bullets.length) packBlock = `\n\n### Why these surfaced (discoverer)\n${bullets.join("\n")}${packBlock}`;
@@ -307,7 +451,7 @@ async function run(ctx) {
307
451
  const toolMatches = [];
308
452
  for (const c of toolCand.values()) {
309
453
  if (keptIds.has(`tool:${c.name}`)) {
310
- toolMatches.push({ name: c.name, description: c.description, why: whyById ? whyById.get(`tool:${c.name}`) || "" : "" });
454
+ toolMatches.push({ name: c.name, description: c.description, why: whyMap ? whyMap.get(`tool:${c.name}`) || "" : "" });
311
455
  }
312
456
  }
313
457
  let toolBlock = "";
@@ -320,20 +464,45 @@ async function run(ctx) {
320
464
  toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
321
465
  }
322
466
 
467
+ // Past-conversation episodes the walker kept (fail-open drops them — an
468
+ // unjudged transcript snippet is a guess, not a memory).
469
+ const episodeMatches = episodeCands
470
+ .filter((c) => keptIds.has(c.id))
471
+ .map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why: whyMap ? whyMap.get(c.id) || "" : "" }));
472
+ let episodeBlock = "";
473
+ if (episodeMatches.length) {
474
+ const lines = episodeMatches.map((m) => {
475
+ const q = (m.terms && m.terms.length ? m.terms.join(" ") : String(m.excerpt).split(/\s+/).slice(0, 4).join(" ")).replace(/"/g, "");
476
+ return `- ${m.name}: "${m.excerpt}"${m.why ? ` — ${m.why}` : ""}\n ↳ full context: open-claudia transcript-search "${q}" --all`;
477
+ });
478
+ episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
479
+ }
480
+
323
481
  metrics.logTurn({
324
482
  engine: "discoverer",
483
+ model: WALKER_MODEL,
325
484
  query: userText,
485
+ tier,
326
486
  seeds: seedNodes,
327
487
  activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
328
- kept: [...keptIds].map((id) => ({ id, why: whyById ? whyById.get(id) : "" })),
488
+ cand: candidates.map((c) => ({ id: c.id, act: !!c.activated })),
489
+ kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "" })),
490
+ auto: (autoKept.size || autoDropped.length || shadowChecks)
491
+ ? { kept: [...autoKept.keys()], dropped: autoDropped, shadow: { checks: shadowChecks, agree: shadowAgree } }
492
+ : null,
329
493
  gated: false,
330
494
  latencyMs: Date.now() - started,
495
+ seedMs,
496
+ expandMs,
497
+ walkerMs: walkRes.walkerMs,
498
+ walker: walkRes.source,
499
+ costUsd: walkRes.costUsd || 0,
331
500
  });
332
501
 
333
502
  return {
334
- packBlock, entityBlock, toolBlock, packMatches: finalPacks, entityMatches: finalEnts,
335
- toolMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
503
+ packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
504
+ toolMatches, episodeMatches, why: whyMap ? Object.fromEntries(whyMap) : {}, gated: false, tier,
336
505
  };
337
506
  }
338
507
 
339
- module.exports = { name: "discoverer", run, needsRecall, walk };
508
+ module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict };
@@ -156,6 +156,33 @@ function reinforceSet(nodeIds, amount = 1) {
156
156
  }
157
157
  }
158
158
 
159
+ // Negative Hebbian: weaken every edge touching a node that keeps surfacing
160
+ // 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.
165
+ function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
166
+ const db = openDb();
167
+ if (!db || !nodeId) return { weakened: 0, pruned: 0 };
168
+ let weakened = 0, pruned = 0;
169
+ try {
170
+ const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
171
+ for (const e of rows) {
172
+ const floor = BASE_WEIGHT[e.type] || 0;
173
+ const next = Math.max(floor, e.weight * factor);
174
+ if (prune && e.type === "related" && next <= floor + 0.05) {
175
+ db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
176
+ pruned++;
177
+ } 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);
179
+ weakened++;
180
+ }
181
+ }
182
+ } catch (e) {}
183
+ return { weakened, pruned };
184
+ }
185
+
159
186
  // Exponential time decay on reinforced weight. Structural floor is preserved:
160
187
  // an edge never decays below its type's base weight.
161
188
  function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
@@ -377,7 +404,7 @@ module.exports = {
377
404
  EDGE_TYPES, DEFAULTS, GRAPH_DB,
378
405
  available, openDb,
379
406
  addEdge, removeEdge, allEdges, neighbors,
380
- reinforce, reinforceSet, decay, expand,
381
- syncFromCorpus, parseLinks, pruneOrphans, tend, stats,
407
+ reinforce, reinforceSet, decay, weakenNode, expand,
408
+ syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
382
409
  _resetForTest,
383
410
  };