@inetafrica/open-claudia 2.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.12.0
4
+ - **Persona packs — the bot now adapts voice + scope to WHO it's talking to (Phase 2).** An entity note about a *person* is upgraded into a persona pack: the same file gains four optional sections — **Role** (what they do), **Style** (how to talk to them), **Knows** (what to assume vs explain), **Mandate** (for non-owners: what the bot may do/discuss on their behalf) — plus a `relationship: owner|trusted|external` frontmatter scalar. The upgrade is strictly **additive (risk R7)**: an ordinary entity with no persona data serializes **byte-identically** to before, so places/projects/orgs/systems are untouched. `core/entities.js` learns `PERSONA_SECTIONS`, `RELATIONSHIPS`, `normalizeRelationship`, an `emptySections()` backfill for notes written before the upgrade, and persona params on `upsertEntity` that each replace their section wholesale (like Notes). FTS indexes the descriptive sections (Role/Style/Knows) so a persona is matchable by content, but **Mandate is deliberately not indexed** — it's policy, not description, and must not surface a pack on an unrelated mention.
5
+ - **Per-turn speaker persona, injected cache-safely (2.2).** `buildSpeakerPersonaBlock` resolves the *current* speaker (deterministically, not by keyword match) and injects their Role/Style/Knows — and, for a non-owner, their Mandate — so the model shapes its voice and scope every turn. It rides the **uncached dynamic tail** (risk R13), never `buildSystemPrompt`, so persona edits never bust the prompt-cache prefix, and dedupes once per (channel, session, entity-version) like packs/entities (a compaction or a persona edit re-injects). An **owner's Mandate is never injected** (the owner has no guardrails — injecting one would be noise).
6
+ - **People ↔ persona binding (2.1).** `people.js` gains an `entitySlug` pointer (a person can point at a differently-named pack) with `setEntitySlug` + a pure `resolveEntitySlug` (explicit pointer, else slug-from-name); surfaced in `roster()`.
7
+ - **Write-side population (2.3).** The post-turn reviewer (`pack-review.js`) and the nightly dream (`dream.js`) both learn to fill persona sections: their prompts describe the four sections + the relationship scalar with explicit guardrails ("never invent an owner mandate; never copy one person's guardrail onto another"), their JSON schemas carry the new fields, and their apply paths thread them into `upsertEntity` (each replacing its section wholesale; null/omit leaves it intact).
8
+ - **Relationship-gated cross-conversation recall (2.4, risk R6).** Episodic recall reaches across conversations into the owner's project transcripts, so surfacing it to a non-owner is a cross-tenant leak. `episodesAllowedForSpeaker()` makes cross-conversation episodes **owner-only and fail-closed**: an unknown speaker or any resolution error suppresses episodes; only a positively-identified owner (or no speaker context at all — CLI/tests) pulls them. Same-conversation packs/entities are unaffected.
9
+ - **Provenance stamps (2.5).** Appended Log/Journal lines from a **non-owner** contributor are stamped `(via <name>)` (the owner is the default author — tagging them would be noise), so a topic's history shows where an external/trusted colleague's contribution came from. Resolved from the live store at the reviewer call site (the reviewer applies in an async continuation) and threaded through as a `source`.
10
+ - **Tests.** Four new hermetic suites, all wired into `npm test`: `test-persona-packs.js` (round-trip + R7 byte-identical ordinary entity + FTS mandate-exclusion), `test-speaker-persona.js` (owner-Mandate suppression, non-owner Mandate shown, dedupe + re-inject on edit, silent for no-persona/unknown speaker), `test-persona-pipeline.js` (reviewer + dream populate/refresh persona wholesale + provenance), `test-recall-relationship-gate.js` (owner allowed, non-owner/unknown blocked, no-context ungated). Full suite green.
11
+ - **Agent-space / OpenClaw:** no new deps, no new required env; Docker image builds identically. Behaviour is inert until a person actually has persona data — existing deployments are unaffected until the reviewer/dream (or the owner, editing `entities/<slug>.md`) fills a pack. The independent **fail-closed enforcer** that hard-enforces Mandate at the outbound choke point is still Phase 3.
12
+
3
13
  ## v2.11.0
4
14
  - **Unified multi-channel identity is now LIVE by default (Phases 0 + 1).** v2.10.0 shipped the split-brain fix inert behind `OC_UNIFIED_IDENTITY` (default off). This release flips the default **on** and lands the Phase 1 queue-routing fix that made it safe to do so. The owner's Telegram, Kazee and voice channels now resolve to one canonical person out of the box — one state, one session, one transcript, one run-lock — so the bot stops fracturing into a separate assistant per channel. The flag stays as an operator **kill-switch** (`OC_UNIFIED_IDENTITY=0` + restart) for reversibility (risk R14); it is no longer a hold-back gate.
