@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/core/system-prompt.js
CHANGED
|
@@ -304,6 +304,17 @@ function packMatchLimit() {
|
|
|
304
304
|
return intSetting("PACK_MATCH_LIMIT", packProgressive() ? 6 : 3);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
// Pull model: when on, a matched pack/entity is injected as a *headline*
|
|
308
|
+
// (description + Stance for packs, description for entities) plus a pointer.
|
|
309
|
+
// The heavier sections (State/Procedure/Journal, entity Notes/Log) are NOT
|
|
310
|
+
// pushed — the agent decides which packs are worth a real read and pulls them
|
|
311
|
+
// on demand via `open-claudia pack show <dir>` / `entity show <slug>`. This is
|
|
312
|
+
// what makes the "📖 Recalled my notes on …" banner mean "what I actually
|
|
313
|
+
// read" (fired at read time in the runner) instead of "what got pushed at me".
|
|
314
|
+
function recallHeadline() {
|
|
315
|
+
return String(config.RECALL_HEADLINE ?? process.env.RECALL_HEADLINE ?? "on").toLowerCase() === "on";
|
|
316
|
+
}
|
|
317
|
+
|
|
307
318
|
function intSetting(key, fallback) {
|
|
308
319
|
const raw = config[key] ?? process.env[key];
|
|
309
320
|
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
@@ -346,6 +357,7 @@ function consumeLastInjected() {
|
|
|
346
357
|
}
|
|
347
358
|
|
|
348
359
|
function formatPackForContext(pack, packsLib, opts = {}) {
|
|
360
|
+
const headline = !!opts.headline;
|
|
349
361
|
const progressive = !!opts.progressive;
|
|
350
362
|
const clip = (s, n) => (s.length > n ? s.slice(0, n) + "\n…[truncated — read the full pack file]" : s);
|
|
351
363
|
const parts = [`### Pack: ${pack.name} (${pack.dir})`];
|
|
@@ -362,12 +374,22 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
362
374
|
// Progressive mode injects only the live sections (Stance = how to think,
|
|
363
375
|
// State = where we are) plus a pointer; full mode also inlines Procedure
|
|
364
376
|
// and the recent Journal.
|
|
365
|
-
|
|
377
|
+
// Headline = description + Stance only (Stance carries hard rules/prefs that
|
|
378
|
+
// must always apply when the topic comes up). Everything else is pulled on
|
|
379
|
+
// demand. Progressive = Stance + State. Full = Stance + Procedure + State.
|
|
380
|
+
const sections = headline
|
|
381
|
+
? ["Stance"]
|
|
382
|
+
: progressive
|
|
383
|
+
? ["Stance", "State"]
|
|
384
|
+
: ["Stance", "Procedure", "State"];
|
|
366
385
|
for (const section of sections) {
|
|
367
386
|
const body = (pack.sections[section] || "").trim();
|
|
368
387
|
if (body) parts.push(`#### ${section}\n${body}`);
|
|
369
388
|
}
|
|
370
|
-
if (
|
|
389
|
+
if (headline) {
|
|
390
|
+
const hidden = ["State", "Procedure", "Journal"].filter((s) => (pack.sections[s] || "").trim());
|
|
391
|
+
if (hidden.length) parts.push(`#### More\n${hidden.join(" + ")} not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
|
|
392
|
+
} else if (progressive) {
|
|
371
393
|
const hasMore = (pack.sections.Procedure || "").trim() || (pack.sections.Journal || "").trim();
|
|
372
394
|
if (hasMore) parts.push(`#### More\nProcedure + Journal not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
|
|
373
395
|
} else {
|
|
@@ -386,6 +408,7 @@ function buildPackBlock(matches, budget) {
|
|
|
386
408
|
const channelId = currentChannelId();
|
|
387
409
|
const sess = state.lastSessionId || "new";
|
|
388
410
|
const progressive = packProgressive();
|
|
411
|
+
const headline = recallHeadline();
|
|
389
412
|
const blocks = [];
|
|
390
413
|
const used = [];
|
|
391
414
|
for (const m of matches) {
|
|
@@ -400,7 +423,7 @@ function buildPackBlock(matches, budget) {
|
|
|
400
423
|
// as the task tree), so the pack survives compaction without paying
|
|
401
424
|
// to re-stamp an anchor on every intervening turn.
|
|
402
425
|
if (packsInjectedFor.get(key) === stamp) continue;
|
|
403
|
-
const block = formatPackForContext(pack, packsLib, { progressive });
|
|
426
|
+
const block = formatPackForContext(pack, packsLib, { progressive, headline });
|
|
404
427
|
if (!tryUseRecallBudget(budget, block)) continue;
|
|
405
428
|
packsInjectedFor.set(key, stamp);
|
|
406
429
|
lastInjected.packs.push(pack.name || m.dir);
|
|
@@ -408,7 +431,10 @@ function buildPackBlock(matches, budget) {
|
|
|
408
431
|
}
|
|
409
432
|
if (used.length > 0) setImmediate(() => { try { packsLib.touchUsed(used); } catch (e) {} });
|
|
410
433
|
if (blocks.length === 0) return "";
|
|
411
|
-
|
|
434
|
+
const intro = headline
|
|
435
|
+
? `Long-term topic context auto-matched to this message — these are *headlines* (description + Stance only), not full packs. Decide which are actually relevant and pull the full State/Procedure/Journal on demand with \`open-claudia pack show <dir>\`. If the user asked for a skill by name, \`pack show\` the matching pack to load its Procedure. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.`
|
|
436
|
+
: `Long-term topic context auto-matched to this message. If the user asked for a skill by name, treat the matching pack's Procedure section as that Open Claudia skill. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.`;
|
|
437
|
+
return `\n\n## Active Open Claudia skills / context packs\n${intro}\n\n${blocks.join("\n\n---\n\n")}`;
|
|
412
438
|
} catch (e) {
|
|
413
439
|
return "";
|
|
414
440
|
}
|
|
@@ -420,14 +446,23 @@ function buildPackBlock(matches, budget) {
|
|
|
420
446
|
const entitiesInjectedFor = new Map(); // `${adapterId}:${channelId}:${slug}` -> `${sessionId}:${updated}`
|
|
421
447
|
const ENTITY_INJECT_MAX_CHARS = 1200;
|
|
422
448
|
|
|
423
|
-
function formatEntityForContext(ent) {
|
|
449
|
+
function formatEntityForContext(ent, opts = {}) {
|
|
450
|
+
const headline = !!opts.headline;
|
|
451
|
+
const slug = opts.slug || ent.slug;
|
|
424
452
|
const clip = (s, n) => (s.length > n ? s.slice(0, n) + "\n…[truncated — read the full entity file]" : s);
|
|
425
453
|
const head = `### ${ent.name} (${ent.type}${ent.aliases.length ? `, aka ${ent.aliases.join(", ")}` : ""})`;
|
|
426
454
|
const parts = [head];
|
|
427
455
|
if (ent.description) parts.push(ent.description);
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
456
|
+
if (headline) {
|
|
457
|
+
const hidden = [];
|
|
458
|
+
if ((ent.sections.Notes || "").trim()) hidden.push("Notes");
|
|
459
|
+
if ((ent.sections.Log || "").trim()) hidden.push("Log");
|
|
460
|
+
if (hidden.length) parts.push(`${hidden.join(" + ")} not shown — run \`open-claudia entity show ${slug}\` to read them.`);
|
|
461
|
+
} else {
|
|
462
|
+
if (ent.sections.Notes) parts.push(ent.sections.Notes);
|
|
463
|
+
const log = ent.sections.Log.split("\n").filter(Boolean).slice(-4).join("\n");
|
|
464
|
+
if (log) parts.push(`Recent:\n${log}`);
|
|
465
|
+
}
|
|
431
466
|
return clip(parts.join("\n\n"), ENTITY_INJECT_MAX_CHARS);
|
|
432
467
|
}
|
|
433
468
|
|
|
@@ -448,7 +483,7 @@ function buildEntityBlock(matches, budget) {
|
|
|
448
483
|
const key = `${adapter?.id || "?"}:${channelId || "?"}:${m.slug}`;
|
|
449
484
|
const stamp = `${sess}:${ent.updated}`;
|
|
450
485
|
if (entitiesInjectedFor.get(key) === stamp) continue;
|
|
451
|
-
const block = formatEntityForContext(ent);
|
|
486
|
+
const block = formatEntityForContext(ent, { headline: recallHeadline(), slug: m.slug });
|
|
452
487
|
if (!tryUseRecallBudget(budget, block)) continue;
|
|
453
488
|
entitiesInjectedFor.set(key, stamp);
|
|
454
489
|
lastInjected.entities.push(ent.name || m.slug);
|
|
@@ -456,7 +491,10 @@ function buildEntityBlock(matches, budget) {
|
|
|
456
491
|
}
|
|
457
492
|
if (seen.length > 0) setImmediate(() => { try { entitiesLib.touchSeen(seen); } catch (e) {} });
|
|
458
493
|
if (blocks.length === 0) return "";
|
|
459
|
-
|
|
494
|
+
const intro = recallHeadline()
|
|
495
|
+
? `Memory notes on people/places/projects auto-matched to this message — headlines only (description + a pointer). Pull a full note's Notes/Log on demand with \`open-claudia entity show <slug>\`. Source files live under ${entitiesLib.ENTITIES_DIR}/<slug>.md — read or edit them directly to correct or deepen.`
|
|
496
|
+
: `Memory notes on people/places/projects auto-matched to this message. Source files live under ${entitiesLib.ENTITIES_DIR}/<slug>.md — read or edit them directly to correct or deepen.`;
|
|
497
|
+
return `\n\n## Known entities\n${intro}\n\n${blocks.join("\n\n---\n\n")}`;
|
|
460
498
|
} catch (e) {
|
|
461
499
|
return "";
|
|
462
500
|
}
|
|
@@ -585,32 +623,19 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
585
623
|
const fullContext = [contextText, historyText].filter(Boolean).join("\n\n");
|
|
586
624
|
const packsLib = require("./packs");
|
|
587
625
|
const entitiesLib = require("./entities");
|
|
588
|
-
let packMatches = [];
|
|
589
|
-
let entityMatches = [];
|
|
590
626
|
const packLimit = packMatchLimit();
|
|
591
|
-
try {
|
|
592
|
-
packMatches = mergeMatches(
|
|
593
|
-
packsLib.matchPacks(userText, { limit: packLimit }),
|
|
594
|
-
fullContext ? packsLib.matchPacks(fullContext, { limit: packLimit }) : [],
|
|
595
|
-
(m) => m.dir,
|
|
596
|
-
);
|
|
597
|
-
} catch (e) {}
|
|
598
|
-
try {
|
|
599
|
-
entityMatches = mergeMatches(
|
|
600
|
-
entitiesLib.matchEntities(userText, { limit: 4 }),
|
|
601
|
-
fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
|
|
602
|
-
(m) => m.slug,
|
|
603
|
-
);
|
|
604
|
-
} catch (e) {}
|
|
605
|
-
const candPacks = packMatches;
|
|
606
|
-
const candEntities = entityMatches;
|
|
607
|
-
({ packMatches, entityMatches } = await filterMatches(userText, fullContext, packMatches, entityMatches));
|
|
608
|
-
packMatches = packMatches.slice(0, packLimit);
|
|
609
|
-
entityMatches = entityMatches.slice(0, 4);
|
|
610
|
-
logRecall(userText, candPacks, candEntities, packMatches, entityMatches);
|
|
611
627
|
const budget = memoryRecallBudget();
|
|
612
|
-
|
|
613
|
-
|
|
628
|
+
let settings = {};
|
|
629
|
+
try { settings = currentState().settings || {}; } catch (e) {}
|
|
630
|
+
const recall = require("./recall");
|
|
631
|
+
const engine = recall.getEngine(recall.activeEngineName(settings));
|
|
632
|
+
const helpers = {
|
|
633
|
+
packsLib, entitiesLib, mergeMatches, filterMatches, logRecall,
|
|
634
|
+
buildPackBlock, buildEntityBlock,
|
|
635
|
+
};
|
|
636
|
+
const { packBlock, entityBlock } = await engine.run({
|
|
637
|
+
userText, contextText, fullContext, packLimit, budget, helpers,
|
|
638
|
+
});
|
|
614
639
|
const budgetNote = budget.omitted > 0
|
|
615
640
|
? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
|
|
616
641
|
: "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.34",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
".env.example",
|
|
31
31
|
"README.md",
|
|
32
32
|
"CHANGELOG.md",
|
|
33
|
-
"test-usage-accounting.js"
|
|
33
|
+
"test-usage-accounting.js",
|
|
34
|
+
"test-recall-engine.js",
|
|
35
|
+
"test-recall-graph.js",
|
|
36
|
+
"test-recall-discoverer.js"
|
|
34
37
|
],
|
|
35
38
|
"keywords": [
|
|
36
39
|
"claude",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Discoverer engine: pre-gate, seed → (graph) → fail-open injection. The LLM
|
|
2
|
+
// walker is disabled here (RECALL_DISCOVERER_WALKER=off) so the engine takes
|
|
3
|
+
// its deterministic fail-open path: keep user-origin seeds, drop guesses.
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-disc-"));
|
|
10
|
+
process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
|
|
11
|
+
process.env.RECALL_DISCOVERER_WALKER = "off";
|
|
12
|
+
process.env.RECALL_METRICS = "off";
|
|
13
|
+
|
|
14
|
+
const disc = require("./core/recall/discoverer");
|
|
15
|
+
|
|
16
|
+
// --- pre-gate ---
|
|
17
|
+
assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
|
|
18
|
+
assert.strictEqual(disc.needsRecall("thanks", 0), false, "pleasantry → no recall");
|
|
19
|
+
assert.strictEqual(disc.needsRecall("ok", 0), false, "terse ack → no recall");
|
|
20
|
+
assert.strictEqual(disc.needsRecall("yo", 0), false, "two words, no seeds → no recall");
|
|
21
|
+
assert.strictEqual(disc.needsRecall("how do I deploy the mobile app", 0), true, "substantive → recall");
|
|
22
|
+
assert.strictEqual(disc.needsRecall("k", 5), true, "seeds present always recall");
|
|
23
|
+
|
|
24
|
+
// --- shared stub corpus + helpers ---
|
|
25
|
+
const packs = {
|
|
26
|
+
"kazee-mobile": { dir: "kazee-mobile", name: "Kazee Mobile", description: "the app",
|
|
27
|
+
parent: "kazee", tags: [], sections: { Stance: "lime", Procedure: "", State: "", Journal: "" } },
|
|
28
|
+
"kazee-theme": { dir: "kazee-theme", name: "Kazee Theme", description: "palette",
|
|
29
|
+
parent: null, tags: ["shared"], sections: { Stance: "lime palette", Procedure: "", State: "", Journal: "" } },
|
|
30
|
+
};
|
|
31
|
+
const packsLib = {
|
|
32
|
+
matchPacks: (text) => /mobile/.test(text) ? [{ dir: "kazee-mobile", name: "Kazee Mobile", score: 4 }] : [],
|
|
33
|
+
readPack: (dir) => packs[dir] || null,
|
|
34
|
+
listPacks: () => Object.values(packs),
|
|
35
|
+
PACKS_DIR: tmp,
|
|
36
|
+
};
|
|
37
|
+
const entitiesLib = {
|
|
38
|
+
matchEntities: () => [],
|
|
39
|
+
readEntity: () => null,
|
|
40
|
+
listEntities: () => [],
|
|
41
|
+
ENTITIES_DIR: tmp,
|
|
42
|
+
};
|
|
43
|
+
const mergeMatches = (primary, secondary, keyOf) => {
|
|
44
|
+
const out = primary.map((m) => ({ ...m, origin: "user" }));
|
|
45
|
+
const seen = new Set(primary.map(keyOf));
|
|
46
|
+
for (const m of secondary) if (!seen.has(keyOf(m))) out.push({ ...m, origin: "context" });
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
let builtPacks = null;
|
|
50
|
+
const buildPackBlock = (matches) => { builtPacks = matches; return matches.length ? "PACKBLOCK" : ""; };
|
|
51
|
+
const buildEntityBlock = (matches) => (matches.length ? "ENTITYBLOCK" : "");
|
|
52
|
+
const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock };
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
// gated turn: trivial text, no seeds → empty result, builders not called
|
|
56
|
+
builtPacks = null;
|
|
57
|
+
const gated = await disc.run({
|
|
58
|
+
userText: "ok", contextText: "", fullContext: "", packLimit: 6, budget: {}, helpers,
|
|
59
|
+
});
|
|
60
|
+
assert.strictEqual(gated.packBlock, "");
|
|
61
|
+
assert.strictEqual(gated.packMatches.length, 0);
|
|
62
|
+
assert.strictEqual(builtPacks, null, "gated turn never builds blocks");
|
|
63
|
+
|
|
64
|
+
// seeded turn: FTS hits kazee-mobile → fail-open keeps it, block rendered
|
|
65
|
+
const out = await disc.run({
|
|
66
|
+
userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
|
|
67
|
+
packLimit: 6, budget: {}, helpers,
|
|
68
|
+
});
|
|
69
|
+
assert.strictEqual(out.packBlock, "PACKBLOCK");
|
|
70
|
+
assert.strictEqual(out.packMatches.length, 1);
|
|
71
|
+
assert.strictEqual(out.packMatches[0].dir, "kazee-mobile");
|
|
72
|
+
assert.ok(builtPacks && builtPacks.some((m) => m.dir === "kazee-mobile"), "seed reached the builder");
|
|
73
|
+
|
|
74
|
+
// resilient: a throwing matcher must not blow up the engine
|
|
75
|
+
const out2 = await disc.run({
|
|
76
|
+
userText: "deploy something substantive please", contextText: "", fullContext: "",
|
|
77
|
+
packLimit: 6, budget: {},
|
|
78
|
+
helpers: { ...helpers, packsLib: { ...packsLib, matchPacks: () => { throw new Error("boom"); } } },
|
|
79
|
+
});
|
|
80
|
+
assert.strictEqual(typeof out2.packBlock, "string");
|
|
81
|
+
|
|
82
|
+
console.log("recall discoverer OK");
|
|
83
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Recall engine selector + classic-engine smoke tests.
|
|
2
|
+
const assert = require("assert");
|
|
3
|
+
const recall = require("./core/recall");
|
|
4
|
+
|
|
5
|
+
// --- selector ---
|
|
6
|
+
assert.strictEqual(recall.activeEngineName({}), "classic", "default → classic");
|
|
7
|
+
assert.strictEqual(recall.activeEngineName({ recallEngine: "classic" }), "classic");
|
|
8
|
+
assert.strictEqual(recall.activeEngineName({ recallEngine: "nope" }), "classic", "unknown → classic");
|
|
9
|
+
assert.strictEqual(recall.activeEngineName({ recallEngine: "CLASSIC" }), "classic", "case-insensitive");
|
|
10
|
+
assert.ok(recall.listEngines().includes("classic"));
|
|
11
|
+
assert.strictEqual(typeof recall.getEngine("classic").run, "function");
|
|
12
|
+
assert.strictEqual(recall.getEngine("bogus").name, "classic", "getEngine falls back to classic");
|
|
13
|
+
|
|
14
|
+
// env override when no setting
|
|
15
|
+
const prev = process.env.RECALL_ENGINE;
|
|
16
|
+
process.env.RECALL_ENGINE = "classic";
|
|
17
|
+
assert.strictEqual(recall.activeEngineName(null), "classic");
|
|
18
|
+
if (prev === undefined) delete process.env.RECALL_ENGINE; else process.env.RECALL_ENGINE = prev;
|
|
19
|
+
|
|
20
|
+
// --- classic engine orchestration: calls helpers and returns their blocks ---
|
|
21
|
+
(async () => {
|
|
22
|
+
const calls = [];
|
|
23
|
+
const helpers = {
|
|
24
|
+
packsLib: { matchPacks: () => [{ dir: "p1", name: "P1" }] },
|
|
25
|
+
entitiesLib: { matchEntities: () => [{ slug: "e1", name: "E1" }] },
|
|
26
|
+
mergeMatches: (a, b) => { calls.push("merge"); return a.map((m) => ({ ...m, origin: "user" })); },
|
|
27
|
+
filterMatches: async (u, c, pm, em) => { calls.push("filter"); return { packMatches: pm, entityMatches: em }; },
|
|
28
|
+
logRecall: () => { calls.push("log"); },
|
|
29
|
+
buildPackBlock: (m) => { calls.push("buildPack"); return m.length ? "PACKBLOCK" : ""; },
|
|
30
|
+
buildEntityBlock: (m) => { calls.push("buildEntity"); return m.length ? "ENTITYBLOCK" : ""; },
|
|
31
|
+
};
|
|
32
|
+
const out = await recall.getEngine("classic").run({
|
|
33
|
+
userText: "hello", contextText: "", fullContext: "", packLimit: 6, budget: {}, helpers,
|
|
34
|
+
});
|
|
35
|
+
assert.strictEqual(out.packBlock, "PACKBLOCK");
|
|
36
|
+
assert.strictEqual(out.entityBlock, "ENTITYBLOCK");
|
|
37
|
+
assert.strictEqual(out.packMatches.length, 1);
|
|
38
|
+
assert.strictEqual(out.entityMatches.length, 1);
|
|
39
|
+
assert.ok(calls.includes("filter") && calls.includes("log") && calls.includes("buildPack"));
|
|
40
|
+
|
|
41
|
+
// resilient: a throwing matcher must not blow up the engine
|
|
42
|
+
const out2 = await recall.getEngine("classic").run({
|
|
43
|
+
userText: "x", contextText: "", fullContext: "",
|
|
44
|
+
packLimit: 6, budget: {},
|
|
45
|
+
helpers: {
|
|
46
|
+
...helpers,
|
|
47
|
+
packsLib: { matchPacks: () => { throw new Error("boom"); } },
|
|
48
|
+
entitiesLib: { matchEntities: () => { throw new Error("boom"); } },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
assert.strictEqual(typeof out2.packBlock, "string");
|
|
52
|
+
|
|
53
|
+
console.log("recall engine OK");
|
|
54
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Recall graph: edges, Hebbian reinforce, decay, spreading activation,
|
|
2
|
+
// structural sync, orphan prune. Uses a throwaway sqlite db under a temp dir.
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-graph-"));
|
|
9
|
+
process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
|
|
10
|
+
|
|
11
|
+
const graph = require("./core/recall/graph");
|
|
12
|
+
|
|
13
|
+
if (!graph.available()) { console.log("recall graph OK (skipped — no node:sqlite)"); process.exit(0); }
|
|
14
|
+
|
|
15
|
+
// --- edges + neighbours ---
|
|
16
|
+
graph.addEdge("pack:a", "pack:b", "related");
|
|
17
|
+
graph.addEdge("pack:b", "pack:c", "related");
|
|
18
|
+
graph.addEdge("pack:a", "pack:theme", "governed-by");
|
|
19
|
+
const nb = graph.neighbors("pack:a").map((x) => x.node).sort();
|
|
20
|
+
assert.deepStrictEqual(nb, ["pack:b", "pack:theme"], "undirected neighbours of a");
|
|
21
|
+
assert.strictEqual(graph.allEdges().length, 3);
|
|
22
|
+
|
|
23
|
+
// addEdge keeps the max weight (structural re-derive never lowers reinforcement)
|
|
24
|
+
graph.addEdge("pack:a", "pack:b", "related", { weight: 99 });
|
|
25
|
+
let ab = graph.allEdges().find((e) => e.src === "pack:a" && e.dst === "pack:b");
|
|
26
|
+
assert.strictEqual(ab.weight, 99, "weight raised to max");
|
|
27
|
+
graph.addEdge("pack:a", "pack:b", "related", { weight: 1 });
|
|
28
|
+
ab = graph.allEdges().find((e) => e.src === "pack:a" && e.dst === "pack:b");
|
|
29
|
+
assert.strictEqual(ab.weight, 99, "weight not lowered by smaller structural weight");
|
|
30
|
+
|
|
31
|
+
// --- Hebbian reinforce (both directions) ---
|
|
32
|
+
graph.reinforce("pack:x", "pack:y", 2);
|
|
33
|
+
const xy = graph.allEdges().find((e) => e.src === "pack:x" && e.dst === "pack:y");
|
|
34
|
+
const yx = graph.allEdges().find((e) => e.src === "pack:y" && e.dst === "pack:x");
|
|
35
|
+
assert.ok(xy && yx, "reinforce creates both directions");
|
|
36
|
+
assert.ok(xy.last_reinforced, "reinforce stamps last_reinforced");
|
|
37
|
+
graph.reinforce("pack:x", "pack:y", 2);
|
|
38
|
+
const xy2 = graph.allEdges().find((e) => e.src === "pack:x" && e.dst === "pack:y");
|
|
39
|
+
assert.ok(xy2.weight > xy.weight, "repeat reinforce bumps weight");
|
|
40
|
+
|
|
41
|
+
graph.reinforceSet(["pack:a", "pack:b", "pack:c"]);
|
|
42
|
+
assert.ok(graph.neighbors("pack:a").some((n) => n.node === "pack:c"), "reinforceSet links all pairs");
|
|
43
|
+
|
|
44
|
+
// --- spreading activation ---
|
|
45
|
+
// strong chain a→b→c; seed a, expect b (1 hop) and possibly c (2 hops).
|
|
46
|
+
const act = graph.expand([{ id: "pack:a", score: 4 }], { hops: 2, threshold: 0.1 });
|
|
47
|
+
assert.ok(act.has("pack:b"), "1-hop neighbour activates");
|
|
48
|
+
assert.ok(!act.has("pack:a"), "seed excluded from activation result");
|
|
49
|
+
for (const [, v] of act) assert.ok(v.activation > 0 && v.hop >= 1, "activation has metadata");
|
|
50
|
+
|
|
51
|
+
// seeds with no edges produce nothing
|
|
52
|
+
assert.strictEqual(graph.expand([{ id: "pack:orphanseed", score: 5 }]).size, 0);
|
|
53
|
+
|
|
54
|
+
// --- decay (never below structural floor) ---
|
|
55
|
+
graph.addEdge("pack:d", "pack:e", "parent"); // base weight 3, no last_reinforced → untouched
|
|
56
|
+
graph.reinforce("pack:d", "pack:f", 10);
|
|
57
|
+
// backdate the reinforced edge well past the half-life
|
|
58
|
+
const db = graph.openDb();
|
|
59
|
+
const old = new Date(Date.now() - 400 * 86400000).toISOString();
|
|
60
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(old, "pack:d", "pack:f");
|
|
61
|
+
graph.decay({ halfLifeDays: 30 });
|
|
62
|
+
const df = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f");
|
|
63
|
+
assert.ok(df.weight < 11 && df.weight >= 1, "reinforced edge decayed toward floor");
|
|
64
|
+
const de = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:e");
|
|
65
|
+
assert.strictEqual(de.weight, 3, "structural edge with no reinforcement untouched by decay");
|
|
66
|
+
|
|
67
|
+
// --- structural sync from a stubbed corpus ---
|
|
68
|
+
const packsStub = {
|
|
69
|
+
listPacks: () => [
|
|
70
|
+
{ dir: "kazee-mobile", name: "Kazee Mobile", tags: [], parent: "kazee",
|
|
71
|
+
sections: { Stance: "uses [[kazee-theme]]", Procedure: "", State: "", Journal: "" } },
|
|
72
|
+
{ dir: "kazee", name: "Kazee", tags: [], parent: null,
|
|
73
|
+
sections: { Stance: "", Procedure: "", State: "", Journal: "" } },
|
|
74
|
+
{ dir: "kazee-theme", name: "Kazee Theme", tags: ["shared"], parent: null,
|
|
75
|
+
sections: { Stance: "lime palette", Procedure: "", State: "", Journal: "" } },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
const entStub = { listEntities: () => [] };
|
|
79
|
+
const r = graph.syncFromCorpus(packsStub, entStub);
|
|
80
|
+
assert.ok(r.edges >= 2, "sync derived parent + link edges");
|
|
81
|
+
const edges = graph.allEdges();
|
|
82
|
+
assert.ok(edges.some((e) => e.src === "pack:kazee-mobile" && e.dst === "pack:kazee" && e.type === "parent"), "parent edge from frontmatter");
|
|
83
|
+
assert.ok(edges.some((e) => e.src === "pack:kazee-mobile" && e.dst === "pack:kazee-theme" && e.type === "governed-by"), "shared-concern link → governed-by");
|
|
84
|
+
|
|
85
|
+
// --- orphan prune ---
|
|
86
|
+
graph.addEdge("pack:ghost", "pack:kazee", "related");
|
|
87
|
+
const pruned = graph.pruneOrphans(packsStub, entStub);
|
|
88
|
+
assert.ok(pruned >= 1, "orphan edge pruned");
|
|
89
|
+
assert.ok(!graph.allEdges().some((e) => e.src === "pack:ghost"), "ghost edge gone");
|
|
90
|
+
|
|
91
|
+
// parseLinks
|
|
92
|
+
assert.deepStrictEqual(graph.parseLinks("see [[Foo-Bar]] and [[baz]]"), ["foo-bar", "baz"]);
|
|
93
|
+
|
|
94
|
+
console.log("recall graph OK");
|