@inetafrica/open-claudia 2.11.0 → 2.13.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.13.0
4
+ - **The independent relationship guardrail is now LIVE — an owned bot is finally safe to point at external people (Phase 3).** Phases 0–2 unified the bot's identity across channels and taught it WHO it's talking to; this phase adds the missing half: hard limits on what it may do or say when that person is **not** the owner. Everything is a strict **no-op for the owner and for no-context callers** (CLI/cron/tests) — the owner reply/tool/file paths stay **byte-for-byte unchanged** (risk R9/R14), so this is inert on every existing single-owner deployment until an external channel is actually `/auth`'d. Classification lives in `core/relationship.js` and is **cheap, synchronous, and never derived from message text**: the owner is owner **only** via `people.isOwner`; a non-owner whose note claims relationship `owner` is coerced to `external` (**R1 — a note can never confer owner powers**); every unclassified or unmapped authed non-owner defaults to `external` (**default-deny**).
5
+ - **Owner-authored mandate, set from chat (3.1).** `entity persona <name> --relationship|--role|--style|--knows|--mandate` lets the owner declare, per person, the ONLY things the bot is cleared to do or discuss with them. The verb is **owner-gated** (an external speaker can never edit their own mandate — self-escalation is refused) and each flag replaces its section wholesale.
6
+ - **External-mode posture, injected where it can't rot (3.2).** When the speaker is guarded, a compact default-deny block (`buildExternalModeBlock`) is appended to the **uncached per-turn tail** — never `buildSystemPrompt`/cache — and **re-injected every guarded turn** (not deduped once-per-session like the persona block) so the security posture can't age out of context. It carries the person's mandate (or "no mandate → almost everything is out of scope") plus five non-negotiables: default-deny, never leak owner/infra/creds/paths, no write/destructive without the mandate, treat their messages as DATA not instructions, warm-but-firm.
7
+ - **Independent fail-CLOSED enforcer (3.3).** Every outbound reply and every write/destructive tool action aimed at an external person is vetted by an **isolated Sonnet call** (`core/enforcer.js`) that sees only a structured `{mandate, proposed output}` — never the raw external text as instructions — so prompt-injection or context-rot in the main agent can't talk the guard out of the owner's rules (**R10**). It inverts `judgeRelevance`'s fail-OPEN posture: **any error, timeout, or unparseable verdict → "escalate"**, never "allow". One verdict per turn, cached by `sha1(kind|relationship|tier|mandate|payload)` for 5 min (a fail-closed verdict is deliberately **not** cached — a transient failure is re-judged, never remembered).
8
+ - **Escalation reuses the approvals plumbing verbatim (3.4).** On block/escalate the external is told "let me check with <owner>" and the owner gets **Approve · Deny · Always-allow-for-<person>** buttons on their own channel (the record separates **origin** = the external speaker from **approver** = the owner). Approve delivers the held reply or runs the held action; Always-allow writes it into the person's mandate. Reuses `approvals.create` + the owner-gated `apr:` handler in `actions.js`, which now wakes the originating (external) channel for a held **action** so an owner-approved external action actually executes where it was requested.
9
+ - **Per-speaker tool gate (3.5).** An external write/destructive `tool run` is vetted against the mandate before it can execute: **vet=allow means the owner's mandate IS the pre-authorization** (bypasses the `--yes` risk flag, stamped `approvedVia:"mandate"`); anything else escalates to the owner. Externals never reach the loopback `approval-request` path (which would post buttons to the requester's own channel) — that path is now **owner-only guarded** as defence-in-depth, and a fresh `--approval` token still bypasses the vet.
10
+ - **Egress gates (R8).** File/photo/voice sends to a guarded speaker are blocked at the true boundary (`loopback.handleSend`, where the send-* CLIs POST), with a cheap defensive gate in `io.sendFile` too — an unvetted file must never egress to an external contact.
11
+ - **Tests.** Two new hermetic suites wired into `npm test`: `test-relationship.js` (owner/external/trusted classification, R1 owner-coercion, default-deny for unmapped/no-pack, no-context ungated, `ownerTarget` picks the primary handle) and `test-enforcer.js` (owner no-op with zero guard calls, allow/block→escalate, fail-closed-and-not-cached, identical-vet caching, escalation record shape for reply + action, read-only plan-mode sub-agent). Full suite green.
12
+ - **Agent-space / OpenClaw:** no new deps, no new **required** env; Docker image builds identically. The guard model defaults to `sonnet` (override `ENFORCER_MODEL`) and can be disabled with `ENFORCER=off`. Inert until an external channel is authed AND has persona/mandate data — existing owner-only deployments are unaffected.
13
+
14
+ ## v2.12.0
15
+ - **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.
16
+ - **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).
17
+ - **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()`.
18
+ - **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).
19
+ - **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.
20
+ - **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`.
21
+ - **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.
22
+ - **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.
23
+
3
24
  ## v2.11.0
