@inetafrica/open-claudia 2.6.32 → 2.6.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.34
4
+ - **Dual-engine memory recall: typed-edge graph + discoverer (opt-in).** Recall is now pluggable behind a narrow engine interface (`core/recall/`), selected per chat via `/engine` (callback `eng:`). The default stays **classic** (FTS keyword match + relevance judge + headline injection) — unchanged behaviour. The new **discoverer** engine adds a typed-edge graph (`parent`/`governed-by`/`related` edges with weight + last_reinforced in a SQLite store at `recall-graph.db`) and runs: heuristic pre-gate → FTS seed → spreading activation across the graph (1–2 hops, top-k, firing threshold — auto-pulls cross-cutting concerns the query never named) → a haiku "walker" that reads each candidate's Stance/State excerpt and returns the genuinely-relevant set with one-line why-bullets (fail-open to keyword seeds, so it never recalls worse than classic). Edges form structurally from pack `parent` frontmatter and `[[links]]` (a `[[link]]` to a `shared`-tagged pack becomes `governed-by`) and strengthen via **Hebbian co-use** — when the agent actually opens packs together in a turn (📖), their `related` edge is reinforced; weights decay over time. A metrics layer logs every discoverer turn (seeds, activated, kept+why, pre-gate, latency) — view with `open-claudia recall-stats` and `open-claudia recall graph [--sync]`. The nightly dream now runs on a high tier (opus; `DREAM_TIER`/`DREAM_MODEL` override) and tends the graph (structural sync, weight decay, orphan prune). Switch back any time with `/engine classic`.
5
+
3
6
  ## v2.6.30
4
7
  - **Cluster self-management for AgentSpace pods.** When running as an AgentSpace pod, the bot can inspect and manage its own deployment through the backend broker — `/cluster status|logs [n]|restart|start|stop|scale <0|1>|sync` (owner-gated) for humans, and `open-claudia cluster …` for the agent. Both are thin clients over `core/cluster-client.js`, which posts to the broker's capability-gated `/pods/self/cluster` endpoint with the pod's bearer token; the bot holds no Kubernetes credentials. `brokerConfigured()` requires both `AGENTSPACE_API_URL` and `AGENTSPACE_POD_TOKEN`, so an unconfigured local install safely replies "not available" while a provisioned pod reaches the broker. Also tightens the `core/pack-guard.js` exfil heuristic: the broad "URL co-occurring with a sensitive word" rule (which false-positived on legitimate infra notes) is replaced by two precise carriers — a send/store/upload verb pointed at a URL/address, and a credential attached directly to a URL as a query/fragment param.
5
8
 
package/bin/cli.js CHANGED
@@ -294,6 +294,15 @@ switch (command) {
294
294
  break;
295
295
  }
296
296
 
