@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 +3 -0
- package/bin/cli.js +9 -0
- package/bin/recall.js +64 -0
- package/core/actions.js +8 -0
- package/core/dream.js +25 -2
- package/core/handlers.js +27 -1
- package/core/pack-review.js +1 -1
- package/core/recall/classic.js +49 -0
- package/core/recall/discoverer.js +231 -0
- package/core/recall/graph.js +366 -0
- package/core/recall/index.js +33 -0
- package/core/recall/metrics.js +109 -0
- package/core/runner.js +53 -9
- package/core/state.js +1 -1
- package/core/system-prompt.js +59 -34
- package/package.json +6 -3
- package/test-recall-discoverer.js +83 -0
- package/test-recall-engine.js +54 -0
- package/test-recall-graph.js +94 -0
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
|
-
|
|
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 }) => {
|
package/core/pack-review.js
CHANGED
|
@@ -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 };
|