4
25
  - **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
26
  - **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/bin/entity.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // open-claudia entity show <slug-or-name>
4
4
  // open-claudia entity match "<text>" — debug the router
5
5
  // open-claudia entity note <name> "<text>" [--type person|place|project|org|system]
6
+ // open-claudia entity persona <name> [--relationship owner|trusted|external] [--role ..] [--style ..] [--knows ..] [--mandate ..]
6
7
  // open-claudia entity remove <slug-or-name>
7
8
  // open-claudia entity reindex
8
9
 
@@ -57,6 +58,66 @@ function run(args) {
57
58
  break;
58
59
  }
59
60
 
61
+ case "persona": {
62
+ // Owner-set relationship + persona/mandate for a person entity. The
63
+ // Mandate is what the external guardrail enforces: the ONLY things the bot
64
+ // is cleared to do/discuss with that person. Each flag replaces its section
65
+ // wholesale; omit a flag to leave it unchanged.
66
+ //
67
+ // OWNER-ONLY: setting a mandate/relationship is a privilege operation, so
68
+ // an external speaker must never reach it (self-escalation). No guarded
69
+ // speaker (owner turn, CLI, or cron) may proceed. Note: relationship
70
+ // "owner" set here is still coerced to "external" at runtime for a non-
71
+ // owner person (R1) — a note can never confer owner powers.
72
+ try {
73
+ const relationship = require("../core/relationship");
74
+ const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID);
75
+ if (relationship.guardActive(sp)) {
76
+ console.error("⛔ Only the owner can set a persona/mandate.");
77
+ process.exitCode = 3; return;
78
+ }
79
+ } catch (e) { /* no context → owner-equivalent, allowed */ }
80
+
81
+ const takeFlag = (flag) => {
82
+ const i = rest.indexOf(flag);
83
+ if (i === -1) return null;
84
+ const v = rest[i + 1];
85
+ rest.splice(i, 2);
86
+ return v == null ? "" : String(v);
87
+ };
88
+ const relationship = takeFlag("--relationship");
89
+ const role = takeFlag("--role");
90
+ const style = takeFlag("--style");
91
+ const knows = takeFlag("--knows");
92
+ const mandate = takeFlag("--mandate");
93
+ const type = takeFlag("--type");
94
+ const name = rest.join(" ").trim();
95
+ if (!name) {
96
+ console.error('Usage: entity persona <name> [--relationship owner|trusted|external] [--role "..."] [--style "..."] [--knows "..."] [--mandate "..."] [--type person]');
97
+ process.exitCode = 1; return;
98
+ }
99
+ if (!relationship && !role && !style && !knows && !mandate) {
100
+ console.error("Nothing to set — pass at least one of --relationship / --role / --style / --knows / --mandate.");
101
+ console.error("(To CLEAR a section, edit the file directly: open-claudia entity show " + name + ")");
102
+ process.exitCode = 1; return;
103
+ }
104
+ const { entity, created } = entities.upsertEntity({
105
+ name, type: type || "person",
106
+ relationship: relationship || undefined,
107
+ role: role || undefined,
108
+ style: style || undefined,
109
+ knows: knows || undefined,
110
+ mandate: mandate || undefined,
111
+ });
112
+ console.log(`${created ? "Created" : "Updated"} persona for ${entity.slug} (${entity.type}${entity.relationship ? `, ${entity.relationship}` : ""}).`);
113
+ const s = entity.sections || {};
114
+ if (s.Role) console.log(` Role: ${s.Role}`);
115
+ if (s.Style) console.log(` Style: ${s.Style}`);
116
+ if (s.Knows) console.log(` Knows: ${s.Knows}`);
117
+ if (s.Mandate) console.log(` Mandate: ${s.Mandate}`);
118
+ break;
119
+ }
120
+
60
121
  case "remove": {
61
122
  const removed = entities.removeEntity(rest.join(" "));
62
123
  if (!removed) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
@@ -70,7 +131,7 @@ function run(args) {
70
131
  }
71
132
 
72
133
  default:
73
- console.log("Usage: open-claudia entity [list|show <slug>|match \"<text>\"|note <name> \"<text>\"|remove <slug>|reindex]");
134
+ console.log("Usage: open-claudia entity [list|show <slug>|match \"<text>\"|note <name> \"<text>\"|persona <name> [--relationship|--role|--style|--knows|--mandate ...]|remove <slug>|reindex]");
74
135
  }