297
+ case "recall-stats": {
298
+ require("./recall").run(["stats", ...args.slice(1)]);
299
+ break;
300
+ }
301
+ case "recall": {
302
+ require("./recall").run(args.slice(1));
303
+ break;
304
+ }
305
+
297
306
  case "dream": {
298
307
  require("./dream").run(args.slice(1));
299
308
  break;
package/bin/recall.js ADDED
@@ -0,0 +1,64 @@
1
+ // open-claudia recall-stats [--json]
2
+ // open-claudia recall graph [--sync] [--json]
3
+ //
4
+ // Inspect the recall system: discoverer metrics (precision/miss-rescue/noise,
5
+ // latency, cost) and the typed-edge graph (node/edge counts, sync).
6
+
7
+ const path = require("path");
8
+
9
+ const HELP = `
10
+ Inspect the memory recall system.
11
+
12
+ open-claudia recall-stats [--json] Discoverer metrics summary
13
+ open-claudia recall graph [--sync] Recall-graph stats (optionally re-sync structural edges first)
14
+
15
+ The discoverer engine logs one line per turn to recall-metrics.jsonl and a
16
+ rolling summary to recall-metrics-summary.json. Switch engines per chat with
17
+ the /engine command (classic stays the default).
18
+ `;
19
+
20
+ function stats(args) {
21
+ const metrics = require(path.join(__dirname, "..", "core", "recall", "metrics"));
22
+ const s = metrics.summary();
23
+ if (args.includes("--json")) { console.log(JSON.stringify(s, null, 2)); return; }
24
+ console.log("Recall metrics (discoverer engine)");
25
+ console.log("──────────────────────────────────");
26
+ console.log(`Turns logged: ${s.turns}`);
27
+ console.log(`Pre-gated (skipped): ${s.gatedTurns} (${s.gatedPct})`);
28
+ console.log(`Avg seeds/turn: ${s.avgSeeds}`);
29
+ console.log(`Avg activated/turn: ${s.avgActivated}`);
30
+ console.log(`Avg kept/turn: ${s.avgKept}`);
31
+ console.log(`Graph rescues: ${s.rescues} over ${s.rescueTurns} turns (${s.rescuePct})`);
32
+ console.log(`Nodes opened (📖): ${s.opens}`);
33
+ console.log(`Avg latency: ${s.avgLatencyMs}ms`);
34
+ console.log(`Cost: $${s.costUsd}`);
35
+ if (s.updatedAt) console.log(`Updated: ${s.updatedAt}`);
36
+ }
37
+
38
+ function graph(args) {
39
+ const g = require(path.join(__dirname, "..", "core", "recall", "graph"));
40
+ if (!g.available()) { console.error("Recall graph unavailable (node:sqlite not present)."); process.exit(1); }
41
+ if (args.includes("--sync")) {
42
+ const packs = require(path.join(__dirname, "..", "core", "packs"));
43
+ const entities = require(path.join(__dirname, "..", "core", "entities"));
44
+ const r = g.syncFromCorpus(packs, entities);
45
+ console.error(`Synced ${r.edges} structural edge(s).`);
46
+ }
47
+ const s = g.stats();
48
+ if (args.includes("--json")) { console.log(JSON.stringify(s, null, 2)); return; }
49
+ console.log("Recall graph");
50
+ console.log("────────────");
51
+ console.log(`Nodes: ${s.nodes}`);
52
+ console.log(`Edges: ${s.edges}`);
53
+ for (const [type, n] of Object.entries(s.byType || {})) console.log(` ${type}: ${n}`);
54
+ }
55
+
56
+ function run(args) {
57
+ const sub = args[0];
58
+ if (sub === "--help" || sub === "help") { console.log(HELP); process.exit(0); }
59
+ if (sub === "graph") { graph(args.slice(1)); return; }
60
+ // default / "stats": metrics summary
61
+ stats(args.filter((a) => a !== "stats"));
62
+ }
63
+
64
+ module.exports = { run, HELP };
package/core/actions.js CHANGED
@@ -274,6 +274,14 @@ async function handleAction(envelope) {
274
274
  if (d.startsWith("m:")) { state.settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${state.settings.model || "default"}`); return; }
275
275
  if (d.startsWith("e:")) { const e = d.slice(2); state.settings.effort = e === "default" ? null : e; await send(`Effort: ${state.settings.effort || "default"}`); return; }
276
276
  if (d.startsWith("b:")) { const b = d.slice(2); state.settings.budget = b === "none" ? null : parseFloat(b); await send(`Budget: ${state.settings.budget ? "$" + state.settings.budget : "none"}`); return; }
277
+ if (d.startsWith("eng:")) {
278
+ const v = d.slice(4);
279
+ const recall = require("./recall");
280
+ state.settings.recallEngine = recall.listEngines().includes(v) ? v : null;
281
+ saveState();
282
+ await send(`Recall engine: ${state.settings.recallEngine || "classic (default)"}`);
283
+ return;
284
+ }
277
285
  if (d.startsWith("cw:")) {
278
286
  const v = d.slice(3);
279
287
  if (v === "default") state.settings.compactWindow = null;
package/core/dream.js CHANGED
@@ -21,7 +21,19 @@ const packGuard = require("./pack-guard");
21
21
  const persona = require("./persona");
22
22
  const { spawnSubagent } = require("./subagent");
23
23
 
24
- const DREAM_MODEL = process.env.DREAM_MODEL || "sonnet";
24
+ // Graph surgery + threshold tuning is the highest-judgment work in the recall
25
+ // system, so the dream runs on the best available high tier. Honour an explicit
26
+ // DREAM_MODEL override; otherwise prefer opus (strongest reliably-available
27
+ // Claude tier) with sonnet as graceful fallback. Provider-specific high tiers
28
+ // (e.g. a connected Codex 5.5) can extend pickDreamModel later.
29
+ function pickDreamModel() {
30
+ if (process.env.DREAM_MODEL) return process.env.DREAM_MODEL;
31
+ const tier = String(process.env.DREAM_TIER || "high").toLowerCase();
32
+ if (tier === "low") return "haiku";
33
+ if (tier === "medium") return "sonnet";
34
+ return "opus";
35
+ }
36
+ const DREAM_MODEL = pickDreamModel();
25
37
  const DREAM_CRON = process.env.DREAM_CRON || "0 4 * * *";
26
38
  const MAX_PACK_CHARS = 2500;
27
39
  const MAX_ENTITY_CHARS = 900;
@@ -372,14 +384,25 @@ async function runDream({ trigger = "manual" } = {}) {
372
384
  const applied = applyDream(decision, backupRoot);
373
385
  const report = decision.report || (applied.length > 0 ? "Tidied up my memory overnight." : "");
374
386
 
387
+ // Recall-graph maintenance: refresh structural edges, decay reinforced
388
+ // weights, prune orphans. Deterministic + safe, runs every dream.
389
+ let graphNote = "";
390
+ try {
391
+ const g = require("./recall/graph").tend(packs, entities);
392
+ if (g.synced || g.decayed || g.pruned) {
393
+ graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, decayed ${g.decayed}, pruned ${g.pruned}).`;
394
+ }
395
+ } catch (e) { /* graph is best-effort */ }
396
+
375
397
  const staleNote = staleTaskReport();
376
398
 
377
399
  let message = applied.length > 0
378
400
  ? `💤 ${report}\n\n${applied.join("\n")}\n\n🗄 Anything merged away is backed up under ${backupRoot}`
379
401
  : (report ? `💤 ${report}` : "");
402
+ if (graphNote) message = message ? `${message}\n\n${graphNote}` : `💤 ${graphNote}`;
380
403
  if (staleNote) message = message ? `${message}\n\n${staleNote}` : `💤 ${staleNote}`;
381
404
 
382
- return { applied, report, message, staleNote, trigger };
405
+ return { applied, report, message, staleNote, graphNote, trigger };
383
406
  } finally {
384
407
  _dreaming = false;
385
408
  }