5
15
  - **Person-scoped queue routing (Phase 1, 1.2) — the blocker that kept the flag off.** Under unified identity the owner's channels share one run-lock and message queue, so a message sent from one channel while a run is in flight on another used to drain in the *finishing* turn's async scope — a Kazee follow-up could be answered in the Telegram chat. Each queued message now **captures its origin channel** at enqueue time, and `drainQueuedMessages` peels off one same-origin run at a time (requeuing the rest so the natural close→drain tail-recursion handles the next group), scoping each drained reply to `chatContext.run(origin, …)`. Replies always route back to the channel the message came from; same-origin batches behave exactly as before. Grouping logic extracted to a pure, unit-tested `core/queue-drain.js`.
package/core/dream.js CHANGED
@@ -320,7 +320,7 @@ Your job — decide what consolidation, if any, is warranted:
320
320
  3. parents: assign an existing pack as parent of another (sub-topic relationship) without creating anything. NOTE: a separate deterministic pass already auto-files packs whose slug is a prefix-child of another (e.g. foo-bar under foo), so DON'T spend a parents op on those — reserve parents (and umbrellas) for semantic groupings the slug prefix can't catch (differently-named siblings of one theme).
321
321
  4. retag: tighten descriptions and tags. The router FTS-matches incoming messages against name/description/tags, so generic words there cause false matches. Descriptions should be one specific line; tags specific nouns.
322
322
  5. entity_merges: the same real-world entity recorded twice gets merged (the better slug wins).
323
- 6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name.
323
+ 6. entity_notes: rewrite an entity's Notes to be current and cross-linked — mention related packs as [[pack-dir]] and related entities by name. For a PERSON, this doubles as their persona pack: you may also consolidate role/style/knows (how to talk to them, what they know) and — for non-owners only — mandate (what the assistant may do/discuss on their behalf). Set relationship (owner|trusted|external) only if clearly established. Keep each persona field tight; never invent an owner mandate; never copy one person's guardrail onto another. Send only the fields you're changing; null/omit the rest.
324
324
  7. archive: retire packs that have gone cold — long unused AND rarely used over their life (consult last_used and the usage count). Archiving moves a pack out of the live index (reversibly, backed up) so it stops adding recall noise. ONLY propose packs idle ≥${ARCHIVE_IDLE_DAYS} days and used ≤${ARCHIVE_MAX_USAGE}× total; never an umbrella/parent pack that still has children; when in doubt, leave it. Give a one-line reason.
325
325
  8. lessons: keep the always-loaded lessons few and sharp — they cost context on EVERY turn. dedupe near-identical lessons (op "remove" the weaker, keep the better wording; or op "edit" to merge two into one tight line); tighten a clumsy lesson with op "edit". If the count exceeds the cap of ${lessons.MAX_LESSONS}, demote the weakest down to the cap — prefer lessons that are low-reinforced AND whose fact is safely captured in the pack named in their (src) (they will still surface by topic-match), and NEVER remove a frequently-reinforced lesson (those are actively preventing a repeat mistake). Even under the cap you may remove a lesson clearly redundant with its source pack that has stayed at 0 reinforcements for a long time, but be conservative — when in doubt, keep it. Reference lessons by their exact current text.
326
326
  9. persona: evolve the persona GENTLY — keep its structure and length (under 2200 chars), adjust only what recent work justifies (a new habit, a sharpened quirk). Most dreams should return null here.
@@ -341,7 +341,7 @@ Reply with ONLY a JSON object, no prose, no code fences:
341
341
  "parents": [{"pack": "<dir>", "parent": "<dir>"}],
342
342
  "retag": [{"pack": "<dir>", "description": "<one specific line>", "tags": ["..."]}],
343
343
  "entity_merges": [{"into": "<slug>", "from": ["<slug>"], "notes": "<merged Notes or null>"}],
344
- "entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes>"}],
344
+ "entity_notes": [{"entity": "<slug>", "notes": "<rewritten Notes or null>", "relationship": "<owner|trusted|external, people only, or null>", "role": "<people only, or null>", "style": "<people only, or null>", "knows": "<people only, or null>", "mandate": "<non-owner people only, or null>"}],
345
345
  "archive": [{"pack": "<dir>", "reason": "<one line: why it's cold>"}],
346
346
  "lessons": [{"op": "remove", "text": "<exact current lesson text>", "reason": "<duplicate of … / safely in pack … / cold>"}],
347
347
  "persona": null,
