@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
13
+ ## v2.11.0
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.
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`.
16
+ - **Cross-channel dedup (0.3).** `router.isDuplicate` is re-keyed from `adapter:channel:msg` to `canonical:adapter:msg` — a message redelivered against a different channel that resolves to the same person is now caught once the owner's channels are unified (adapter stays in the key, so distinct messages that merely share a numeric id across transports are never collapsed).
17
+ - **First-contact migration (0.2 / 1.1) fires automatically.** With the flag on, the first inbound message from a previously-siloed owner channel folds its pre-existing state + sessions into the owner bucket — non-destructive, idempotent, and backed up to `~/.open-claudia/backups/` first (unchanged from v2.10.0, now actually reached). Pre-migration per-channel transcripts remain as separate files and stay reachable via `transcript-search --all`; new history accretes under the unified person id (1.3).
18
+ - **Tests.** `test-unified-identity.js` gains a `default` (unset ⇒ on) child to lock the new default and proves an explicit `0` still reverts to strict per-channel resolution; new `test-queue-routing.js` covers origin keying, same-origin batching, mixed-origin split/requeue, and legacy (pre-capture) fallback. Full suite green.
19
+ - **Agent-space / OpenClaw:** no new deps or required env; Docker image builds identically. Existing single-owner deployments see unified behaviour for the owner's own channels only — strangers still resolve to their own isolated bucket (the per-person guardrail arrives in Phase 3).
20
+
3
21
  ## v2.10.0
4
22
  - **Unified multi-channel identity — foundation, gated OFF by default (Phase 0).** An owned bot is meant to be one mind across every channel its owner reaches it on; today it split-brains because conversation state (session, transcript, run-lock, queue) is keyed per `transport:channelId`, so an unlinked channel (voice bridge, a stray Kazee DM) becomes a separate assistant with its own history. This release lands the identity-resolution foundation behind a single flag, `OC_UNIFIED_IDENTITY` (default **off**). **With the flag off nothing changes** — every new code path is gated, so publishing this version is behaviourally identical to v2.9.3 until an operator sets `OC_UNIFIED_IDENTITY=1` and restarts. Boot log now prints `Unified identity: on|off` for visibility.
5
23
  - **Resolution seam (0.1).** `identity.canonicalForChannel` — the single chokepoint every adapter (Telegram/Kazee/voice) routes through — gains a flag-gated branch: a *configured owner channel* (one whose `transport:channelId` matches an operator-declared owner id — a `TELEGRAM_CHAT_ID`, `KAZEE_OWNER_USER_ID`, or `VOICE_OWNER_USER_ID`) with no explicit `/link` now resolves to the single owner canonical (the owner's first-linked handle, matching `people.linkHandle`'s primary convention). Because it's the one seam, state/transcript/scheduler all unify downstream for free. Owner detection uses only trusted, operator-set signals — never a guess — so a stranger's channel can never be merged by mistake (verified: a non-owner channel still resolves to its own default bucket).
package/core/config.js CHANGED
@@ -94,11 +94,12 @@ const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL ||
94
94
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
95
95
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
96
96
  const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
97
- // Unified multi-channel identity (Phase 0). Default OFF: when unset the bot
98
- // behaves exactly as before every code path added for unification is gated
99
- // on this flag, so publishing is behaviourally inert until an operator flips
100
- // OC_UNIFIED_IDENTITY=1 in .env and restarts.
101
- const UNIFIED_IDENTITY = configTruthy(config.OC_UNIFIED_IDENTITY || process.env.OC_UNIFIED_IDENTITY, false);
97
+ // Unified multi-channel identity. Default ON: the owner's Telegram, Kazee and
98
+ // voice channels resolve to one canonical person, sharing one state, session,
99
+ // transcript and run-lock (the split-brain fix). The flag remains as an
100
+ // operator kill-switch for reversibility (R14) — set OC_UNIFIED_IDENTITY=0 in
101
+ // .env and restart to fall back to strict per-channel behaviour.
102
+ const UNIFIED_IDENTITY = configTruthy(config.OC_UNIFIED_IDENTITY || process.env.OC_UNIFIED_IDENTITY, true);
102
103
  const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
103
104
  const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
104
105
  const WHISPER_CLI = config.WHISPER_CLI || "";
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
  };
@@ -0,0 +1,25 @@
1
+ // Pure helpers for origin-aware queue draining. Kept separate from the runner
2
+ // so the grouping logic — which decides where a drained reply is routed under
3
+ // unified identity — is unit-testable without spawning a run.
4
+
5
+ // A queued message's origin channel, or a sentinel for legacy items that were
6
+ // queued before origin capture existed (those fall back to the finishing turn's
7
+ // context, exactly as before).
8
+ function originKey(item) {
9
+ return item && item.origin ? `${item.origin.transport}:${item.origin.channelId}` : "__current__";
10
+ }
11
+
12
+ // Split the leading run of same-origin messages off the front of the queue.
13
+ // Returns { group, rest }: `group` is the contiguous same-origin prefix (to run
14
+ // now, scoped to that origin), `rest` is everything after it (to be requeued so
15
+ // the natural close→drain tail-recursion handles the next origin group). For a
16
+ // non-empty queue, `group` always has at least one item.
17
+ function splitLeadingOriginGroup(queue) {
18
+ if (!Array.isArray(queue) || queue.length === 0) return { group: [], rest: [] };
19
+ const firstKey = originKey(queue[0]);
20
+ let i = 0;
21
+ while (i < queue.length && originKey(queue[i]) === firstKey) i++;
22
+ return { group: queue.slice(0, i), rest: queue.slice(i) };
23
+ }
24
+
25
+ module.exports = { originKey, splitLeadingOriginGroup };
@@ -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/router.js CHANGED
@@ -26,11 +26,14 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
26
26
  const channelWizard = require("./channel-wizard");
27
27
  require("./handlers"); // side-effect: register slash commands
28
28
 
29
- // Per-adapter dedup. Telegram message_ids are unique per chat, not
30
- // globally; same goes for Kazee. Namespace by adapter:channel:msg.
29
+ // Cross-channel dedup. Message ids are unique per adapter, not globally, so
30
+ // the adapter stays in the key. Keying by canonical (not raw channelId) means
31
+ // a message redelivered against a different channel that resolves to the same
32
+ // person is still caught once the owner's channels are unified.
31
33
  const processedMessages = new Set();
32
34
  function isDuplicate(envelope) {
33
- const key = `${envelope.adapter?.id || envelope.adapter?.type}:${envelope.channelId}:${envelope.messageId}`;
35
+ const canonical = envelope.canonicalUserId || envelope.channelId;
36
+ const key = `${canonical}:${envelope.adapter?.id || envelope.adapter?.type}:${envelope.messageId}`;
34
37
  if (processedMessages.has(key)) return true;
35
38
  processedMessages.add(key);
36
39
  if (processedMessages.size > 400) {
package/core/runner.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  } = require("./config");
14
14
  const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
15
15
  const { chatContext, currentChannelId, currentAdapter } = require("./context");
16
+ const { splitLeadingOriginGroup } = require("./queue-drain");
16
17
  const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
17
18
  const { redactSensitive } = require("./redact");
18
19
  const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
@@ -782,7 +783,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
782
783
  cwd = resolveRunCwd(cwd);
783
784
 
784
785
  if (state.runningProcess) {
785
- state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now() });
786
+ // Capture the origin channel so the drained reply routes back to where the
787
+ // message came from. Under unified identity the owner's channels share one
788
+ // run-lock and queue, so a Kazee message queued during a Telegram run must
789
+ // still be answered on Kazee — not wherever the finishing turn happens to be.
790
+ state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now(), origin: store ? { ...store } : null });
786
791
  await send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId });
787
792
  return;
788
793
  }
@@ -1132,10 +1137,19 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1132
1137
  };
1133
1138
 
1134
1139
  const drainQueuedMessages = async () => {
1135
- if (state.messageQueue.length > 0 && state.currentSession) {
1136
- const drained = state.messageQueue.splice(0);
1137
- if (drained.length === 1) {
1138
- const only = drained[0];
1140
+ if (state.messageQueue.length === 0 || !state.currentSession) return;
1141
+ const drained = state.messageQueue.splice(0);
1142
+ // Handle one same-origin run at a time; requeue the remainder so the
1143
+ // finishing turn's close→drain tail-recursion picks up the next origin
1144
+ // group. Each group's reply is scoped to the channel it came from, so a
1145
+ // message queued from one of the owner's channels during a run on another
1146
+ // is still answered on its own channel (unified identity, one run-lock).
1147
+ const { group, rest } = splitLeadingOriginGroup(drained);
1148
+ if (rest.length) state.messageQueue.unshift(...rest);
1149
+
1150
+ const runGroup = async () => {
1151
+ if (group.length === 1) {
1152
+ const only = group[0];
1139
1153
  await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1140
1154
  } else {
1141
1155
  const fmtTime = (ts) => {
@@ -1145,16 +1159,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1145
1159
  const ss = String(d.getSeconds()).padStart(2, "0");
1146
1160
  return `${hh}:${mm}:${ss}`;
1147
1161
  };
1148
- const numbered = drained.map((m, i) => `[${i + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1162
+ const numbered = group.map((m, idx) => `[${idx + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1149
1163
  const batched =
1150
- `While you were working the user sent these ${drained.length} follow-up messages (oldest first):\n\n` +
1164
+ `While you were working the user sent these ${group.length} follow-up messages (oldest first):\n\n` +
1151
1165
  `${numbered}\n\n` +
1152
1166
  `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1153
1167
  `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1154
- const last = drained[drained.length - 1];
1168
+ const last = group[group.length - 1];
1155
1169
  await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
1156
1170
  }
1157
- }
1171
+ };
1172
+
1173
+ const origin = group[0].origin;
1174
+ if (origin) await chatContext.run(origin, runGroup);
1175
+ else await runGroup();
1158
1176
  };
1159
1177
 
1160
1178
  const updateProgress = async () => {
@@ -1580,6 +1598,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1580
1598
  ...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
1581
1599
  ])],
1582
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
+ })(),
1583
1611
  announce: (text) => chatContext.run(store, () => send(text)),
1584
1612
  });
1585
1613
  }