@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 +21 -0
- package/bin/entity.js +62 -1
- package/bin/tool.js +45 -2
- package/core/actions.js +83 -16
- package/core/approvals.js +18 -1
- package/core/dream.js +13 -5
- package/core/enforcer.js +0 -0
- package/core/entities.js +68 -15
- package/core/io.js +9 -0
- package/core/loopback.js +24 -0
- package/core/pack-review.js +31 -3
- package/core/people.js +24 -0
- package/core/recall/discoverer.js +22 -2
- package/core/relationship.js +107 -0
- package/core/runner.js +61 -8
- package/core/system-prompt.js +89 -2
- package/package.json +9 -3
- package/test-enforcer.js +170 -0
- package/test-persona-packs.js +126 -0
- package/test-persona-pipeline.js +79 -0
- package/test-recall-relationship-gate.js +42 -0
- package/test-relationship.js +122 -0
- package/test-speaker-persona.js +89 -0
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"
|
|
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
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
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/enforcer.js
ADDED
|
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
|
|
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
|
-
//
|
|
8
|
-
// the
|
|
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 =
|
|
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(
|
|
77
|
+
const h = line.match(headingRe);
|
|
52
78
|
if (h) {
|
|
53
|
-
current = h[1].toLowerCase()
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
// (capped); aliases union; description/type update when
|
|
146
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|