@@ -582,9 +582,17 @@ function applyDream(decision, backupRoot) {
582
582
  for (const en of decision.entity_notes || []) {
583
583
  try {
584
584
  const ent = en?.entity && !gone.has(en.entity) && entities.readEntity(en.entity);
585
- if (!ent || typeof en.notes !== "string" || !en.notes.trim()) continue;
586
- entities.upsertEntity({ name: ent.name, notes: en.notes });
587
- lines.push(`🔗 Refreshed notes on ${ent.name}`);
585
+ if (!ent) continue;
586
+ // Persona-pack fields consolidate alongside Notes (people only). Each
587
+ // replaces its section wholesale when a non-empty string is supplied.
588
+ const persona = {};
589
+ for (const k of ["relationship", "role", "style", "knows", "mandate"]) {
590
+ if (typeof en[k] === "string" && en[k].trim()) persona[k] = en[k];
591
+ }
592
+ const hasNotes = typeof en.notes === "string" && en.notes.trim();
593
+ if (!hasNotes && Object.keys(persona).length === 0) continue;
594
+ entities.upsertEntity({ name: ent.name, ...(hasNotes ? { notes: en.notes } : {}), ...persona });
595
+ lines.push(`🔗 Refreshed ${Object.keys(persona).length ? "persona + notes" : "notes"} on ${ent.name}`);
588
596
  } catch (e) { console.warn(`[dream] entity notes failed: ${e.message}`); }
589
597
  }
590
598
 
package/core/entities.js CHANGED
@@ -1,11 +1,20 @@
1
1
  // Entity memory: short living notes on the people, places, projects,
2
2
  // orgs and systems that come up in conversation (a Honcho-style store,
3
3
  // our own version). Each entity is ~/.open-claudia/entities/<slug>.md
4
- // with frontmatter and two sections:
4
+ // with frontmatter and these sections:
5
5
  // ## Notes — current truth about the entity (replaces wholesale)
6
6
  // ## Log — dated one-line observations, newest last
7
- // The pre-turn router injects matched entities alongside context packs;
8
- // the post-turn reviewer extracts/updates them after each turn.
7
+ // A person entity doubles as that person's PERSONA PACK — the structured
8
+ // per-person memory that shapes how the bot talks to them. Persona sections
9
+ // (written only when populated, so non-person entities stay unchanged):
10
+ // ## Role — what they do / own
11
+ // ## Style — how to communicate with them (tone, brevity, formality)
12
+ // ## Knows — domain knowledge to assume; what to explain vs skip
13
+ // ## Mandate — for non-owners: what the bot may do/discuss on their behalf
14
+ // plus a `relationship: owner|trusted|external` frontmatter scalar. The
15
+ // pre-turn router injects matched entities alongside context packs; the
16
+ // post-turn reviewer extracts/updates them after each turn; the dream
17
+ // compacts them.
9
18
 
10
19
  const fs = require("fs");
11
20
  const path = require("path");
@@ -18,6 +27,22 @@ const ENTITIES_DIR = process.env.ENTITIES_DIR ? path.resolve(process.env.ENTITIE
18
27
 
19
28
  const TYPES = ["person", "place", "project", "org", "system", "thing"];
20
29
  const MAX_LOG_ENTRIES = 40;
30
+ // Persona-pack sections layered on top of the base Notes/Log. Additive: only
31
+ // serialized when populated, so ordinary entities are untouched (R7).
32
+ const PERSONA_SECTIONS = ["Role", "Style", "Knows", "Mandate"];
33
+ const ALL_SECTIONS = ["Notes", "Log", ...PERSONA_SECTIONS];
34
+ const RELATIONSHIPS = ["owner", "trusted", "external"];
35
+
36
+ function emptySections() {
37
+ const out = {};
38
+ for (const s of ALL_SECTIONS) out[s] = "";
39
+ return out;
40
+ }
41
+
42
+ function normalizeRelationship(rel) {
43
+ const r = String(rel || "").toLowerCase().trim();
44
+ return RELATIONSHIPS.includes(r) ? r : "";
45
+ }
21
46
 
22
47
  function ensureDir() {
23
48
  fs.mkdirSync(ENTITIES_DIR, { recursive: true, mode: 0o700 });
@@ -45,28 +70,31 @@ function parseFrontmatter(content) {
45
70
  }
46
71
 
47
72
  function parseSections(body) {
48
- const out = { Notes: "", Log: "" };
73
+ const out = emptySections();
74
+ const headingRe = new RegExp(`^##\\s+(${ALL_SECTIONS.join("|")})\\s*$`, "i");
49
75
  let current = null;
50
76
  for (const line of String(body || "").split("\n")) {
51
- const h = line.match(/^##\s+(Notes|Log)\s*$/i);
77
+ const h = line.match(headingRe);
52
78
  if (h) {
53
- current = h[1].toLowerCase() === "notes" ? "Notes" : "Log";
79
+ current = ALL_SECTIONS.find((s) => s.toLowerCase() === h[1].toLowerCase());
54
80
  continue;
55
81
  }
56
82
  if (current) out[current] += line + "\n";
57
83
  }
58
- out.Notes = out.Notes.trim();
59
- out.Log = out.Log.trim();
84
+ for (const s of ALL_SECTIONS) out[s] = out[s].trim();
60
85
  return out;
61
86
  }
62
87
 
63
88
  function serialize(ent) {
64
- return [
89
+ const lines = [
65
90
  "---",
66
91
  `name: ${ent.name}`,
67
92
  `type: ${ent.type}`,
68
93
  `aliases: ${(ent.aliases || []).join(", ")}`,
69
94
  `description: ${ent.description || ""}`,
95
+ ];
96
+ if (ent.relationship) lines.push(`relationship: ${ent.relationship}`);
97
+ lines.push(
70
98
  `created: ${ent.created || new Date().toISOString()}`,
71
99
  `updated: ${ent.updated || new Date().toISOString()}`,
72
100
  `last_seen: ${ent.last_seen || ""}`,
@@ -80,7 +108,14 @@ function serialize(ent) {
80
108
  "",
81
109
  (ent.sections?.Log || "").trim(),
82
110
  "",
83
- ].join("\n");
111
+ );
112
+ // Persona sections are written only when populated, so an ordinary entity
113
+ // (no persona data) serializes byte-identically to before the upgrade.
114
+ for (const s of PERSONA_SECTIONS) {
115
+ const val = (ent.sections?.[s] || "").trim();
116
+ if (val) lines.push(`## ${s}`, "", val, "");
117
+ }
118
+ return lines.join("\n");
84
119
  }
85
120
 
86
121
  function entityFile(slug) {
@@ -98,6 +133,7 @@ function readEntity(slug) {
98
133
  type: normalizeType(fm.type),
99
134
  aliases: (fm.aliases || "").split(",").map((a) => a.trim()).filter(Boolean),
100
135
  description: fm.description || "",
136
+ relationship: normalizeRelationship(fm.relationship),
101
137
  created: fm.created || "",
102
138
  updated: fm.updated || "",
103
139
  last_seen: fm.last_seen || "",
@@ -141,9 +177,11 @@ function today() {
141
177
  return new Date().toISOString().slice(0, 10);
142
178
  }
143
179
 
144
- // Create-or-merge. Notes replace (current truth); log lines append
145
- // (capped); aliases union; description/type update when supplied.
146
- function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
180
+ // Create-or-merge. Notes + persona sections replace (current truth); log lines
181
+ // append (capped); aliases union; description/type/relationship update when
182
+ // supplied. Persona params (role/style/knows/mandate) each replace their
183
+ // section wholesale, mirroring Notes.
184
+ function upsertEntity({ name, type, aliases, description, notes, log, relationship, role, style, knows, mandate } = {}) {
147
185
  const cleanName = String(name || "").trim();
148
186
  if (!cleanName) throw new Error("entity needs a name");
149
187
  const existing = findEntity(cleanName);
@@ -153,13 +191,18 @@ function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
153
191
  type: normalizeType(type),
154
192
  aliases: [],
155
193
  description: "",
194
+ relationship: "",
156
195
  created: new Date().toISOString(),
157
- sections: { Notes: "", Log: "" },
196
+ sections: emptySections(),
158
197
  };
159
198
  if (!ent.slug) throw new Error("entity needs a name");
199
+ // Existing entities read before this upgrade may lack persona keys; backfill.
200
+ ent.sections = { ...emptySections(), ...(ent.sections || {}) };
160
201
  ent.updated = new Date().toISOString();
161
202
  if (type) ent.type = normalizeType(type);
162
203
  if (description) ent.description = String(description).trim();
204
+ const rel = normalizeRelationship(relationship);
205
+ if (rel) ent.relationship = rel;
163
206
  for (const a of [].concat(aliases || [])) {
164
207
  const alias = String(a).trim();
165
208
  if (alias && alias.toLowerCase() !== ent.name.toLowerCase() &&
@@ -169,6 +212,10 @@ function upsertEntity({ name, type, aliases, description, notes, log } = {}) {
169
212
  }
170
213
  ent.aliases = ent.aliases.slice(0, 8);
171
214
  if (typeof notes === "string" && notes.trim()) ent.sections.Notes = notes.trim();
215
+ const persona = { Role: role, Style: style, Knows: knows, Mandate: mandate };
216
+ for (const [section, value] of Object.entries(persona)) {
217
+ if (typeof value === "string" && value.trim()) ent.sections[section] = value.trim();
218
+ }
172
219
  if (typeof log === "string" && log.trim()) {
173
220
  const entries = ent.sections.Log.split("\n").filter((l) => l.trim());
174
221
  entries.push(`- [${today()}] ${log.trim().replace(/\n+/g, " ")}`);
@@ -262,7 +309,12 @@ function reindex() {
262
309
  db.exec("DELETE FROM entities");
263
310
  const insert = db.prepare("INSERT INTO entities (name, aliases, description, content, slug) VALUES (?, ?, ?, ?, ?)");
264
311
  for (const e of all) {
265
- insert.run(e.name, e.aliases.join(" "), e.description, [e.sections.Notes, e.sections.Log].join("\n"), e.slug);
312
+ // Index descriptive persona sections (Role/Style/Knows) so a persona pack
313
+ // is matchable by its content. Mandate is policy, not description — leaving
314
+ // it out keeps guardrail text from surfacing packs on unrelated mentions.
315
+ const content = [e.sections.Notes, e.sections.Log, e.sections.Role, e.sections.Style, e.sections.Knows]
316
+ .filter(Boolean).join("\n");
317
+ insert.run(e.name, e.aliases.join(" "), e.description, content, e.slug);
266
318
  }
267
319
  db.exec("COMMIT");
268
320
  } catch (e) {
@@ -326,6 +378,7 @@ function matchEntities(text, { limit = 4, threshold = null } = {}) {
326
378
 
327
379
  module.exports = {
328
380
  ENTITIES_DIR, TYPES, slugify,
381
+ PERSONA_SECTIONS, RELATIONSHIPS, normalizeRelationship,
329
382
  listEntities, findEntity, readEntity, writeEntity, upsertEntity, removeEntity,
330
383
  touchSeen, entityNameFromPath, matchEntities, reindex, markIndexDirty,
331
384
  };
@@ -114,6 +114,13 @@ An entity note is a short memory file about ONE specific named entity — a pers
114
114
  - Notes: current truth about the entity (who/what it is, role, preferences, relationships). Replaces wholesale.
115
115
  - Log: one-line dated observations, appended.
116
116
 
117
+ When the entity is a PERSON, the note doubles as their PERSONA PACK — the memory that shapes how the assistant talks to them. A person may also carry (all optional, all replace-wholesale like Notes):
118
+ - relationship: one of owner | trusted | external. The person the assistant works for is "owner"; a colleague the owner trusts is "trusted"; anyone else is "external". Only set this when the turn makes it clear.
119
+ - role: what they do / own, in a line.
120
+ - style: how to communicate with them — tone, brevity, formality, format preferences.
121
+ - knows: domain knowledge to assume they have, so the assistant knows what to explain vs skip.
122
+ - mandate: for NON-owners only, what the assistant may do or discuss on their behalf (a scope/guardrail). Leave empty for the owner.
123
+
117
124
  An ABILITY is a special kind of pack (kind:"ability") for a REUSABLE HOW-TO that is NOT tied to one project — a procedure, pattern, or technique you would follow just as well on a different project later (e.g. "ship a mobile app: bump versionCode, build the APK, push the in-app updater"; "safely run a destructive DB write"; "wire up a new ArgoCD app"). A normal pack (kind:"context", the default) is about ONE project/system and stays scoped to it. Decide by NATURE, not by repetition: if THIS turn demonstrated a self-contained method you would re-run on a DIFFERENT project, capture it as an ability the FIRST time you see it — do not wait for it to recur. Give an ability an ACTIVITY-oriented name, description, and tags (what you DO — the verbs, tools, and artifacts involved) so it can be found later from a different project by the work being done, not by a project name. Set "learned_on" to the project pack dir this turn worked on and list that same dir in "applied_on". If an ability below (marked ◆ability) already covers the method, do NOT duplicate it — UPDATE it and add the current project dir to "applied_on" so it visibly transfers.
118
125
 
119
126
  A LESSON is a single always-loaded rule (NOT topic-gated like packs/entities — it loads on every turn). Lessons exist for ONE purpose: to stop a recurring mistake. Propose a lesson ONLY when THIS turn shows the assistant MISSED something it should already have known — i.e. the user corrected the assistant ("no", "that's wrong", "actually it's X"), or signalled repetition ("again", "I keep telling you", "as I said", "I've told you before", "like I mentioned"). That friction is the proof that topic-matched memory failed, so the corrected fact must move to the always-on tier. No correction/repetition signal in the turn → NO lesson (return an empty lessons array). A lesson is the CORRECT fact written as one crisp imperative line, cross-cutting and durable (a mechanism, rule, or constraint that will matter on future, possibly off-topic turns) — never a one-off task detail or a fact with no miss behind it. Point "src" at the pack that should hold the full context if one fits. If an existing lesson below already covers it, reuse its EXACT wording so it reinforces rather than duplicating.
@@ -153,6 +160,7 @@ Decide. Rules:
153
160
  - State should be concise (under 150 words). Journal entries one sentence. Never start a journal or log sentence with a date — dates are prepended automatically.
154
161
  - At most ${MAX_ACTIONS} pack actions.
155
162
  - Entities: add an item when the turn revealed something durable about a specific named person, place, project, org, or system — their role, status, preferences, or relationship to other work. Use the existing entity name when one matches (check aliases). Skip entities mentioned only in passing with nothing learned. Notes under 100 words; "notes" null means leave Notes unchanged. At most ${MAX_ENTITY_ACTIONS} entity items.
163
+ - PERSONA (people only): when the turn revealed how to talk to a person or what they know, populate the persona fields — role/style/knows, and for non-owners a mandate (scope). Set relationship (owner|trusted|external) only when the turn makes it clear. Each persona field replaces its section wholesale; send only the ones this turn actually informed, null/omit the rest. Do NOT invent a mandate for the owner. Never copy a guardrail the owner set for one person onto another.
156
164
  - Packs and entities are independent — a turn can update both, either, or neither. Do not duplicate the same fact in a pack AND an entity unless it genuinely belongs to both.
157
165
  - Lessons are the rare case: at most ${MAX_LESSON_ACTIONS} per turn, and ONLY on a real correction/repetition signal as defined above. The default is an empty lessons array. When you do promote one, ALSO record the same fact in the appropriate pack (update/create) so the durable home stays authoritative — the lesson is just the always-on shortcut. Never put secrets in a lesson.
158
166
 
@@ -162,7 +170,7 @@ Reply with ONLY a JSON object, no prose, no code fences:
162
170
  | {"action": "create", "dir": "<kebab-slug>", "name": "<title>", "description": "<one line: when this pack is relevant>", "tags": ["..."], "kind": "context|ability", "learned_on": "<originating project dir — abilities only>", "applied_on": ["<project dirs — abilities only>"], "stance": "<or empty>", "procedure": "<or empty>", "state": "<where things stand>", "journal": "<one sentence>"}
163
171
  ],
164
172
  "entities": [
165
- {"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>"}
173
+ {"name": "<canonical name>", "type": "person|place|project|org|system|thing", "aliases": ["..."], "description": "<one line: who/what this is>", "notes": "<full new Notes or null>", "log": "<one sentence observation>", "relationship": "<owner|trusted|external, people only, only when clear, else null>", "role": "<people only, or null>", "style": "<people only: how to talk to them, or null>", "knows": "<people only: what to assume they know, or null>", "mandate": "<non-owner people only: what you may do/discuss on their behalf, or null>"}
166
174
  ],
167
175
  "lessons": [
168
176
  {"text": "<the correct fact as one crisp imperative line>", "src": "<pack dir that holds the full context, or empty>", "trigger": "<the exact correction/repetition phrase from the turn>"}
@@ -320,6 +328,13 @@ function applyEntityAction(e) {
320
328
  description: e.description,
321
329
  notes: typeof e.notes === "string" ? e.notes : "",
322
330
  log: e.log || "",
331
+ // Persona-pack fields (people only). Each replaces its section wholesale
332
+ // when a non-empty string is supplied; null/absent leaves it untouched.
333
+ relationship: typeof e.relationship === "string" ? e.relationship : "",
334
+ role: typeof e.role === "string" ? e.role : "",
335
+ style: typeof e.style === "string" ? e.style : "",
336
+ knows: typeof e.knows === "string" ? e.knows : "",
337
+ mandate: typeof e.mandate === "string" ? e.mandate : "",
323
338
  });
324
339
  return { kind: created ? "create" : "update", slug: entity.slug, name: entity.name, type: entity.type, note: e.log || e.description || "" };
325
340
  }
@@ -368,7 +383,17 @@ function shouldSkipReview({ userText, assistantText, signals = {} }) {
368
383
 
369
384
  // Fire-and-forget. `announce` is an async (text) => void bound to the
370
385
  // originating channel; failures are logged, never thrown into the turn.
371
- function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks }) {
386
+ // Provenance (2.5): stamp appended Log/Journal lines with the contributing
387
+ // person when they're NOT the owner. The owner is the default author, so
388
+ // tagging their contributions would be pure noise — the signal is precisely
389
+ // "this note came from someone else" (an external/trusted colleague), which the
390
+ // enforcer and the owner both want visible in the topic's history.
391
+ function provenanceTag(source) {
392
+ if (!source || source.isOwner || !String(source.name || "").trim()) return "";
393
+ return `(via ${String(source.name).trim()}) `;
394
+ }
395
+
396
+ function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks, source }) {
372
397
  if (!enabled()) return;
373
398
  const skip = shouldSkipReview({ userText, assistantText, signals });
374
399
  if (skip) {
@@ -391,8 +416,10 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
391
416
  if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
392
417
  const lines = [];
393
418
  const usedIds = []; // nodes this turn's work demonstrably touched
419
+ const tag = provenanceTag(source);
394
420
  for (const a of decision.actions) {
395
421
  try {
422
+ if (tag && typeof a.journal === "string" && a.journal.trim()) a.journal = tag + a.journal.trim();
396
423
  const r = applyAction(a, activeSet);
397
424
  if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
398
425
  if (r && r.kind === "skipped") {
@@ -414,6 +441,7 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
414
441
  }
415
442
  for (const ea of decision.entities) {
416
443
  try {
444
+ if (tag && typeof ea.log === "string" && ea.log.trim()) ea.log = tag + ea.log.trim();
417
445
  const r = applyEntityAction(ea);
418
446
  if (r) {
419
447
  if (r.slug) usedIds.push(`entity:${r.slug}`);
@@ -461,4 +489,4 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals, act
461
489
  });
462
490
  }
463
491
 
464
- module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, ENTITY_EMOJI };
492
+ module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, provenanceTag, ENTITY_EMOJI };
package/core/people.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const fs = require("fs");
12
12
  const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
13
13
  const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
14
+ const { slugify } = require("./entities");
14
15
 
15
16
  function nowIso() { return new Date().toISOString(); }
16
17
 
@@ -66,6 +67,7 @@ function add({ name, isOwner = false, bio = null }) {
66
67
  handles: [],
67
68
  notes: [],
68
69
  primaryChannel: null,
70
+ entitySlug: null,
69
71
  createdAt: nowIso(),
70
72
  updatedAt: nowIso(),
71
73
  };
@@ -195,6 +197,26 @@ function removeNote(personId, index) {
195
197
  return removed;
196
198
  }
197
199
 
200
+ // Bind a person to their persona pack (entities/<slug>.md). Explicit link so a
201
+ // person can point at a differently-named pack; setEntitySlug(id, null) clears.
202
+ function setEntitySlug(personId, slug) {
203
+ const all = load();
204
+ const p = all.find((x) => x.id === personId);
205
+ if (!p) return null;
206
+ p.entitySlug = slug ? slugify(slug) : null;
207
+ p.updatedAt = nowIso();
208
+ save(all);
209
+ return p;
210
+ }
211
+
212
+ // Which persona pack belongs to this person: the explicit pointer if set,
213
+ // otherwise the slug derived from their name. Pure — does not read the pack.
214
+ function resolveEntitySlug(person) {
215
+ if (!person) return null;
216
+ if (person.entitySlug) return person.entitySlug;
217
+ return person.name ? slugify(person.name) : null;
218
+ }
219
+
198
220
  function owners() { return load().filter((p) => p.isOwner); }
199
221
 
200
222
  function hasOwnerRecord() { return owners().length > 0; }
@@ -233,6 +255,7 @@ function roster() {
233
255
  bio: p.bio || null,
234
256
  handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
235
257
  primaryChannel: p.primaryChannel || null,
258
+ entitySlug: resolveEntitySlug(p),
236
259
  notes: (p.notes || []).slice(-5),
237
260
  }));
238
261
  }
@@ -243,5 +266,6 @@ module.exports = {
243
266
  findById, findByName, findByCanonicalUserId, findByHandle,
244
267
  linkHandle, unlinkHandle, setPrimary,
245
268
  note, removeNote,
269
+ setEntitySlug, resolveEntitySlug,
246
270
  owners, hasOwnerRecord, seedOwnerFromLegacy,
247
271
  };
@@ -100,6 +100,26 @@ const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLo
100
100
  // measured as THE dominant recall latency (p50 ~13.5s even warm).
101
101
  const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
102
102
  const EXCERPT_CHARS = 600;
103
+
104
+ // Relationship gate (R6): episodic recall reaches ACROSS conversations into the
105
+ // owner's project transcripts, which can hold private context. Surfacing that to
106
+ // a non-owner speaker is a cross-tenant leak, so cross-conversation episodes are
107
+ // OWNER-ONLY. Fail-closed: an unknown speaker or any resolution error suppresses
108
+ // episodes; only a positively-identified owner (or no speaker context at all,
109
+ // e.g. CLI/tests) may pull them. Same-conversation packs/entities are unaffected.
110
+ function episodesAllowedForSpeaker() {
111
+ try {
112
+ const { currentAdapter, currentChannelId } = require("../context");
113
+ const adapter = currentAdapter();
114
+ const channelId = currentChannelId();
115
+ if (!adapter || !channelId) return true; // no speaker context (CLI/tests) → ungated
116
+ const people = require("../people");
117
+ const speaker = people.findByHandle(adapter.type, channelId);
118
+ return !!(speaker && speaker.isOwner); // unknown/non-owner → gated
119
+ } catch (e) {
120
+ return false; // resolution error → fail closed
121
+ }
122
+ }
103
123
  const CONTEXT_CLIP = 3000;
104
124
  // Fraction of cascade auto-verdicts also sent to the judge as an accuracy
105
125
  // audit — divergence is logged and the dream disables the cascade if it drifts.
@@ -387,7 +407,7 @@ async function run(ctx) {
387
407
  // knowledge (packs) to episodes (what we actually did last time) — kept ones
388
408
  // inject as a snippet plus a pointer to reopen the conversation window.
389
409
  let episodeCands = [];
390
- if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
410
+ if (tier === "full" && EPISODES_ENABLED && episodesAllowedForSpeaker() && transcriptIndex && transcriptIndex.available()) {
391
411
  try {
392
412
  const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
393
413
  episodeCands = (hits || []).map((h) => {
@@ -582,4 +602,4 @@ async function run(ctx) {
582
602
  };
583
603
  }
584
604
 
585
- module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps };
605
+ module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
package/core/runner.js CHANGED
@@ -1598,6 +1598,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1598
1598
  ...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
1599
1599
  ])],
1600
1600
  signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
1601
+ // Provenance (2.5): who contributed this turn, so accreted Log/Journal
1602
+ // lines from a non-owner get stamped "(via <name>)". Resolved from the
1603
+ // live store here because the reviewer applies in an async continuation.
1604
+ source: (() => {
1605
+ try {
1606
+ const sp = require("./people").findByHandle(store.adapter?.type, store.channelId);
1607
+ if (sp) return { name: sp.name, isOwner: !!sp.isOwner };
1608
+ } catch (e) {}
1609
+ return null;
1610
+ })(),
1601
1611
  announce: (text) => chatContext.run(store, () => send(text)),
1602
1612
  });
1603
1613
  }
@@ -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.11.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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.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",
@@ -53,7 +53,11 @@
53
53
  "test-approval-async.js",
54
54
  "test-recall-evolution.js",
55
55
  "test-unified-identity.js",
56
- "test-queue-routing.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"
57
61
  ],
58
62
  "keywords": [
59
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,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");
@@ -0,0 +1,89 @@
1
+ // Speaker persona injection: the deterministic per-turn block that primes the
2
+ // bot on WHO it's talking to (their persona pack) so it adapts voice + scope.
3
+ // Rides the uncached tail (R13). These tests pin: the block renders the
4
+ // speaker's Role/Style/Knows, suppresses an OWNER's Mandate but shows a
5
+ // non-owner's, dedupes within a session, and is silent when there's no persona.
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(), "speaker-persona-"));
13
+ process.env.OPEN_CLAUDIA_TEST = "1";
14
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
15
+ process.env.PEOPLE_FILE = path.join(tmp, "people.json");
16
+
17
+ const people = require("./core/people");
18
+ const entities = require("./core/entities");
19
+ const { runInChat } = require("./core/context");
20
+ const { buildSpeakerPersonaBlock } = require("./core/system-prompt");
21
+
22
+ const adapter = { id: "telegram", type: "telegram" };
23
+ const CH_OWNER = "111";
24
+ const CH_EXT = "222";
25
+ const CH_BARE = "333";
26
+
27
+ // ── seed an owner with a persona pack (Mandate present but must be hidden) ──
28
+ const owner = people.add({ name: "Ada Owner", isOwner: true });
29
+ people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER });
30
+ entities.upsertEntity({
31
+ name: "Ada Owner", type: "person", relationship: "owner",
32
+ role: "Founder and primary operator.",
33
+ style: "Terse and technical. No hand-holding.",
34
+ knows: "Fluent in the codebase, kubernetes, gitops.",
35
+ mandate: "Full trust — no guardrails apply.",
36
+ });
37
+
38
+ // ── seed an external contact with a Mandate that MUST surface ──
39
+ const ext = people.add({ name: "Zephyr Contact" });
40
+ people.linkHandle(ext.id, { adapter: "telegram", channelId: CH_EXT });
41
+ entities.upsertEntity({
42
+ name: "Zephyr Contact", type: "person", relationship: "external",
43
+ style: "Friendly but guarded.",
44
+ mandate: "May only discuss invoicing status. Never mention internal systems.",
45
+ });
46
+
47
+ // ── a person with a handle but NO persona pack → silent ──
48
+ const bare = people.add({ name: "Bare Person" });
49
+ people.linkHandle(bare.id, { adapter: "telegram", channelId: CH_BARE });
50
+
51
+ const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
52
+
53
+ // Owner: Role/Style/Knows shown, Mandate suppressed (owner has no guardrails).
54
+ const ownerBlock = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
55
+ assert.ok(ownerBlock.includes("Ada Owner"), "owner block names the speaker");
56
+ assert.ok(ownerBlock.includes("(owner)"), "owner block shows relationship");
57
+ assert.ok(ownerBlock.includes("Founder and primary operator."), "owner Role shown");
58
+ assert.ok(ownerBlock.includes("Terse and technical"), "owner Style shown");
59
+ assert.ok(ownerBlock.includes("Fluent in the codebase"), "owner Knows shown");
60
+ assert.ok(!ownerBlock.includes("Full trust"), "owner Mandate is NOT injected (no guardrails on owner)");
61
+ assert.ok(!/Mandate/.test(ownerBlock), "no Mandate row for the owner at all");
62
+
63
+ // External: Style shown AND Mandate surfaced (it scopes the turn).
64
+ const extBlock = inChat(CH_EXT, () => buildSpeakerPersonaBlock());
65
+ assert.ok(extBlock.includes("Zephyr Contact"), "external block names the speaker");
66
+ assert.ok(extBlock.includes("(external)"), "external relationship shown");
67
+ assert.ok(extBlock.includes("Friendly but guarded."), "external Style shown");
68
+ assert.ok(extBlock.includes("May only discuss invoicing status"), "external Mandate IS injected");
69
+
70
+ // Bare person (handle, no pack): nothing to inject.
71
+ const bareBlock = inChat(CH_BARE, () => buildSpeakerPersonaBlock());
72
+ assert.strictEqual(bareBlock, "", "no persona pack → empty block");
73
+
74
+ // Unknown channel (no person): silent.
75
+ const strangerBlock = inChat("999", () => buildSpeakerPersonaBlock());
76
+ assert.strictEqual(strangerBlock, "", "unknown speaker → empty block");
77
+
78
+ // ── dedupe: within one session the same speaker is injected once ──
79
+ // (First call above already stamped CH_OWNER for session "new"; the repeat
80
+ // must come back empty so we don't re-bill the tail every turn.)
81
+ const ownerAgain = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
82
+ assert.strictEqual(ownerAgain, "", "same speaker re-injected within a session is deduped");
83
+
84
+ // ── a persona edit (new `updated` stamp) re-injects even in the same session ──
85
+ entities.upsertEntity({ name: "Ada Owner", style: "Even terser now." });
86
+ const ownerAfterEdit = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
87
+ assert.ok(ownerAfterEdit.includes("Even terser now."), "a persona edit re-injects the fresh block");
88
+
89
+ console.log("speaker persona OK");