@inetafrica/open-claudia 2.10.0 → 2.12.0

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.
@@ -607,6 +607,57 @@ function buildEntityBlock(matches, budget) {
607
607
  }
608
608
  }
609
609
 
610
+ // Speaker persona pack: unlike the entity router (which matches on message
611
+ // content), this is the DETERMINISTIC persona of whoever is talking right now.
612
+ // It shapes voice/scope every turn, so it rides the uncached tail (R13) and is
613
+ // injected once per (channel, session, entity-version) to spare tail tokens —
614
+ // a compaction mints a new session id and re-injects, same as packs/entities.
615
+ const speakerPersonaInjectedFor = new Map(); // `${adapterId}:${channelId}` -> `${sessionId}:${slug}:${updated}`
616
+ const SPEAKER_PERSONA_MAX_CHARS = 1400;
617
+
618
+ function buildSpeakerPersonaBlock() {
619
+ try {
620
+ const adapter = currentAdapter();
621
+ const channelId = currentChannelId();
622
+ if (!adapter || !channelId) return "";
623
+ const speaker = people.findByHandle(adapter.type, channelId);
624
+ if (!speaker) return "";
625
+ const slug = people.resolveEntitySlug(speaker);
626
+ if (!slug) return "";
627
+ const entitiesLib = require("./entities");
628
+ const ent = entitiesLib.readEntity(slug);
629
+ if (!ent) return "";
630
+ const s = ent.sections || {};
631
+ // Style/Knows/Mandate are what shape a turn; Role frames who they are.
632
+ // Mandate is the speaker's OWN scope (no leak risk — it's about the person
633
+ // you're talking to), and only meaningful for non-owners.
634
+ const rows = [];
635
+ if ((s.Role || "").trim()) rows.push(`Role: ${s.Role.trim()}`);
636
+ if ((s.Style || "").trim()) rows.push(`How to talk to them: ${s.Style.trim()}`);
637
+ if ((s.Knows || "").trim()) rows.push(`Assume they know: ${s.Knows.trim()}`);
638
+ if (!speaker.isOwner && (s.Mandate || "").trim()) {
639
+ rows.push(`Mandate — what you may do/discuss with them: ${s.Mandate.trim()}`);
640
+ }
641
+ if (rows.length === 0) return "";
642
+ const state = currentState();
643
+ const sess = state.lastSessionId || "new";
644
+ const key = `${adapter.id || "?"}:${channelId}`;
645
+ const stamp = `${sess}:${slug}:${ent.updated}`;
646
+ if (speakerPersonaInjectedFor.get(key) === stamp) return "";
647
+ speakerPersonaInjectedFor.set(key, stamp);
648
+ const rel = ent.relationship || (speaker.isOwner ? "owner" : "");
649
+ const clip = (str) => (str.length > SPEAKER_PERSONA_MAX_CHARS ? str.slice(0, SPEAKER_PERSONA_MAX_CHARS) + "\n…[truncated — `open-claudia entity show " + slug + "`]" : str);
650
+ return clip([
651
+ `\n\n## Who you're speaking with: ${speaker.name}${rel ? ` (${rel})` : ""}`,
652
+ `Their persona pack (source: ${entitiesLib.ENTITIES_DIR}/${slug}.md) — adapt your voice and scope to it. Edit that file to refine how you treat them.`,
653
+ "",
654
+ rows.join("\n"),
655
+ ].join("\n"));
656
+ } catch (e) {
657
+ return "";
658
+ }
659
+ }
660
+
610
661
  // The composed prompt can carry text the user didn't write this turn:
611
662
  // router.js wraps Telegram reply-quotes in "Replying to …:\n---\n…\n---",
612
663
  // and quoting a 📖 recall announcement echoes every entity named in it.