package/core/handlers.js CHANGED
@@ -122,7 +122,7 @@ register({
122
122
  if (!authorized(env)) return;
123
123
  send([
124
124
  "Session: /session /sessions /projects /continue /status /stop /end",
125
- "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode",
125
+ "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine",
126
126
  "Identity: /whoami /link",
127
127
  "Team: /people /intros /auth (owner)",
128
128
  "Automation: /cron /vault /soul /dreamsummary",
@@ -693,6 +693,32 @@ register({
693
693
  },
694
694
  });
695
695
 
696
+ register({
697
+ name: "engine", description: "Switch memory recall engine", args: "[classic|discoverer]",
698
+ handler: async (env, { tail }) => {
699
+ if (!authorized(env)) return;
700
+ const { settings } = currentState();
701
+ const recall = require("./recall");
702
+ const active = recall.activeEngineName(settings);
703
+ if (tail) {
704
+ const v = tail.trim().toLowerCase();
705
+ if (v === "classic" || v === "default") settings.recallEngine = null;
706
+ else if (recall.listEngines().includes(v)) settings.recallEngine = v;
707
+ else return send(`Unknown engine "${v}". Options: ${recall.listEngines().join(", ")}.`);
708
+ saveState();
709
+ return send(`Recall engine: ${settings.recallEngine || "classic (default)"}`);
710
+ }
711
+ send(
712
+ `Recall engine: ${active}${settings.recallEngine ? "" : " (default)"}\n\n` +
713
+ "• classic — keyword FTS + relevance judge (stable default)\n" +
714
+ "• discoverer — typed-edge graph + spreading activation + why-bullets (new, opt-in)",
715
+ { keyboard: { inline_keyboard: [
716
+ [{ text: "Classic (default)", callback_data: "eng:classic" }, { text: "Discoverer", callback_data: "eng:discoverer" }],
717
+ ] } },
718
+ );
719
+ },
720
+ });
721
+
696
722
  register({
697
723
  name: "budget", description: "Set max spend for next task", args: "[$N]",
698
724
  handler: async (env, { tail }) => {
@@ -216,7 +216,7 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
216
216
  } else if (r) {
217
217
  lines.push(r.kind === "create"
218
218
  ? `📦 New pack: ${r.name}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`
219
- : `✏️ ${r.name} — ${clipWords(r.note, 180)}`);
219
+ : `✏️ ${r.name} — ${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
220
220
  }
221
221
  } catch (e) {
222
222
  console.warn(`[pack-review] apply failed: ${e.message}`);
@@ -0,0 +1,49 @@
1
+ // Classic recall engine: FTS keyword match (user words + context) → LLM
2
+ // relevance judge → headline injection. This is the original pipeline,
3
+ // extracted behind the RecallEngine interface with NO behaviour change.
4
+ //
5
+ // RecallEngine interface:
6
+ // async run(ctx) -> { packBlock, entityBlock, packMatches, entityMatches }
7
+ // ctx = { userText, contextText, fullContext, packLimit, budget, helpers }
8
+ // helpers = { packsLib, entitiesLib, mergeMatches, filterMatches, logRecall,
9
+ // buildPackBlock, buildEntityBlock }
10
+ // The renderers (buildPackBlock/buildEntityBlock) stay in system-prompt.js and
11
+ // are injected here so module-local injection caches / lastInjected keep working.
12
+
13
+ async function run(ctx) {
14
+ const { userText, fullContext, packLimit, budget, helpers } = ctx;
15
+ const {
16
+ packsLib, entitiesLib, mergeMatches, filterMatches, logRecall,
17
+ buildPackBlock, buildEntityBlock,
18
+ } = helpers;
19
+
20
+ let packMatches = [];
21
+ let entityMatches = [];
22
+ try {
23
+ packMatches = mergeMatches(
24
+ packsLib.matchPacks(userText, { limit: packLimit }),
25
+ fullContext ? packsLib.matchPacks(fullContext, { limit: packLimit }) : [],
26
+ (m) => m.dir,
27
+ );
28
+ } catch (e) {}
29
+ try {
30
+ entityMatches = mergeMatches(
31
+ entitiesLib.matchEntities(userText, { limit: 4 }),
32
+ fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
33
+ (m) => m.slug,
34
+ );
35
+ } catch (e) {}
36
+
37
+ const candPacks = packMatches;
38
+ const candEntities = entityMatches;
39
+ ({ packMatches, entityMatches } = await filterMatches(userText, fullContext, packMatches, entityMatches));
40
+ packMatches = packMatches.slice(0, packLimit);
41
+ entityMatches = entityMatches.slice(0, 4);
42
+ logRecall(userText, candPacks, candEntities, packMatches, entityMatches);
43
+
44
+ const packBlock = buildPackBlock(packMatches, budget);
45
+ const entityBlock = buildEntityBlock(entityMatches, budget);
46
+ return { packBlock, entityBlock, packMatches, entityMatches };
47
+ }
48
+
49
+ module.exports = { name: "classic", run };
@@ -0,0 +1,231 @@
1
+ // Discoverer recall engine. Same shared corpus as classic, different retrieval:
2
+ //
3
+ // 1. Pre-gate (cheap heuristic) — skip recall on trivial/terse turns.
4
+ // 2. Seed — FTS match (user words + context), same matchers as classic.
5
+ // 3. Spreading activation — propagate from seeds across the typed-edge graph
6
+ // 1-2 hops; nodes over the firing threshold join the candidate set. This
7
+ // auto-pulls governed-by concerns (theme, commit style) the query never
8
+ // named, which is the whole point of the graph.
9
+ // 4. Walker (haiku) — reads the seed+activated candidates' live sections and
10
+ // returns the truly-relevant set WITH a one-line "why" per node. Facts are
11
+ // quoted, not paraphrased. Fail-open: any error keeps the seeds (classic
12
+ // baseline), so the discoverer never recalls worse than keyword matching.
13
+ // 5. Inject — render via the shared block builders, prefixed with why-bullets.
14
+ //
15
+ // Conforms to RecallEngine: run(ctx) -> { packBlock, entityBlock, packMatches,
16
+ // entityMatches }. Downstream injection/budget/banner logic stays engine-agnostic.
17
+
18
+ const graph = require("./graph");
19
+ const metrics = require("./metrics");
20
+ const { spawnSubagent } = require("../subagent");
21
+
22
+ const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
23
+ const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
24
+ const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
25
+ const EXCERPT_CHARS = 600;
26
+ const CONTEXT_CLIP = 3000;
27
+
28
+ let _lastSync = 0;
29
+ const SYNC_INTERVAL_MS = 60000;
30
+
31
+ // Keep the structural graph fresh without paying on every turn.
32
+ function maybeSync(packsLib, entitiesLib) {
33
+ if (!graph.available()) return;
34
+ const t = Date.now();
35
+ if (t - _lastSync < SYNC_INTERVAL_MS) return;
36
+ _lastSync = t;
37
+ try { graph.syncFromCorpus(packsLib, entitiesLib); } catch (e) {}
38
+ }
39
+
40
+ // Cheap pre-gate: terse acknowledgements / pure pleasantries don't need recall.
41
+ function needsRecall(userText, seedCount) {
42
+ const t = String(userText || "").trim().toLowerCase();
43
+ if (!t) return false;
44
+ if (seedCount > 0) return true; // FTS already found something — never gate it out
45
+ // No seeds: only bother with graph/walker work for a substantive message.
46
+ const words = t.split(/\s+/).filter(Boolean);
47
+ if (words.length <= 2) return false;
48
+ if (/^(ok|okay|thanks|thank you|yes|yep|no|nope|cool|nice|great|done|sure|got it|k)\b/.test(t)) return false;
49
+ return true;
50
+ }
51
+
52
+ function idFor(m) { return m.dir ? `pack:${m.dir}` : `entity:${m.slug}`; }
53
+
54
+ function clip(s, n) { return s && s.length > n ? s.slice(0, n) + "…" : (s || ""); }
55
+
56
+ // Build the candidate excerpt the walker judges against — real content, not
57
+ // just a description, so "reads into the node" is meaningful.
58
+ function excerptFor(id, packsLib, entitiesLib) {
59
+ try {
60
+ if (id.startsWith("pack:")) {
61
+ const p = packsLib.readPack(id.slice(5));
62
+ if (!p) return null;
63
+ const body = [p.sections.Stance, p.sections.State].filter(Boolean).join("\n").trim();
64
+ return { id, name: p.name, description: p.description, excerpt: clip(body, EXCERPT_CHARS) };
65
+ }
66
+ const e = entitiesLib.readEntity(id.slice(7));
67
+ if (!e) return null;
68
+ return { id, name: e.name, description: e.description, excerpt: clip((e.sections.Notes || "").trim(), EXCERPT_CHARS) };
69
+ } catch (e) { return null; }
70
+ }
71
+
72
+ const WALKER_SYSTEM = [
73
+ "You are the recall walker for an assistant's long-term memory.",
74
+ "You are given the user's message, recent conversation, and candidate memory nodes (some matched by keyword, some pulled in by graph links).",
75
+ "Decide which nodes are GENUINELY relevant to what the user is doing now.",
76
+ "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.",
77
+ "For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
78
+ 'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
79
+ ].join("\n");
80
+
81
+ async function walk(userText, contextText, candidates) {
82
+ if (!WALKER_ENABLED || candidates.length === 0) return null;
83
+ const lines = candidates.map((c) => {
84
+ const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
85
+ return `- ${c.id}: ${c.name}${tag}${c.description ? `\n ${c.description}` : ""}${c.excerpt ? `\n excerpt: ${c.excerpt.replace(/\n/g, " ")}` : ""}`;
86
+ });
87
+ const prompt = [
88
+ "User message:",
89
+ "<<<", clip(String(userText || ""), 1500), ">>>",
90
+ ...(contextText ? ["", "Recent conversation:", "<<<", clip(String(contextText), CONTEXT_CLIP), ">>>"] : []),
91
+ "",
92
+ "Candidate memory nodes:",
93
+ ...lines,
94
+ "",
95
+ 'Reply ONLY with the JSON array of kept nodes and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
96
+ ].join("\n");
97
+ try {
98
+ const { text } = await spawnSubagent(prompt, {
99
+ model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS,
100
+ });
101
+ const match = String(text || "").match(/\[[\s\S]*\]/);
102
+ if (!match) return null;
103
+ const arr = JSON.parse(match[0]);
104
+ if (!Array.isArray(arr)) return null;
105
+ const known = new Set(candidates.map((c) => c.id));
106
+ const out = new Map();
107
+ for (const item of arr) {
108
+ if (item && typeof item.id === "string" && known.has(item.id)) {
109
+ out.set(item.id, String(item.why || "").slice(0, 120));
110
+ }
111
+ }
112
+ return out;
113
+ } catch (e) {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ async function run(ctx) {
119
+ const { userText, contextText, fullContext, packLimit, budget, helpers } = ctx;
120
+ const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
121
+ const started = Date.now();
122
+
123
+ maybeSync(packsLib, entitiesLib);
124
+
125
+ // 1+2: seed via FTS (user words + context), same as classic.
126
+ let packSeeds = [];
127
+ let entSeeds = [];
128
+ try {
129
+ packSeeds = mergeMatches(
130
+ packsLib.matchPacks(userText, { limit: packLimit }),
131
+ fullContext ? packsLib.matchPacks(fullContext, { limit: packLimit }) : [],
132
+ (m) => m.dir,
133
+ );
134
+ } catch (e) {}
135
+ try {
136
+ entSeeds = mergeMatches(
137
+ entitiesLib.matchEntities(userText, { limit: 4 }),
138
+ fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
139
+ (m) => m.slug,
140
+ );
141
+ } catch (e) {}
142
+
143
+ const seedCount = packSeeds.length + entSeeds.length;
144
+
145
+ // 1: pre-gate.
146
+ if (!needsRecall(userText, seedCount)) {
147
+ metrics.logTurn({ engine: "discoverer", query: userText, gated: true, latencyMs: Date.now() - started });
148
+ return { packBlock: "", entityBlock: "", packMatches: [], entityMatches: [] };
149
+ }
150
+
151
+ // 3: spreading activation from seeds across the graph.
152
+ const seedNodes = [
153
+ ...packSeeds.map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
154
+ ...entSeeds.map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
155
+ ];
156
+ const seedIds = new Set(seedNodes.map((s) => s.id));
157
+ let activated = new Map();
158
+ try { activated = graph.expand(seedNodes, {}); } catch (e) {}
159
+
160
+ // 4: assemble candidates (seeds + activated), build excerpts, run the walker.
161
+ const candIds = new Set(seedIds);
162
+ for (const id of activated.keys()) candIds.add(id);
163
+ const candidates = [];
164
+ for (const id of candIds) {
165
+ const base = excerptFor(id, packsLib, entitiesLib);
166
+ if (!base) continue;
167
+ const act = activated.get(id);
168
+ candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
169
+ }
170
+
171
+ const whyById = (await walk(userText, contextText || fullContext, candidates)) || null;
172
+
173
+ // Decide the kept set. Walker result wins; on fail-open keep the user-origin
174
+ // seeds only (classic baseline) and drop graph-expanded/context guesses.
175
+ let keptIds;
176
+ if (whyById) {
177
+ keptIds = new Set(whyById.keys());
178
+ } else {
179
+ keptIds = new Set(
180
+ [...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
181
+ ...entSeeds.filter((m) => m.origin === "user").map((m) => `entity:${m.slug}`)],
182
+ );
183
+ }
184
+
185
+ const packMatches = [];
186
+ for (const m of packSeeds) if (keptIds.has(`pack:${m.dir}`)) packMatches.push(m);
187
+ for (const id of keptIds) {
188
+ if (id.startsWith("pack:") && !packMatches.some((m) => m.dir === id.slice(5))) {
189
+ const dir = id.slice(5);
190
+ const p = packsLib.readPack(dir);
191
+ if (p) packMatches.push({ dir, name: p.name, origin: "graph" });
192
+ }
193
+ }
194
+ const entityMatches = [];
195
+ for (const m of entSeeds) if (keptIds.has(`entity:${m.slug}`)) entityMatches.push(m);
196
+ for (const id of keptIds) {
197
+ if (id.startsWith("entity:") && !entityMatches.some((m) => m.slug === id.slice(7))) {
198
+ const slug = id.slice(7);
199
+ const e = entitiesLib.readEntity(slug);
200
+ if (e) entityMatches.push({ slug, name: e.name, origin: "graph" });
201
+ }
202
+ }
203
+
204
+ const finalPacks = packMatches.slice(0, packLimit);
205
+ const finalEnts = entityMatches.slice(0, 4);
206
+
207
+ // 5: render via shared builders, then weave in the why-bullets.
208
+ let packBlock = buildPackBlock(finalPacks, budget);
209
+ const entityBlock = buildEntityBlock(finalEnts, budget);
210
+ if (packBlock && whyById) {
211
+ const bullets = finalPacks
212
+ .map((m) => ({ m, why: whyById.get(`pack:${m.dir}`) }))
213
+ .filter((x) => x.why)
214
+ .map((x) => `- ${x.m.name || x.m.dir}: ${x.why}`);
215
+ if (bullets.length) packBlock = `\n\n### Why these surfaced (discoverer)\n${bullets.join("\n")}${packBlock}`;
216
+ }
217
+
218
+ metrics.logTurn({
219
+ engine: "discoverer",
220
+ query: userText,
221
+ seeds: seedNodes,
222
+ activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
223
+ kept: [...keptIds].map((id) => ({ id, why: whyById ? whyById.get(id) : "" })),
224
+ gated: false,
225
+ latencyMs: Date.now() - started,
226
+ });
227
+
228
+ return { packBlock, entityBlock, packMatches: finalPacks, entityMatches: finalEnts };
229
+ }
230
+
231
+ module.exports = { name: "discoverer", run, needsRecall, walk };