75
136
  }
76
137
 
package/bin/tool.js CHANGED
@@ -374,6 +374,47 @@ async function run(args) {
374
374
  const commandLine = ["open-claudia tool run", t.name, ...toolArgs].join(" ");
375
375
  let approvedVia = "";
376
376
 
377
+ // ── Phase 3.5: per-speaker external gate ──────────────────────────────
378
+ // When this run is FOR an external (non-owner) speaker, a write/destructive
379
+ // tool cannot rely on the agent's own --yes flag — the external could have
380
+ // talked it into passing it. Route mutating runs through the independent
381
+ // mandate vet: an action the owner-authored mandate clearly covers carries
382
+ // the owner's authority and runs; anything else is HELD and the owner is
383
+ // asked (Approve/Deny/Always-allow), then re-run via a one-shot --approval
384
+ // token. A token already present means the owner just approved → skip.
385
+ // Owner / no-context speakers are never gated here, so the legacy owner
386
+ // path is byte-for-byte unchanged (R9/R14).
387
+ if (!approvalId && (gate.tier === "write" || gate.tier === "destructive")) {
388
+ let speaker = null;
389
+ try {
390
+ const relationship = require("../core/relationship");
391
+ const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID);
392
+ if (relationship.guardActive(sp)) speaker = sp; // else owner/no-context → ungated
393
+ } catch (e) { speaker = null; }
394
+ if (speaker) {
395
+ const enforcer = require("../core/enforcer");
396
+ let vet;
397
+ try { vet = await enforcer.vetAction(speaker, { command: commandLine, tier: gate.tier }); }
398
+ catch (e) { vet = { decision: "escalate", reason: "vet error — failing closed" }; }
399
+ if (vet.decision === "allow") {
400
+ // The owner's mandate pre-authorizes this class of action → it carries
401
+ // the owner's approval, satisfying the risk gate without a redundant --yes.
402
+ approvedVia = "mandate";
403
+ } else {
404
+ const esc = await enforcer.escalate({
405
+ speaker, kind: "action", command: commandLine, tier: gate.tier, reason: vet.reason,
406
+ });
407
+ if (esc.escalated) {
408
+ console.error(`🛡️ Held for owner approval — ${speaker.name || "the external contact"} is outside mandate for this ${gate.tier} action.`);
409
+ console.error(`Approval ${esc.id} was raised to the owner. End the turn and tell them you're checking; a wakeup fires when they decide. Do NOT retry or re-request.`);
410
+ process.exitCode = 5; return;
411
+ }
412
+ console.error(`⛔ Blocked — ${speaker.name || "the external contact"} is outside mandate and the owner could not be reached to approve.`);
413
+ process.exitCode = 3; return;
414
+ }
415
+ }
416
+ }
417
+
377
418
  if (approvalId) {
378
419
  const approvals = require("../core/approvals");
379
420
  if (String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim()) {
@@ -387,7 +428,7 @@ async function run(args) {
387
428
  }
388
429
  approvedVia = "chat-approval";
389
430
  console.error(`✅ Approval ${approvalId} redeemed (single use) — running.`);
390
- } else if (!gate.ok) {
431
+ } else if (!gate.ok && approvedVia !== "mandate") {
391
432
  // Chat-approval fallback: an unflagged destructive top-level run inside
392
433
  // a bot task escalates to the requesting channel with the EXACT command
393
434
  // and inline Approve/Deny. Fast path: block ~90s for an inline decision.
@@ -447,7 +488,9 @@ async function run(args) {
447
488
  const parentStack = String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim();
448
489
  env.OPEN_CLAUDIA_APPROVED_TIER = parentStack
449
490
  ? (tools.normalizeRisk(process.env.OPEN_CLAUDIA_APPROVED_TIER) || "read-only")
450
- : (approvedVia === "chat-approval" ? "destructive" : tools.approvedTierFromArgv(toolArgs));
491
+ : (approvedVia === "chat-approval" ? "destructive"
492
+ : approvedVia === "mandate" ? gate.tier
493
+ : tools.approvedTierFromArgv(toolArgs));
451
494
  env.OPEN_CLAUDIA_TOOL_STACK = parentStack ? `${parentStack}>${t.name}` : t.name;
452
495
  const started = Date.now();
453
496
  const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env });
package/core/actions.js CHANGED
@@ -23,6 +23,26 @@ const {
23
23
  getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
24
24
  } = require("./auth-flow");
25
25
 
26
+ // Append an owner "always-allow" grant to a person's persona-pack Mandate so
27
+ // the guardrail won't escalate the same thing again. Wholesale-writes the
28
+ // combined mandate (upsert replaces the section); relationship is preserved.
29
+ function appendMandate(rec, line) {
30
+ const entities = require("./entities");
31
+ const ent = rec.entitySlug ? entities.readEntity(rec.entitySlug) : null;
32
+ const name = (ent && ent.name) || rec.personName;
33
+ if (!name) return false;
34
+ const cur = (ent && ent.sections && ent.sections.Mandate) || "";
35
+ const addition = `- Always-allowed by owner: ${String(line || "").trim()}`.trim();
36
+ const mandate = cur ? `${cur}\n${addition}` : addition;
37
+ entities.upsertEntity({
38
+ name,
39
+ relationship: (ent && ent.relationship) || "external",
40
+ mandate,
41
+ log: "owner always-allowed a guarded request",
42
+ });
43
+ return true;
44
+ }
45
+
26
46
  async function handleAction(envelope) {
27
47
  const adapter = envelope.adapter;
28
48
  const d = envelope.action?.payload || envelope.action?.buttonId || "";
@@ -298,33 +318,80 @@ async function handleAction(envelope) {
298
318
  return;
299
319
  }
300
320
  if (d.startsWith("apr:")) {
301
- // Destructive tool-run approval buttons. Owner-only: the exact payload was
302
- // shown in the prompt; decide() is idempotent so double-taps can't flip it.
303
- if (!isChatOwner(envelope.channelId)) return send("Owner only tool approvals are restricted.");
321
+ // Approval buttons. Owner-only: the exact payload was shown in the prompt;
322
+ // decide() is idempotent so double-taps can't flip it. Two shapes share
323
+ // this handler: (a) destructive TOOL runs (owner approving a command, which
324
+ // re-runs via a one-shot token), and (b) Phase 3 external-relationship
325
+ // ESCALATIONS (a held reply/action to an external — approve delivers it,
326
+ // Always-allow additionally writes the person's mandate).
327
+ if (!isChatOwner(envelope.channelId)) return send("Owner only — approvals are restricted.");
304
328
  const parts = d.split(":");
305
329
  const id = parts[1];
306
- const choice = parts[2] === "ok" ? "approved" : "denied";
330
+ const always = parts[2] === "always";
331
+ const choice = (parts[2] === "ok" || always) ? "approved" : "denied";
307
332
  const approvals = require("./approvals");
308
333
  const wasPending = approvals.read(id)?.status === "pending";
309
334
  const rec = approvals.decide(id, choice, String(envelope.channelId));
310
- if (!rec) return send("That approval request has expired or was cleaned up — the run stays blocked.");
335
+ if (!rec) return send("That approval request has expired or was cleaned up — it stays blocked.");
336
+
337
+ // (b) External-relationship escalation for a held REPLY: on approve, deliver
338
+ // the vetted text to the external directly (no agent re-run needed); on
339
+ // Always-allow, also fold it into that person's mandate so it won't ask
340
+ // again. On deny, hold it and tell no one but the owner.
341
+ if (rec.kind === "reply") {
342
+ if (rec.status !== "approved") {
343
+ return send(`⛔ Held — the reply to ${rec.personName || "the external contact"} was not sent.`);
344
+ }
345
+ let delivered = false;
346
+ try {
347
+ await require("./relay").send({
348
+ text: rec.payloadText,
349
+ target: { adapter: rec.targetAdapter, channelId: rec.targetChannel },
350
+ from: { name: "owner-approved" }, kind: "escalation-approved",
351
+ });
352
+ delivered = true;
353
+ } catch (e) {
354
+ return send(`Approved, but delivery failed: ${e.message}`);
355
+ }
356
+ if (always && wasPending) {
357
+ try { appendMandate(rec, `reply about: ${String(rec.payloadText || "").replace(/\s+/g, " ").slice(0, 120)}`); } catch (e) {}
358
+ }
359
+ return send(delivered
360
+ ? `✅ Sent to ${rec.personName || "the contact"}${always ? " · added to their mandate" : ""}.`
361
+ : `Could not deliver the reply.`);
362
+ }
363
+
311
364
  // If a second press disagreed, rec.status still holds the FIRST decision.
312
365
  const label = `${rec.tool}${rec.verb ? " " + rec.verb : ""}`;
313
- if (rec.detached && wasPending) {
314
- // The CLI gave up its fast-path wait and exited — no live turn is
315
- // listening. Wake the agent so it can redeem (or acknowledge) the
316
- // decision. The one-shot token in consume() keeps this fail-closed.
366
+ // Always-allow on an external ACTION escalation folds the command into the
367
+ // person's mandate before the run proceeds.
368
+ if (always && rec.status === "approved" && wasPending && rec.entitySlug) {
369
+ try { appendMandate(rec, `action: ${String(rec.command || "").slice(0, 160)}`); } catch (e) {}
370
+ }
371
+ // Two re-run cases wake the agent: (a) a legacy destructive owner-self run
372
+ // whose CLI detached, and (b) a Phase 3 external-ACTION escalation (the tool
373
+ // CLI escalated and exited immediately, so it's never "detached" but always
374
+ // needs a re-run). For (b) the agent must resume the EXTERNAL's conversation
375
+ // — the origin channel — not the owner's, so the completed action lands where
376
+ // the request came from.
377
+ const isExternalAction = rec.kind === "action";
378
+ if ((rec.detached || isExternalAction) && wasPending) {
379
+ // No live turn is listening. Wake the agent so it can redeem (or
380
+ // acknowledge) the decision. The one-shot token in consume() keeps this
381
+ // fail-closed.
317
382
  const prompt = rec.status === "approved"
318
- ? `[Tool approval ${rec.id}] The user APPROVED the pending destructive run:\n${rec.command}\n` +
383
+ ? `[Tool approval ${rec.id}] The owner APPROVED the held ${rec.tier || "destructive"} run${isExternalAction ? ` requested by ${rec.personName || "an external contact"}` : ""}:\n${rec.command}\n` +
319
384
  `Run it now EXACTLY as:\n${rec.command} --approval ${rec.id}\n` +
320
- `The token is single-use and bound to that exact command — do not alter it. Report the result to the user.`
321
- : `[Tool approval ${rec.id}] The user DENIED the pending destructive run:\n${rec.command}\n` +
322
- `Do NOT run it. Acknowledge the denial to the user and adjust course.`;
385
+ `The token is single-use and bound to that exact command — do not alter it. Report the result${isExternalAction ? " to them" : " to the user"}.`
386
+ : `[Tool approval ${rec.id}] The owner DENIED the held run${isExternalAction ? ` requested by ${rec.personName || "an external contact"}` : ""}:\n${rec.command}\n` +
387
+ `Do NOT run it. ${isExternalAction ? "Let them know you can't do that one and move on." : "Acknowledge the denial to the user and adjust course."}`;
323
388
  try {
324
389
  scheduler.addJob({
325
390
  kind: "wakeup",
326
- adapter: rec.adapter || (adapter && adapter.id) || "",
327
- adapterType: adapter && adapter.type,
391
+ // External escalations carry the origin as an adapter TYPE string
392
+ // (resolved by type); legacy runs carry the requester's adapter id.
393
+ adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
394
+ adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
328
395
  channelId: String(rec.channelId || envelope.channelId),
329
396
  prompt,
330
397
  label: `Tool approval ${rec.status}: ${label}`.slice(0, 60),
@@ -335,7 +402,7 @@ async function handleAction(envelope) {
335
402
  return send(`Decision recorded (${rec.status}) but I couldn't wake the agent: ${e.message}`);
336
403
  }
337
404
  await send(rec.status === "approved"
338
- ? `✅ Approved — waking the agent to run ${label} now.`
405
+ ? `✅ Approved — waking the agent to run ${label}${isExternalAction && rec.personName ? ` for ${rec.personName}` : ""} now.`
339
406
  : `⛔ Denied — ${label} stays blocked; letting the agent know.`);
340
407
  return;
341
408
  }
package/core/approvals.js CHANGED
@@ -40,7 +40,19 @@ function prune() {
40
40
  }
41
41
  }
42
42
 
43
- function create({ tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "" }) {
43
+ // The base record is a destructive tool-run approval (the owner approving their
44
+ // own command). Phase 3 reuses the SAME plumbing for external-relationship
45
+ // escalations via optional fields: `kind` ("tool"|"reply"|"external-*"), an
46
+ // `approver*` channel (where the buttons go — the OWNER — distinct from the
47
+ // `channelId`/`adapter` ORIGIN, which for an external escalation is the external
48
+ // speaker), the vetted `payloadText` to deliver on a reply approval, the
49
+ // `target*` external channel, and person/entity pointers for Always-allow.
50
+ function create({
51
+ tool, verb = "", tier = "destructive", command = "", channelId = "", adapter = "",
52
+ kind = "tool", approverAdapter = "", approverChannel = "",
53
+ payloadText = "", targetAdapter = "", targetChannel = "",
54
+ personName = "", personId = "", entitySlug = "",
55
+ } = {}) {
44
56
  fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
45
57
  prune();
46
58
  const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
@@ -49,6 +61,11 @@ function create({ tool, verb = "", tier = "destructive", command = "", channelId
49
61
  tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
50
62
  command: String(command || "").slice(0, 1000),
51
63
  channelId: String(channelId || ""), adapter: String(adapter || ""),
64
+ kind: String(kind || "tool"),
65
+ approverAdapter: String(approverAdapter || ""), approverChannel: String(approverChannel || ""),
66
+ payloadText: String(payloadText || "").slice(0, 4000),
67
+ targetAdapter: String(targetAdapter || ""), targetChannel: String(targetChannel || ""),
68
+ personName: String(personName || ""), personId: String(personId || ""), entitySlug: String(entitySlug || ""),
52
69
  requestedAt: new Date().toISOString(),
53
70
  };
54
71
  fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
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
 
Binary file
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
  };
package/core/io.js CHANGED
@@ -49,6 +49,15 @@ async function sendFile(filePath, caption) {
49
49
  const adapter = currentAdapter();
50
50
  const channelId = currentChannelId();
51
51
  if (!adapter || !channelId) return false;
52
+ // R8 defensive gate: an unvetted file must never egress to a guarded
53
+ // (external) speaker. Owner / no-context → guardActive is false → no-op.
54
+ try {
55
+ const relationship = require("./relationship");
56
+ if (relationship.guardActive(relationship.speakerFor(adapter.type, channelId))) {
57
+ console.error("sendFile blocked: external speaker — file egress requires owner approval");
58
+ return false;
59
+ }
60
+ } catch (e) { /* fail open only on classification error; primary gate is loopback.handleSend */ }
52
61
  return adapter.sendFile(channelId, filePath, caption);
53
62
  }
54
63