@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.
@@ -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
- const sections = progressive ? ["Stance", "State"] : ["Stance", "Procedure", "State"];
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 (progressive) {
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
- return `\n\n## Active Open Claudia skills / context packs\nLong-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.\n\n${blocks.join("\n\n---\n\n")}`;
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 (ent.sections.Notes) parts.push(ent.sections.Notes);
429
- const log = ent.sections.Log.split("\n").filter(Boolean).slice(-4).join("\n");
430
- if (log) parts.push(`Recent:\n${log}`);
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
- return `\n\n## Known entities\nMemory 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.\n\n${blocks.join("\n\n---\n\n")}`;
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
- const packBlock = buildPackBlock(packMatches, budget);
613
- const entityBlock = buildEntityBlock(entityMatches, budget);
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.32",
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");