@@ -791,10 +842,10 @@ async function promptWithDynamicContext(prompt, opts = {}) {
791
842
  ? `\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.`
792
843
  : "";
793
844
  const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
794
- return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${episodeBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
845
+ return `${transcriptPointer}${buildDynamicContextBlock()}${buildSpeakerPersonaBlock()}${packBlock}${entityBlock}${toolBlock}${episodeBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
795
846
  } catch (e) {
796
847
  return prompt;
797
848
  }
798
849
  }
799
850
 
800
- module.exports = { loadSoul, buildSystemPrompt, buildDynamicContextBlock, promptWithDynamicContext, consumeLastInjected };
851
+ module.exports = { loadSoul, buildSystemPrompt, buildDynamicContextBlock, buildSpeakerPersonaBlock, promptWithDynamicContext, consumeLastInjected };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
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-runner-watchdog-static.js && 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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.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-runner-watchdog-static.js && 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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-relationship-gate.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -50,7 +50,14 @@
50
50
  "test-tool-guard.js",
51
51
  "test-tooling-mode.js",
52
52
  "test-tool-manifest.js",
53
- "test-approval-async.js"
53
+ "test-approval-async.js",
54
+ "test-recall-evolution.js",
55
+ "test-unified-identity.js",
56
+ "test-queue-routing.js",
57
+ "test-persona-packs.js",
58
+ "test-speaker-persona.js",
59
+ "test-persona-pipeline.js",
60
+ "test-recall-relationship-gate.js"
54
61
  ],
55
62
  "keywords": [
56
63
  "claude",
@@ -0,0 +1,126 @@
1
+ // Persona packs: the entity note upgraded into a per-person memory that shapes
2
+ // how the bot talks to someone. These tests lock in the additive contract (R7):
3
+ // an ordinary entity serializes byte-identically to before the upgrade, while a
4
+ // person entity gains Role/Style/Knows/Mandate sections + a relationship scalar
5
+ // that round-trip cleanly. They also pin the FTS rule that Mandate (policy) is
6
+ // NOT indexed while the descriptive persona sections are.
7
+
8
+ const assert = require("assert");
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "persona-packs-"));
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+
16
+ const ent = require("./core/entities");
17
+
18
+ // ── normalizeRelationship: only the three known scalars survive ──
19
+ assert.strictEqual(ent.normalizeRelationship("owner"), "owner", "owner passes");
20
+ assert.strictEqual(ent.normalizeRelationship(" Trusted "), "trusted", "trimmed + lowercased");
21
+ assert.strictEqual(ent.normalizeRelationship("EXTERNAL"), "external", "case-folded");
22
+ assert.strictEqual(ent.normalizeRelationship("stranger"), "", "unknown → empty");
23
+ assert.strictEqual(ent.normalizeRelationship(""), "", "empty → empty");
24
+ assert.strictEqual(ent.normalizeRelationship(null), "", "null → empty");
25
+
26
+ // ── R7: an ordinary (non-persona) entity serializes byte-identically ──
27
+ // No relationship line, no persona sections — just the historical Notes/Log
28
+ // frontmatter block. This is the exact shape entities had before the upgrade.
29
+ ent.writeEntity({
30
+ slug: "ordinary-co",
31
+ name: "Ordinary Co",
32
+ type: "org",
33
+ aliases: ["OC", "OrdCo"],
34
+ description: "an ordinary org",
35
+ relationship: "",
36
+ created: "2026-01-01T00:00:00.000Z",
37
+ updated: "2026-01-02T00:00:00.000Z",
38
+ last_seen: "2026-01-03T00:00:00.000Z",
39
+ sections: { Notes: "They do things.", Log: "- [2026-01-01] met them", Role: "", Style: "", Knows: "", Mandate: "" },
40
+ });
41
+ const ordinaryRaw = fs.readFileSync(path.join(process.env.ENTITIES_DIR, "ordinary-co.md"), "utf-8");
42
+ const expectedOrdinary = [
43
+ "---",
44
+ "name: Ordinary Co",
45
+ "type: org",
46
+ "aliases: OC, OrdCo",
47
+ "description: an ordinary org",
48
+ "created: 2026-01-01T00:00:00.000Z",
49
+ "updated: 2026-01-02T00:00:00.000Z",
50
+ "last_seen: 2026-01-03T00:00:00.000Z",
51
+ "---",
52
+ "",
53
+ "## Notes",
54
+ "",
55
+ "They do things.",
56
+ "",
57
+ "## Log",
58
+ "",
59
+ "- [2026-01-01] met them",
60
+ "",
61
+ ].join("\n");
62
+ assert.strictEqual(ordinaryRaw, expectedOrdinary, "ordinary entity is byte-identical to the pre-upgrade format");
63
+ assert.ok(!ordinaryRaw.includes("relationship:"), "no relationship line leaks into an ordinary entity");
64
+ assert.ok(!/##\s+(Role|Style|Knows|Mandate)/.test(ordinaryRaw), "no persona sections leak into an ordinary entity");
65
+
66
+ // ── a persona pack round-trips: relationship + all four sections survive ──
67
+ const person = ent.upsertEntity({
68
+ name: "Ada Owner",
69
+ type: "person",
70
+ relationship: "owner",
71
+ notes: "Runs the whole show.",
72
+ role: "Founder and primary operator.",
73
+ style: "Terse, technical, mobile-first. No hand-holding.",
74
+ knows: "Deep in the codebase; assume kubernetes and gitops fluency.",
75
+ mandate: "Full trust — no guardrails apply to the owner.",
76
+ log: "kicked off the persona work",
77
+ });
78
+ assert.strictEqual(person.created, true, "first upsert creates");
79
+ const ada = ent.findEntity("Ada Owner");
80
+ assert.strictEqual(ada.relationship, "owner", "relationship persisted");
81
+ assert.strictEqual(ada.sections.Role, "Founder and primary operator.", "Role round-trips");
82
+ assert.strictEqual(ada.sections.Style, "Terse, technical, mobile-first. No hand-holding.", "Style round-trips");
83
+ assert.strictEqual(ada.sections.Knows, "Deep in the codebase; assume kubernetes and gitops fluency.", "Knows round-trips");
84
+ assert.strictEqual(ada.sections.Mandate, "Full trust — no guardrails apply to the owner.", "Mandate round-trips");
85
+ assert.strictEqual(ada.sections.Notes, "Runs the whole show.", "Notes still work alongside persona");
86
+ assert.ok(ada.sections.Log.includes("kicked off the persona work"), "Log still appends");
87
+
88
+ const adaRaw = fs.readFileSync(path.join(process.env.ENTITIES_DIR, "ada-owner.md"), "utf-8");
89
+ assert.ok(adaRaw.includes("relationship: owner"), "relationship written to frontmatter");
90
+ assert.ok(adaRaw.includes("## Mandate"), "Mandate section written when populated");
91
+
92
+ // ── persona sections replace wholesale (like Notes), log still appends ──
93
+ const person2 = ent.upsertEntity({
94
+ name: "Ada Owner",
95
+ style: "Even terser now.",
96
+ log: "second observation",
97
+ });
98
+ assert.strictEqual(person2.created, false, "second upsert merges, not creates");
99
+ const ada2 = ent.findEntity("Ada Owner");
100
+ assert.strictEqual(ada2.sections.Style, "Even terser now.", "Style replaced wholesale");
101
+ assert.strictEqual(ada2.sections.Role, "Founder and primary operator.", "unspecified Role is left untouched");
102
+ assert.ok(ada2.sections.Log.includes("kicked off the persona work"), "old log line retained");
103
+ assert.ok(ada2.sections.Log.includes("second observation"), "new log line appended");
104
+
105
+ // ── relationship is validated on upsert: junk is ignored, not stored ──
106
+ ent.upsertEntity({ name: "Ada Owner", relationship: "banana" });
107
+ assert.strictEqual(ent.findEntity("Ada Owner").relationship, "owner", "invalid relationship does not overwrite a valid one");
108
+
109
+ // ── FTS: descriptive persona sections are matchable; Mandate (policy) is not ──
110
+ if (ent.reindex()) {
111
+ ent.upsertEntity({
112
+ name: "Zephyr Contact",
113
+ type: "person",
114
+ relationship: "external",
115
+ knows: "familiar with quantumfoo pipelines",
116
+ mandate: "may only discuss zebranaut invoicing, nothing else",
117
+ });
118
+ ent.reindex();
119
+ const byKnows = ent.matchEntities("quantumfoo", { threshold: 1 }).map((r) => r.slug);
120
+ assert.ok(byKnows.includes("zephyr-contact"), "a Knows word matches the persona pack");
121
+ const byMandate = ent.matchEntities("zebranaut", { threshold: 1 }).map((r) => r.slug);
122
+ assert.ok(!byMandate.includes("zephyr-contact"), "a Mandate word does NOT surface the pack (policy, not description)");
123
+ console.log("persona packs OK (incl. FTS mandate-exclusion)");
124
+ } else {
125
+ console.log("persona packs OK (FTS mandate-exclusion skipped — no node:sqlite)");
126
+ }
@@ -0,0 +1,79 @@
1
+ // Persona pipeline: the two write-side paths that POPULATE a persona pack —
2
+ // the post-turn reviewer (pack-review.applyEntityAction) and the nightly dream
3
+ // consolidation (dream.applyDream entity_notes). Both must thread the persona
4
+ // fields (relationship/role/style/knows/mandate) into the entity, replacing
5
+ // each section wholesale, without disturbing the base Notes/Log contract.
6
+
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "persona-pipeline-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+
16
+ const entities = require("./core/entities");
17
+ const review = require("./core/pack-review");
18
+ const dream = require("./core/dream");
19
+
20
+ // ── reviewer: a persona-bearing entity action writes all persona sections ──
21
+ const res = review.applyEntityAction({
22
+ name: "Rex Boss",
23
+ type: "person",
24
+ relationship: "trusted",
25
+ description: "the CTO",
26
+ role: "CTO — owns platform architecture.",
27
+ style: "Blunt and fast. Skip preamble.",
28
+ knows: "Deep backend; assume distributed-systems fluency.",
29
+ mandate: "May discuss roadmap and architecture, not headcount.",
30
+ log: "first mapped his persona",
31
+ });
32
+ assert.strictEqual(res.kind, "create", "reviewer creates the entity");
33
+ let rex = entities.findEntity("Rex Boss");
34
+ assert.strictEqual(rex.relationship, "trusted", "reviewer set relationship");
35
+ assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "reviewer set Role");
36
+ assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "reviewer set Style");
37
+ assert.strictEqual(rex.sections.Knows, "Deep backend; assume distributed-systems fluency.", "reviewer set Knows");
38
+ assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "reviewer set Mandate");
39
+ assert.ok(rex.sections.Log.includes("first mapped his persona"), "reviewer appended Log");
40
+
41
+ // ── reviewer: a note-only action leaves persona sections intact ──
42
+ review.applyEntityAction({ name: "Rex Boss", notes: "Now leads a second team.", log: "team change" });
43
+ rex = entities.findEntity("Rex Boss");
44
+ assert.strictEqual(rex.sections.Notes, "Now leads a second team.", "notes updated");
45
+ assert.strictEqual(rex.sections.Style, "Blunt and fast. Skip preamble.", "persona Style untouched by a note-only update");
46
+ assert.strictEqual(rex.sections.Mandate, "May discuss roadmap and architecture, not headcount.", "persona Mandate untouched");
47
+
48
+ // ── dream: entity_notes consolidation can refine persona fields wholesale ──
49
+ const lines = dream.applyDream({
50
+ entity_notes: [{
51
+ entity: "rex-boss",
52
+ style: "Even blunter now — one-liners.",
53
+ mandate: "Roadmap, architecture AND hiring.",
54
+ }],
55
+ }, null);
56
+ rex = entities.findEntity("Rex Boss");
57
+ assert.strictEqual(rex.sections.Style, "Even blunter now — one-liners.", "dream refined Style wholesale");
58
+ assert.strictEqual(rex.sections.Mandate, "Roadmap, architecture AND hiring.", "dream refined Mandate wholesale");
59
+ assert.strictEqual(rex.sections.Role, "CTO — owns platform architecture.", "dream left unspecified Role intact");
60
+ assert.ok(lines.some((l) => /persona/.test(l)), "dream announces a persona refresh");
61
+
62
+ // ── dream: a plain note refresh still works and does NOT wipe persona ──
63
+ dream.applyDream({ entity_notes: [{ entity: "rex-boss", notes: "Back to one team." }] }, null);
64
+ rex = entities.findEntity("Rex Boss");
65
+ assert.strictEqual(rex.sections.Notes, "Back to one team.", "dream refreshed Notes");
66
+ assert.strictEqual(rex.sections.Style, "Even blunter now — one-liners.", "note-only dream refresh preserves persona Style");
67
+
68
+ // ── provenance (2.5): non-owner contributions are stamped, owner's are not ──
69
+ assert.strictEqual(review.provenanceTag({ name: "Zephyr Contact", isOwner: false }), "(via Zephyr Contact) ", "non-owner stamped");
70
+ assert.strictEqual(review.provenanceTag({ name: "Ada Owner", isOwner: true }), "", "owner not stamped (default author)");
71
+ assert.strictEqual(review.provenanceTag(null), "", "no source → no stamp");
72
+ assert.strictEqual(review.provenanceTag({ name: " ", isOwner: false }), "", "blank name → no stamp");
73
+ // The tag decorates the appended Log line (Rex already exists by now).
74
+ review.applyEntityAction({ name: "Rex Boss", log: "(via Zephyr Contact) flagged a billing question" });
75
+ const lastLog = entities.findEntity("Rex Boss").sections.Log.split("\n").pop();
76
+ assert.ok(lastLog.includes("(via Zephyr Contact) flagged a billing question"), "provenance tag lands in the Log line");
77
+ assert.ok(/^- \[\d{4}-\d{2}-\d{2}\] \(via Zephyr Contact\)/.test(lastLog), "date is prepended before the provenance tag");
78
+
79
+ console.log("persona pipeline OK");
@@ -0,0 +1,52 @@
1
+ // Origin-aware queue draining. Under unified identity the owner's channels
2
+ // share one run-lock and message queue, so a message queued from one channel
3
+ // during a run on another must still be answered on its own channel. These
4
+ // tests lock in the grouping that guarantees that routing.
5
+
6
+ const assert = require("assert");
7
+ const { originKey, splitLeadingOriginGroup } = require("./core/queue-drain");
8
+
9
+ const tg = (id) => ({ prompt: `m${id}`, origin: { transport: "telegram", channelId: "6251055967" } });
10
+ const kz = (id) => ({ prompt: `m${id}`, origin: { transport: "kazee", channelId: "abc" } });
11
+ const legacy = (id) => ({ prompt: `m${id}` }); // queued before origin capture
12
+
13
+ // originKey
14
+ assert.strictEqual(originKey(tg(1)), "telegram:6251055967", "telegram key");
15
+ assert.strictEqual(originKey(kz(1)), "kazee:abc", "kazee key");
16
+ assert.strictEqual(originKey(legacy(1)), "__current__", "legacy → sentinel");
17
+
18
+ // Empty queue
19
+ assert.deepStrictEqual(splitLeadingOriginGroup([]), { group: [], rest: [] }, "empty");
20
+
21
+ // All one origin → single group, no remainder (the common case: preserves the
22
+ // prior single-batch behaviour, now scoped to that origin).
23
+ {
24
+ const q = [tg(1), tg(2), tg(3)];
25
+ const { group, rest } = splitLeadingOriginGroup(q);
26
+ assert.strictEqual(group.length, 3, "same-origin: all grouped");
27
+ assert.strictEqual(rest.length, 0, "same-origin: nothing requeued");
28
+ }
29
+
30
+ // Mixed origins → only the leading same-origin run comes out; the rest is
31
+ // requeued so the next drain routes it to its own channel.
32
+ {
33
+ const q = [tg(1), tg(2), kz(3), tg(4)];
34
+ const { group, rest } = splitLeadingOriginGroup(q);
35
+ assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "leading telegram group");
36
+ assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3", "m4"], "kazee + later telegram requeued");
37
+ // Draining the remainder next peels off the kazee message on its own.
38
+ const next = splitLeadingOriginGroup(rest);
39
+ assert.deepStrictEqual(next.group.map((m) => m.prompt), ["m3"], "next drain: kazee alone");
40
+ assert.deepStrictEqual(next.rest.map((m) => m.prompt), ["m4"], "next drain: trailing telegram requeued");
41
+ }
42
+
43
+ // Legacy items (no origin) group together under the sentinel and fall back to
44
+ // the finishing turn's context — matching pre-change behaviour.
45
+ {
46
+ const q = [legacy(1), legacy(2), tg(3)];
47
+ const { group, rest } = splitLeadingOriginGroup(q);
48
+ assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "legacy grouped");
49
+ assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3"], "origin item requeued");
50
+ }
51
+
52
+ console.log("queue-routing OK");
@@ -0,0 +1,214 @@
1
+ // v2.6.59 evolution: dream-tunable knobs (bounded, env-pinned, revertible),
2
+ // walker cascade verdicts, per-node evidence, windowed health deltas, delta
3
+ // dreaming (dump selection + merge clusters), reviewer skip-gate, journal
4
+ // backfill dedupe, and version-stamped KPI aggregation.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ // Redirect EVERYTHING before any require: CONFIG_DIR derives from HOME, and
11
+ // metrics._resetForTest() deletes files — must never touch live telemetry.
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-evolution-"));
13
+ process.env.HOME = tmp;
14
+ process.env.PACKS_DIR = path.join(tmp, "packs");
15
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
16
+ delete process.env.RECALL_WALKER_MAX_CANDIDATES;
17
+
18
+ const tuning = require("./core/recall/tuning");
19
+ const metrics = require("./core/recall/metrics");
20
+ const disc = require("./core/recall/discoverer");
21
+ const review = require("./core/pack-review");
22
+ const dream = require("./core/dream");
23
+ const kpi = require("./core/recall/kpi");
24
+ const packs = require("./core/packs");
25
+
26
+ // ── tuning: bounds, precedence, revert ──
27
+ tuning._resetForTest();
28
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 14, "default value");
29
+ assert.strictEqual(tuning.get("cascade"), "on", "enum default");
30
+ assert.strictEqual(tuning.get("nope"), undefined, "unknown knob → undefined");
31
+
32
+ const ch = tuning.set("walkerMaxCandidates", 18, { reason: "test", evidence: "unit" });
33
+ assert.deepStrictEqual({ knob: ch.knob, from: ch.from, to: ch.to }, { knob: "walkerMaxCandidates", from: 14, to: 18 });
34
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 18, "set applies");
35
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 18), null, "no-op set rejected");
36
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 99).to, 20, "clamped to max");
37
+ assert.strictEqual(tuning.set("bogusKnob", 5), null, "unknown knob rejected");
38
+ assert.strictEqual(tuning.set("cascade", "sideways"), null, "illegal enum clamps to default → no-op");
39
+ assert.strictEqual(tuning.get("cascade") === "on" || tuning.get("cascade") === "off", true);
40
+
41
+ process.env.RECALL_WALKER_MAX_CANDIDATES = "10";
42
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 10, "env pin wins over tuning file");
43
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 16), null, "env-pinned knob refuses dream writes");
44
+ delete process.env.RECALL_WALKER_MAX_CANDIDATES;
45
+
46
+ const rv = tuning.revert("walkerMaxCandidates", { reason: "test rollback" });
47
+ assert.strictEqual(rv.to, 18, "revert restores the value before the last change");
48
+ assert.ok(tuning.history().some((h) => /^rollback:/.test(h.reason)), "rollback recorded in history");
49
+ assert.ok(tuning.lastChange() && !/^rollback:/.test(tuning.lastChange().reason), "lastChange skips rollbacks");
50
+
51
+ // ── cascade verdicts: conservative, evidence-only ──
52
+ assert.strictEqual(disc.cascadeVerdict(null), null, "no history → judge");
53
+ assert.strictEqual(disc.cascadeVerdict({ seen: 6, kept: 0, open: 0, rev: 0 }), "drop", "seen≥6 never kept never used → drop");
54
+ assert.strictEqual(disc.cascadeVerdict({ seen: 5, kept: 4, open: 1 }), "keep", "keep-rate ≥0.8 with real use → keep");
55
+ assert.strictEqual(disc.cascadeVerdict({ seen: 5, kept: 4, open: 0, rev: 0 }), null, "high keep but never used → judge");
56
+ assert.strictEqual(disc.cascadeVerdict({ seen: 3, kept: 0, open: 0 }), null, "too little history → judge");
57
+ assert.strictEqual(disc.cascadeVerdict({ seen: 6, kept: 1, open: 0 }), null, "ambiguous middle → judge");
58
+
59
+ // ── metrics: node stats, rescues, co-rescue pairs, evidence ──
60
+ metrics._resetForTest();
61
+ for (let i = 0; i < 7; i++) {
62
+ metrics.logTurn({
63
+ query: "q", tier: "full",
64
+ seeds: [{ id: "pack:seeded", score: 3 }],
65
+ activated: [{ id: "pack:noisy", activation: 0.5, hop: 1 }],
66
+ cand: [{ id: "pack:seeded", act: false }, { id: "pack:noisy", act: true }],
67
+ kept: i < 3 ? [{ id: "pack:seeded", why: "w" }, { id: "pack:resc-a", why: "w" }, { id: "pack:resc-b", why: "w" }]
68
+ : [{ id: "pack:seeded", why: "w" }],
69
+ latencyMs: 1000, costUsd: 0.001,
70
+ });
71
+ }
72
+ metrics.logUse(["pack:seeded"]);
73
+ metrics.logUse(["pack:seeded"], "reviewer");
74
+ const ns = metrics.nodeStats().nodes;
75
+ assert.strictEqual(ns["pack:noisy"].seen, 7, "candidate seen count accumulates");
76
+ assert.strictEqual(ns["pack:noisy"].kept, 0, "never-kept stays zero");
77
+ assert.strictEqual(ns["pack:noisy"].act, 7, "graph-arrival count tracked");
78
+ assert.strictEqual(ns["pack:resc-a"].resc, 3, "kept-without-seed counts as rescue");
79
+ assert.strictEqual(ns["pack:seeded"].open, 1, "📖 open tracked per node");
80
+ assert.strictEqual(ns["pack:seeded"].rev, 1, "reviewer credit tracked per node");
81
+
82
+ const ev = metrics.evidence({ minSeen: 6, coRescueMin: 3 });
83
+ assert.ok(ev.neverKept.some((n) => n.id === "pack:noisy"), "noisy node lands in neverKept");
84
+ assert.ok(!ev.neverKept.some((n) => n.id === "pack:seeded"), "used node never in neverKept");
85
+ assert.ok(ev.coRescuePairs.some((p) => p.a === "pack:resc-a" && p.b === "pack:resc-b" && p.n === 3), "co-rescue pair counted");
86
+ assert.ok(ev.summary.turns === 7, "evidence carries the raw summary");
87
+
88
+ // cascadeVerdict against the accumulated stats: noisy is a drop, seeded needs more
89
+ assert.strictEqual(disc.cascadeVerdict(ns["pack:noisy"]), "drop", "real accumulated stats produce a drop verdict");
90
+
91
+ // ── dream: windowed deltas + health ──
92
+ const delta = dream.summaryDelta(
93
+ { turns: 10, opens: 5, keptTotal: 12, rescueTurns: 3, latencyMsTotal: 30000, shadowChecks: 4, shadowAgree: 4, byTier: { full: { n: 4, ms: 40000 }, seeds: { n: 6, ms: 600 } } },
94
+ { turns: 6, opens: 2, keptTotal: 6, rescueTurns: 1, latencyMsTotal: 10000, byTier: { full: { n: 2, ms: 10000 } } }
95
+ );
96
+ assert.strictEqual(delta.turns, 4);
97
+ assert.strictEqual(delta.opens, 3);
98
+ assert.deepStrictEqual(delta.byTier.full, { n: 2, ms: 30000 });
99
+ assert.deepStrictEqual(delta.byTier.seeds, { n: 6, ms: 600 });
100
+ const health = dream.healthOf({ turns: 10, rescueTurns: 3, keptTotal: 10, opens: 2, reviewerUses: 1, latencyMsTotal: 20000 });
101
+ assert.strictEqual(health.rescueRate, 0.3);
102
+ assert.strictEqual(health.noiseRate, 0.7);
103
+ assert.strictEqual(health.avgLatencyMs, 2000);
104
+
105
+ // ── dream: merge clusters (similarity + ability boundary) ──
106
+ const mkPack = (dir, name, description, kind) => ({ dir, name, description, tags: ["kazee", "mobile"], kind });
107
+ const clusters = dream.mergeClusters([
108
+ mkPack("kazee-mobile-deploy", "Kazee Mobile Deployment", "APK versionCode updater pipeline GitLab CI"),
109
+ mkPack("kazee-mobile-deployment", "Kazee Mobile Deployment", "APK versionCode updater pipeline GitLab"),
110
+ mkPack("villa-heatmap", "Villa WiFi Heatmap", "LAN hosted survey renders access points"),
111
+ ]);
112
+ assert.strictEqual(clusters.length, 1, "one similar pair clusters");
113
+ assert.deepStrictEqual([...clusters[0]].sort(), ["kazee-mobile-deploy", "kazee-mobile-deployment"]);
114
+ const abilitySplit = dream.mergeClusters([
115
+ mkPack("deploy-a", "Kazee Mobile Deployment", "APK versionCode updater pipeline", "ability"),
116
+ mkPack("deploy-b", "Kazee Mobile Deployment", "APK versionCode updater pipeline"),
117
+ ]);
118
+ assert.strictEqual(abilitySplit.length, 0, "ability/context never cluster together");
119
+
120
+ // ── dream: delta dump selection ──
121
+ const now = Date.now();
122
+ const iso = (d) => new Date(d).toISOString();
123
+ const corpus = [
124
+ { dir: "touched", updated: iso(now - 3600e3), tags: [] },
125
+ { dir: "stale", updated: iso(now - 40 * 86400e3), tags: [] },
126
+ { dir: "concernpack", updated: iso(now - 40 * 86400e3), tags: ["concern"] },
127
+ { dir: "clustered", updated: iso(now - 40 * 86400e3), tags: [] },
128
+ ];
129
+ const since = iso(now - 86400e3);
130
+ const dump = dream.selectDumpDirs(corpus, { sinceTs: since, fullSweep: false, clusters: [["clustered", "touched"]] });
131
+ assert.ok(dump.has("touched"), "touched pack dumped in full");
132
+ assert.ok(dump.has("concernpack"), "shared concern always dumped");
133
+ assert.ok(dump.has("clustered"), "merge-cluster member dumped");
134
+ assert.ok(!dump.has("stale"), "untouched pack stays an index row");
135
+ const sweep = dream.selectDumpDirs(corpus, { sinceTs: since, fullSweep: true, clusters: [] });
136
+ assert.strictEqual(sweep.size, 4, "full sweep dumps everything");
137
+
138
+ // ── reviewer skip-gate: only provably-empty turns skip ──
139
+ const pad = "The deployment pipeline completed and artifacts were archived. ".repeat(8); // ~500 chars, no preference words
140
+ assert.strictEqual(review.shouldSkipReview({ userText: "ok", assistantText: "", signals: {} }), "short");
141
+ assert.strictEqual(review.shouldSkipReview({ userText: "thanks!", assistantText: pad, signals: { tier: "skip" } }), "trivial");
142
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds", wrote: false } }), "lookup");
143
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds", wrote: true } }), null, "a write means review");
144
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds" } }), null, "unknown wrote-state means review");
145
+ assert.strictEqual(review.shouldSkipReview({ userText: "remember the fup rule", assistantText: pad, signals: { tier: "seeds", wrote: false } }), null, "preference language escapes the skip");
146
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "full", wrote: false } }), null, "full tier always reviews");
147
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad + pad + pad, signals: { tier: "seeds", wrote: false } }), null, "long turns always review");
148
+
149
+ // ── dream: journal backfill dedupe ──
150
+ packs.createPack({ dir: "backfill-me", name: "Backfill", description: "test" });
151
+ const p = packs.readPack("backfill-me");
152
+ p.sections.Journal = [
153
+ "- [2026-06-01] Tuned the FUP enforcer thresholds and reran the dry-run",
154
+ "- [2026-06-02] Tuned the FUP enforcer thresholds and reran the dry-run",
155
+ "- [2026-06-03] Tuned FUP enforcer thresholds and reran the dry-run again",
156
+ "- [2026-06-04] Shipped the tiered recall gate with the candidate cap",
157
+ ].join("\n");
158
+ packs.writePack(p);
159
+ const backupRoot = path.join(tmp, "backup");
160
+ const jd = dream.journalBackfillDedupe(backupRoot);
161
+ assert.ok(jd.removed >= 2, `near-dup journal lines collapsed (removed ${jd.removed})`);
162
+ const after = packs.readPack("backfill-me").sections.Journal.split("\n").filter((l) => l.trim());
163
+ assert.ok(after.some((l) => /tiered recall gate/.test(l)), "distinct line survives");
164
+ assert.ok(after.some((l) => /FUP enforcer/.test(l)), "one representative of the dup family survives");
165
+ assert.ok(fs.existsSync(path.join(backupRoot, "packs", "backfill-me")), "deduped pack backed up first");
166
+
167
+ // ── KPI: version-stamped aggregation + snapshots + report ──
168
+ metrics._resetForTest();
169
+ const L = (o) => JSON.stringify(o) + "\n";
170
+ fs.writeFileSync(metrics.LOG_FILE, [
171
+ L({ ts: "2026-06-01T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }, { id: "pack:b" }], latencyMs: 10000, costUsd: 0.01 }),
172
+ L({ ts: "2026-06-01T00:01:00Z", v: "2.6.50", use: ["pack:a"] }),
173
+ L({ ts: "2026-06-02T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", gated: true, latencyMs: 5 }),
174
+ L({ ts: "2026-07-01T00:00:00Z", v: "2.6.59", model: "haiku", tier: "seeds", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }], latencyMs: 2000, costUsd: 0, auto: { kept: ["pack:a"], dropped: ["pack:z"] } }),
175
+ L({ ts: "2026-05-01T00:00:00Z", tier: "full", seeds: [], kept: [], latencyMs: 3000, walker: "failed" }),
176
+ ].join(""));
177
+ const rows = kpi.aggregate();
178
+ assert.strictEqual(rows.length, 3, "three version buckets (two stamped + legacy)");
179
+ assert.strictEqual(rows[0].v, kpi.LEGACY_VERSION, "unstamped records fall into the legacy bucket, ordered first");
180
+ const v50 = rows.find((r) => r.v === "2.6.50");
181
+ assert.strictEqual(v50.turns, 2);
182
+ assert.strictEqual(v50.gatedPct, 50);
183
+ assert.strictEqual(v50.rescuePct, 100, "kept-without-seed counts as a rescue turn");
184
+ assert.strictEqual(v50.noisePct, 50, "one of two kept nodes was used");
185
+ assert.deepStrictEqual(v50.models, ["haiku"]);
186
+ const v59 = rows.find((r) => r.v === "2.6.59");
187
+ assert.strictEqual(v59.rescuePct, 0);
188
+ assert.strictEqual(v59.autoKept, 1);
189
+ assert.strictEqual(v59.autoDropped, 1);
190
+ assert.strictEqual(rows.find((r) => r.v === kpi.LEGACY_VERSION).walkerFailPct, 100);
191
+
192
+ metrics.appendKpi({ trigger: "test", health: { rescueRate: 0.25, noiseRate: 0.5, avgLatencyMs: 8000 }, window: { turns: 40 } });
193
+ metrics.appendKpi({ trigger: "test", health: { rescueRate: 0.30, noiseRate: 0.4, avgLatencyMs: 7000 }, window: { turns: 55 } });
194
+ const snaps = metrics.readKpi();
195
+ assert.strictEqual(snaps.length, 2, "nightly snapshots append");
196
+ assert.ok(snaps[0].v && snaps[0].ts, "snapshots are version-stamped");
197
+
198
+ const table = kpi.renderTable(rows);
199
+ assert.ok(/2\.6\.50/.test(table) && /rescue/.test(table), "table renders versions and metrics");
200
+ const html = kpi.htmlReport();
201
+ assert.ok(/<svg/.test(html) && /2\.6\.59/.test(html) && /rescue/i.test(html), "HTML report has charts and versions");
202
+ const outPath = kpi.writeHtmlReport(path.join(tmp, "kpi.html"));
203
+ assert.ok(fs.existsSync(outPath), "report file written");
204
+
205
+ // ── dream: prompt carries evidence + knob table; parseDream accepts tuning ──
206
+ const prompt = dream.buildDreamPrompt({ sinceTs: since, fullSweep: false, clusters: [], evidence: ev, windowDelta: { turns: 4, rescueTurns: 1, keptTotal: 4, opens: 1, latencyMsTotal: 8000, byTier: {} } });
207
+ assert.ok(/RECALL EVIDENCE/.test(prompt), "evidence section present");
208
+ assert.ok(/walkerMaxCandidates/.test(prompt), "knob table present");
209
+ assert.ok(/delta night/.test(prompt), "delta framing present");
210
+ const parsed = dream.parseDream(JSON.stringify({ report: "hi", tuning: { knob: "episodeLimit", to: 4, reason: "r" } }));
211
+ assert.deepStrictEqual(parsed.tuning, { knob: "episodeLimit", to: 4, reason: "r" }, "tuning proposal parses");
212
+ assert.strictEqual(dream.parseDream(JSON.stringify({ report: "hi", tuning: "bogus" })).tuning, null, "non-object tuning ignored");
213
+
214
+ console.log("recall evolution OK");
@@ -0,0 +1,42 @@
1
+ // Relationship-gated cross-conversation recall (R6). Episodic recall reaches
2
+ // into the owner's project transcripts, so it must be OWNER-ONLY: an external
3
+ // speaker's turn must never pull the owner's past conversations. These tests
4
+ // pin the fail-closed gate — owner allowed, non-owner blocked, unknown blocked,
5
+ // and no-speaker-context (CLI/tests) ungated so local tooling still works.
6
+
7
+ const assert = require("assert");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-gate-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
15
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
16
+
17
+ const people = require("./core/people");
18
+ const { runInChat } = require("./core/context");
19
+ const { episodesAllowedForSpeaker } = require("./core/recall/discoverer");
20
+
21
+ const adapter = { id: "telegram", type: "telegram" };
22
+
23
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
24
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: "111" });
25
+ const ext = people.add({ name: "Zephyr Contact" });
26
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: "222" });
27
+
28
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
29
+
30
+ // No speaker context at all (CLI / tests) → ungated, episodes flow as before.
31
+ assert.strictEqual(episodesAllowedForSpeaker(), true, "no chat context → episodes allowed (CLI/tests)");
32
+
33
+ // Owner speaking → allowed.
34
+ assert.strictEqual(inChat("111", () => episodesAllowedForSpeaker()), true, "owner → episodes allowed");
35
+
36
+ // Known non-owner speaking → blocked (their turn can't pull the owner's past).
37
+ assert.strictEqual(inChat("222", () => episodesAllowedForSpeaker()), false, "external → episodes blocked");
38
+
39
+ // Unknown channel (no person record) → fail closed.
40
+ assert.strictEqual(inChat("999", () => episodesAllowedForSpeaker()), false, "unknown speaker → episodes blocked");
41
+
42
+ console.log("recall relationship gate OK");