@inetafrica/open-claudia 2.14.3 → 2.14.5
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 +13 -0
- package/bin/cli.js +1 -0
- package/bin/entity.js +1 -1
- package/bin/loopback-client.js +2 -0
- package/bin/tool.js +1 -1
- package/core/channel-wizard.js +24 -1
- package/core/enforcer.js +0 -0
- package/core/handlers.js +20 -9
- package/core/identity.js +20 -0
- package/core/io.js +2 -2
- package/core/loopback.js +8 -3
- package/core/people.js +25 -1
- package/core/relationship.js +13 -5
- package/core/runner.js +22 -4
- package/core/system-prompt.js +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.14.5
|
|
4
|
+
- **`/channel remove <id>` is now a complete teardown — a re-add starts from a genuinely clean slate.** Removal used to clear only the `.env` channel config (`CHANNELS` + the `KAZEE_*` keys), but the wizard's v2.14.4 owner-capture also links the owner's Kazee handle onto their people record (`people.linkHandle`) and mirrors an identity mapping into `identities.json` (`setIdentityMapping`). Those were left behind, so a subsequent `/channel add kazee` stacked on stale handle + identity state. `/channel remove` now also drops every handle for that transport across all people records (new `people.unlinkAdapterHandles`) and removes its identity mappings — channel→canonical links and preferred-channel pointers (new `identity.removeTransportIdentities`) — and clears the previously-orphaned `KAZEE_BOT_USER_ID`.
|
|
5
|
+
- **The cleanup goes through the in-memory stores, so disk and the running process stay consistent.** `identities.json` is loaded once at boot into a shared in-memory object (`identity.js`), and `saveIdentities()` re-serializes that whole object on every write. Because `router.js` calls `autoLinkOwnerChannel` → `setIdentityMapping` → `saveIdentities` on each owner message, any entry lingering in memory is rewritten to disk continuously — so an out-of-band file edit can't stick, and a partial teardown leaves ghosts that reappear. Removing through the in-memory maps (then persisting) means the teardown holds. The `/channel remove` reply now reports what it cleared (e.g. `Removed channel: kazee — cleared 1 handle + identity links`).
|
|
6
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Scoped entirely to the owner-driven `/channel remove` path — inert for deployments that never remove a channel. Full `npm test` green.
|
|
7
|
+
|
|
8
|
+
## v2.14.4
|
|
9
|
+
- **The owner no longer gets asked to double-approve their own Kazee reply — split-brain owner resolution is fixed.** On Kazee a person is identified by `userId` while the room is `channelId` (the chat-document); the two differ. The auth layer already recognised the owner correctly (via `access.matchesTransportOwner` → `currentUserId()`), but the independent relationship guardrail re-derived ownership from `channelId` — which never equals the configured `KAZEE_OWNER_USER_ID` — and so classed the owner as *external*, escalating the owner's own approval a second time. `relationship.speakerFor` now takes an optional third `userId` and keys identity/owner classification off `speakerId = userId || channelId` (the person), while still replying/escalating to `channelId` (the room). The raw per-user id is threaded to every subprocess egress guard via a new `OC_CHANNEL_USER_ID` env var + loopback query/body param (`bin/cli.js`, `bin/entity.js`, `bin/tool.js`, `bin/loopback-client.js`, `core/io.js`, `core/loopback.js`, `core/system-prompt.js`). **On Telegram `userId === channelId` in DMs, so this is a strict no-op** for existing Telegram deployments.
|
|
10
|
+
- **`/channel add kazee` now links the owner's Kazee handle at capture time.** Owner recognition previously lived only in the env/adapter (`KAZEE_OWNER_USER_ID`); the owner's people record had no Kazee handle, so the guardrail (which resolves through canonical identity) re-derived the operator as external. `finalizeOwner` now calls `people.linkHandle(owner.id, { adapter: "kazee", channelId: ownerUserId, … })` so the wizard's captured owner is recognised the same way the rest of the bot resolves identity — belt-and-suspenders with the `speakerFor` fix above.
|
|
11
|
+
- **Debug/internal chatter is owner-only.** Recall banners (`/recall`), tool/skill traces (`/tooltrace`), and usage/cost alerts are now suppressed for a guarded (external) speaker regardless of the toggle state — internal mechanics of how the assistant works must never leak to a non-owner. Resolved once per turn (`isCurrentSpeakerGuarded`) and applied at all three emitters (`core/runner.js`).
|
|
12
|
+
- **Escalation & approval prompts render properly on Telegram.** The external-guardrail Approve/Deny/Always-allow message (`core/enforcer.js`) and the destructive-tool approval prompt (`core/loopback.js`) build HTML (`<b>`/`<pre>`, all dynamic content `esc()`-escaped) but were sent without a parse mode, so the tags showed literally. They now set `parseMode: "HTML"` **only for the Telegram adapter** (other adapters ignore it).
|
|
13
|
+
- **Friendlier stale-button handling.** A wizard callback pressed after the session is gone (bot restart / timeout) previously did nothing — a dead button reads as a hang. It now replies *"That setup step expired — I'm no longer listening. Run /channel add kazee to start again."*
|
|
14
|
+
- **Agent-space / OpenClaw:** no new deps, no new required env (`OC_CHANNEL_USER_ID` is bot-internal, injected per-turn), no schema change; Docker image builds identically. Correctness fix to owner recognition on Kazee + polish to the onboarding wizard — inert for Telegram-only deployments (userId === channelId). Full `npm test` green.
|
|
15
|
+
|
|
3
16
|
## v2.14.3
|
|
4
17
|
- **`/channel add kazee` owner-confirm no longer depends on the inline button round-tripping.** After the wizard captures a candidate owner message it asks *"Is this you — the owner?"* with Yes/No/Skip buttons on the operator's channel — but if that button callback never reaches the bot (e.g. a channel that drops `message:action` server-side), the operator was stuck: the confirm step (`core/channel-wizard.js` `handleText`) only understood "skip" or a pasted 24-hex id, so typing "yes" did nothing. It now accepts **typed "yes"/"no"** (plus y/n/yep/yeah/nope) as a first-class answer — "yes" finalizes the captured candidate as owner, "no" clears it and keeps listening — and any other text nudges with the three options. The confirm prompt now reads *"Tap a button below, or just reply "yes" / "no"."*
|
|
5
18
|
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Purely a robustness fix to the Kazee onboarding wizard's owner-confirm step — inert for deployments that never run `/channel add kazee`.
|
package/bin/cli.js
CHANGED
|
@@ -60,6 +60,7 @@ function sendViaLoopback(kind, restArgs) {
|
|
|
60
60
|
const fileName = path.basename(filePath);
|
|
61
61
|
const stat = fs.statSync(filePath);
|
|
62
62
|
const params = new URLSearchParams({ channelId, adapter: adapterId, fileName });
|
|
63
|
+
if (process.env.OC_CHANNEL_USER_ID) params.set("userId", process.env.OC_CHANNEL_USER_ID);
|
|
63
64
|
if (caption) params.set("caption", caption);
|
|
64
65
|
|
|
65
66
|
const req = http.request({
|
package/bin/entity.js
CHANGED
|
@@ -71,7 +71,7 @@ function run(args) {
|
|
|
71
71
|
// owner person (R1) — a note can never confer owner powers.
|
|
72
72
|
try {
|
|
73
73
|
const relationship = require("../core/relationship");
|
|
74
|
-
const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID);
|
|
74
|
+
const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID, process.env.OC_CHANNEL_USER_ID);
|
|
75
75
|
if (relationship.guardActive(sp)) {
|
|
76
76
|
console.error("⛔ Only the owner can set a persona/mandate.");
|
|
77
77
|
process.exitCode = 3; return;
|
package/bin/loopback-client.js
CHANGED
|
@@ -36,6 +36,7 @@ function context() {
|
|
|
36
36
|
sendToken,
|
|
37
37
|
channelId: process.env.OC_CHANNEL_ID,
|
|
38
38
|
adapterId: process.env.OC_CHANNEL_ADAPTER,
|
|
39
|
+
userId: process.env.OC_CHANNEL_USER_ID || null,
|
|
39
40
|
canonicalUserId: process.env.OC_CANONICAL_USER_ID || null,
|
|
40
41
|
sessionId: process.env.OC_LAST_SESSION_ID || null,
|
|
41
42
|
sessionKey: process.env.OC_LAST_SESSION_KEY || null,
|
|
@@ -57,6 +58,7 @@ function postJson(kind, body) {
|
|
|
57
58
|
const payload = JSON.stringify({
|
|
58
59
|
channelId: ctx.channelId,
|
|
59
60
|
adapter: ctx.adapterId,
|
|
61
|
+
userId: ctx.userId,
|
|
60
62
|
canonicalUserId: ctx.canonicalUserId,
|
|
61
63
|
sessionId: ctx.sessionId,
|
|
62
64
|
sessionKey: ctx.sessionKey,
|
package/bin/tool.js
CHANGED
|
@@ -388,7 +388,7 @@ async function run(args) {
|
|
|
388
388
|
let speaker = null;
|
|
389
389
|
try {
|
|
390
390
|
const relationship = require("../core/relationship");
|
|
391
|
-
const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID);
|
|
391
|
+
const sp = relationship.speakerFor(process.env.OC_CHANNEL_ADAPTER, process.env.OC_CHANNEL_ID, process.env.OC_CHANNEL_USER_ID);
|
|
392
392
|
if (relationship.guardActive(sp)) speaker = sp; // else owner/no-context → ungated
|
|
393
393
|
} catch (e) { speaker = null; }
|
|
394
394
|
if (speaker) {
|
package/core/channel-wizard.js
CHANGED
|
@@ -20,6 +20,7 @@ const { send, deleteMessage } = require("./io");
|
|
|
20
20
|
const { publicCommands } = require("./commands");
|
|
21
21
|
const registry = require("./adapter-registry");
|
|
22
22
|
const { validateKazee } = require("./kazee-probe");
|
|
23
|
+
const people = require("./people");
|
|
23
24
|
|
|
24
25
|
const sessions = new Map(); // canonicalUserId -> session
|
|
25
26
|
|
|
@@ -208,7 +209,12 @@ async function handleAction(envelope) {
|
|
|
208
209
|
await cancel(envelope.canonicalUserId);
|
|
209
210
|
return true;
|
|
210
211
|
}
|
|
211
|
-
if (!session)
|
|
212
|
+
if (!session) {
|
|
213
|
+
// Session gone (bot restart or timeout). Don't silently swallow — a dead
|
|
214
|
+
// button that does nothing reads as a hang. Tell the operator to re-run.
|
|
215
|
+
await send("That setup step expired — I'm no longer listening. Run /channel add kazee to start again.");
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
212
218
|
|
|
213
219
|
if (payload === "chw:owner-yes") {
|
|
214
220
|
const cand = session.data.candidate;
|
|
@@ -293,6 +299,23 @@ async function finalizeOwner(canonicalUserId, session, ownerUserId) {
|
|
|
293
299
|
config.KAZEE_OWNER_USER_ID = ownerUserId;
|
|
294
300
|
const adapter = registry.findAdapter("kazee");
|
|
295
301
|
if (adapter) adapter.ownerUserId = ownerUserId;
|
|
302
|
+
// Link the owner's Kazee handle onto their people record so relationship
|
|
303
|
+
// resolution (speakerFor → channelIsOwner) recognizes their Kazee messages
|
|
304
|
+
// as the owner. Without this, ownership is only known to the env/adapter and
|
|
305
|
+
// the guardrail re-derives the operator as external → spurious re-approval.
|
|
306
|
+
if (ownerUserId) {
|
|
307
|
+
try {
|
|
308
|
+
const owner = people.owners()[0];
|
|
309
|
+
if (owner) {
|
|
310
|
+
people.linkHandle(owner.id, {
|
|
311
|
+
adapter: "kazee",
|
|
312
|
+
channelId: ownerUserId,
|
|
313
|
+
displayName: session.data?.candidate?.name || null,
|
|
314
|
+
approvedBy: "channel-wizard",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
} catch (e) { console.error("channel-wizard linkHandle:", e.message); }
|
|
318
|
+
}
|
|
296
319
|
sessions.delete(canonicalUserId);
|
|
297
320
|
await sayToOrigin(
|
|
298
321
|
session,
|
package/core/enforcer.js
CHANGED
|
Binary file
|
package/core/handlers.js
CHANGED
|
@@ -1674,21 +1674,32 @@ register({
|
|
|
1674
1674
|
if (id === "telegram") return send("Refusing to remove the Telegram adapter from a /channel command — that's your own connection.");
|
|
1675
1675
|
const result = await registry.removeAdapter(id);
|
|
1676
1676
|
if (!result.ok) return send(result.error);
|
|
1677
|
+
const transport = id.split(":")[0];
|
|
1677
1678
|
// Strip from .env so the change survives restart.
|
|
1678
1679
|
const channels = (config.CHANNELS || "telegram").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1679
|
-
const kept = channels.filter((c) => c !== id && !c.startsWith(id + ":") && c !==
|
|
1680
|
+
const kept = channels.filter((c) => c !== id && !c.startsWith(id + ":") && c !== transport);
|
|
1680
1681
|
const next = kept.join(",") || "telegram";
|
|
1681
1682
|
saveEnvKey("CHANNELS", next);
|
|
1682
1683
|
config.CHANNELS = next;
|
|
1683
|
-
if (
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
config.KAZEE_BOT_TOKEN = "";
|
|
1689
|
-
config.KAZEE_OWNER_USER_ID = "";
|
|
1684
|
+
if (transport === "kazee") {
|
|
1685
|
+
for (const k of ["KAZEE_URL", "KAZEE_BOT_TOKEN", "KAZEE_OWNER_USER_ID", "KAZEE_BOT_USER_ID"]) {
|
|
1686
|
+
saveEnvKey(k, "");
|
|
1687
|
+
config[k] = "";
|
|
1688
|
+
}
|
|
1690
1689
|
}
|
|
1691
|
-
|
|
1690
|
+
// Complete the teardown: drop this transport's handles from every person
|
|
1691
|
+
// record and its identity mappings. Both mutate through the in-memory
|
|
1692
|
+
// stores, so disk and the running process stay consistent — otherwise a
|
|
1693
|
+
// stale handle/link survives in memory and gets rewritten on the next
|
|
1694
|
+
// add, which is exactly what made a re-add feel un-clean.
|
|
1695
|
+
let unlinked = 0;
|
|
1696
|
+
try { unlinked = require("./people").unlinkAdapterHandles(transport).length; } catch (e) {}
|
|
1697
|
+
let idsCleared = false;
|
|
1698
|
+
try { idsCleared = require("./identity").removeTransportIdentities(transport); } catch (e) {}
|
|
1699
|
+
const extra = (unlinked || idsCleared)
|
|
1700
|
+
? ` — cleared ${unlinked} handle${unlinked === 1 ? "" : "s"} + identity links`
|
|
1701
|
+
: "";
|
|
1702
|
+
return send(`Removed channel: ${id}${extra}`);
|
|
1692
1703
|
}
|
|
1693
1704
|
|
|
1694
1705
|
send("Usage: /channel | /channel add kazee | /channel remove <id>");
|
package/core/identity.js
CHANGED
|
@@ -137,6 +137,25 @@ function setIdentityMapping(transport, channelId, canonicalUserId) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// Remove every identity mapping for a transport — used when a channel is torn
|
|
141
|
+
// down (/channel remove) so no stale channel→canonical link or preferred-channel
|
|
142
|
+
// pointer lingers to be rewritten to disk on the next identity write. Mutates
|
|
143
|
+
// the in-memory map the whole process shares, THEN persists, so memory and disk
|
|
144
|
+
// stay in sync (a disk-only edit would be resurrected by the cached object).
|
|
145
|
+
function removeTransportIdentities(transport) {
|
|
146
|
+
const t = String(transport || "").toLowerCase();
|
|
147
|
+
if (!t) return false;
|
|
148
|
+
let changed = false;
|
|
149
|
+
for (const key of Object.keys(identities.channels)) {
|
|
150
|
+
if (key.slice(0, t.length + 1) === t + ":") { delete identities.channels[key]; changed = true; }
|
|
151
|
+
}
|
|
152
|
+
for (const [userId, pref] of Object.entries(identities.preferred)) {
|
|
153
|
+
if (pref && String(pref.transport || "").toLowerCase() === t) { delete identities.preferred[userId]; changed = true; }
|
|
154
|
+
}
|
|
155
|
+
if (changed) saveIdentities();
|
|
156
|
+
return changed;
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
module.exports = {
|
|
141
160
|
identities,
|
|
142
161
|
normalizeCanonicalUserId,
|
|
@@ -149,5 +168,6 @@ module.exports = {
|
|
|
149
168
|
canonicalForTelegram,
|
|
150
169
|
canonicalForStoredUserKey,
|
|
151
170
|
setIdentityMapping,
|
|
171
|
+
removeTransportIdentities,
|
|
152
172
|
saveIdentities,
|
|
153
173
|
};
|
package/core/io.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// here so that the right adapter is invoked for the in-flight message
|
|
3
3
|
// without each handler having to know about transports.
|
|
4
4
|
|
|
5
|
-
const { currentAdapter, currentChannelId } = require("./context");
|
|
5
|
+
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
6
6
|
|
|
7
7
|
async function send(text, opts = {}) {
|
|
8
8
|
const adapter = currentAdapter();
|
|
@@ -53,7 +53,7 @@ async function sendFile(filePath, caption) {
|
|
|
53
53
|
// (external) speaker. Owner / no-context → guardActive is false → no-op.
|
|
54
54
|
try {
|
|
55
55
|
const relationship = require("./relationship");
|
|
56
|
-
if (relationship.guardActive(relationship.speakerFor(adapter.type, channelId))) {
|
|
56
|
+
if (relationship.guardActive(relationship.speakerFor(adapter.type, channelId, currentUserId()))) {
|
|
57
57
|
console.error("sendFile blocked: external speaker — file egress requires owner approval");
|
|
58
58
|
return false;
|
|
59
59
|
}
|
package/core/loopback.js
CHANGED
|
@@ -106,6 +106,7 @@ function readBodyAsString(req, max = 64 * 1024) {
|
|
|
106
106
|
|
|
107
107
|
async function handleSend(req, res, url, kind) {
|
|
108
108
|
const channelId = url.searchParams.get("channelId");
|
|
109
|
+
const userId = url.searchParams.get("userId") || "";
|
|
109
110
|
const adapterId = url.searchParams.get("adapter");
|
|
110
111
|
const caption = url.searchParams.get("caption") || "";
|
|
111
112
|
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
@@ -119,7 +120,7 @@ async function handleSend(req, res, url, kind) {
|
|
|
119
120
|
// unaffected — guardActive is false there, so this is a strict no-op for them.
|
|
120
121
|
try {
|
|
121
122
|
const relationship = require("./relationship");
|
|
122
|
-
const speaker = relationship.speakerFor(adapter.type, channelId);
|
|
123
|
+
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
123
124
|
if (relationship.guardActive(speaker)) {
|
|
124
125
|
return reply(res, 403, { error: "file egress to an external contact is blocked by the guardrail — an unvetted file cannot be sent to a non-owner; ask the owner to send it" });
|
|
125
126
|
}
|
|
@@ -152,6 +153,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
152
153
|
catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
|
|
153
154
|
}
|
|
154
155
|
const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
|
|
156
|
+
const userId = String(payload.userId || url.searchParams.get("userId") || "");
|
|
155
157
|
const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
|
|
156
158
|
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
157
159
|
const adapter = registry.findAdapter(adapterId);
|
|
@@ -485,7 +487,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
485
487
|
// non-owner, refuse: never hand an external the approve button (R8/R10).
|
|
486
488
|
try {
|
|
487
489
|
const relationship = require("./relationship");
|
|
488
|
-
const speaker = relationship.speakerFor(adapter.type, channelId);
|
|
490
|
+
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
489
491
|
if (relationship.guardActive(speaker)) {
|
|
490
492
|
return reply(res, 403, { error: "approval requests are owner-only; external mutating runs escalate through the guardrail" });
|
|
491
493
|
}
|
|
@@ -507,7 +509,10 @@ async function handleJson(req, res, url, kind) {
|
|
|
507
509
|
{ text: "✅ Approve", callback_data: `apr:${rec.id}:ok` },
|
|
508
510
|
{ text: "⛔ Deny", callback_data: `apr:${rec.id}:no` },
|
|
509
511
|
]] };
|
|
510
|
-
|
|
512
|
+
// <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
|
|
513
|
+
const sendOpts = { keyboard };
|
|
514
|
+
if (adapter.type === "telegram") sendOpts.parseMode = "HTML";
|
|
515
|
+
const ok = await adapter.send(channelId, text, sendOpts);
|
|
511
516
|
if (!ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
|
|
512
517
|
audit.log("tool.approval.requested", { id: rec.id, tool: rec.tool, verb: rec.verb, channelId });
|
|
513
518
|
return reply(res, 200, { ok: true, id: rec.id });
|
package/core/people.js
CHANGED
|
@@ -159,6 +159,30 @@ function unlinkHandle(personId, { adapter, channelId }) {
|
|
|
159
159
|
return p;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Remove every handle for a given adapter across ALL people records — used when
|
|
163
|
+
// a channel is torn down (/channel remove) so no stranded handle survives to be
|
|
164
|
+
// re-linked or re-mirrored on a later add. primaryChannel falls back to the next
|
|
165
|
+
// remaining handle. Returns the ids of the people that were changed.
|
|
166
|
+
function unlinkAdapterHandles(adapter) {
|
|
167
|
+
const a = String(adapter || "").toLowerCase();
|
|
168
|
+
if (!a) return [];
|
|
169
|
+
const all = load();
|
|
170
|
+
const affected = [];
|
|
171
|
+
for (const p of all) {
|
|
172
|
+
const before = (p.handles || []).length;
|
|
173
|
+
if (!before) continue;
|
|
174
|
+
p.handles = p.handles.filter((h) => h.adapter !== a);
|
|
175
|
+
if (p.handles.length === before) continue;
|
|
176
|
+
if (p.primaryChannel && p.primaryChannel.adapter === a) {
|
|
177
|
+
p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
|
|
178
|
+
}
|
|
179
|
+
p.updatedAt = nowIso();
|
|
180
|
+
affected.push(p.id);
|
|
181
|
+
}
|
|
182
|
+
if (affected.length) save(all);
|
|
183
|
+
return affected;
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
function setPrimary(personId, { adapter, channelId }) {
|
|
163
187
|
const all = load();
|
|
164
188
|
const p = all.find((x) => x.id === personId);
|
|
@@ -264,7 +288,7 @@ module.exports = {
|
|
|
264
288
|
load, save, list, roster,
|
|
265
289
|
add, update, remove,
|
|
266
290
|
findById, findByName, findByCanonicalUserId, findByHandle,
|
|
267
|
-
linkHandle, unlinkHandle, setPrimary,
|
|
291
|
+
linkHandle, unlinkHandle, unlinkAdapterHandles, setPrimary,
|
|
268
292
|
note, removeNote,
|
|
269
293
|
setEntitySlug, resolveEntitySlug,
|
|
270
294
|
owners, hasOwnerRecord, seedOwnerFromLegacy,
|
package/core/relationship.js
CHANGED
|
@@ -13,11 +13,19 @@
|
|
|
13
13
|
// ungated. This is what keeps the owner reply/tool/file paths byte-for-byte
|
|
14
14
|
// unchanged when the guardrail is dormant (R9/R14).
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// `userId` (optional) is the PERSON's id, distinct from `channelId` (the
|
|
17
|
+
// reply target / room) on transports that separate them — Kazee sets
|
|
18
|
+
// userId = the sender and channelId = the chat-document. Identity (owner
|
|
19
|
+
// classification + person lookup) must key off the person, never the room,
|
|
20
|
+
// or the owner is falsely classed external on Kazee (channelId ≠ the
|
|
21
|
+
// configured KAZEE_OWNER_USER_ID). On Telegram userId === channelId, so
|
|
22
|
+
// passing it is a strict no-op. channelId is still what we reply/escalate to.
|
|
23
|
+
function speakerFor(adapterType, channelId, userId) {
|
|
17
24
|
try {
|
|
18
25
|
if (!adapterType || !channelId) return null;
|
|
26
|
+
const speakerId = String(userId || channelId);
|
|
19
27
|
const people = require("./people");
|
|
20
|
-
const person = people.findByHandle(adapterType,
|
|
28
|
+
const person = people.findByHandle(adapterType, speakerId);
|
|
21
29
|
|
|
22
30
|
// Ownership is resolved through canonical identity — the SAME source of
|
|
23
31
|
// truth the queue/state/run-lock uses — so the owner is recognised on every
|
|
@@ -30,7 +38,7 @@ function speakerFor(adapterType, channelId) {
|
|
|
30
38
|
// a note can never trip it (R1 preserved — the owner stays anchored to a
|
|
31
39
|
// real owner record via ownerCanonical → people.isOwner).
|
|
32
40
|
let ownerByCanonical = false;
|
|
33
|
-
try { ownerByCanonical = require("./identity").channelIsOwner(adapterType,
|
|
41
|
+
try { ownerByCanonical = require("./identity").channelIsOwner(adapterType, speakerId); } catch (e) {}
|
|
34
42
|
|
|
35
43
|
if (ownerByCanonical || (person && person.isOwner)) {
|
|
36
44
|
let ownerPerson = person && person.isOwner ? person : null;
|
|
@@ -80,11 +88,11 @@ function speakerFor(adapterType, channelId) {
|
|
|
80
88
|
|
|
81
89
|
function currentSpeaker() {
|
|
82
90
|
try {
|
|
83
|
-
const { currentAdapter, currentChannelId } = require("./context");
|
|
91
|
+
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
84
92
|
const adapter = currentAdapter();
|
|
85
93
|
const channelId = currentChannelId();
|
|
86
94
|
if (!adapter || !channelId) return null; // no context → ungated (owner-local)
|
|
87
|
-
return speakerFor(adapter.type, channelId);
|
|
95
|
+
return speakerFor(adapter.type, channelId, currentUserId());
|
|
88
96
|
} catch (e) {
|
|
89
97
|
return null;
|
|
90
98
|
}
|
package/core/runner.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
CONFIG_DIR, WORKSPACE, config,
|
|
13
13
|
} = require("./config");
|
|
14
14
|
const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
|
|
15
|
-
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
15
|
+
const { chatContext, currentChannelId, currentAdapter, currentUserId } = require("./context");
|
|
16
16
|
const { splitLeadingOriginGroup } = require("./queue-drain");
|
|
17
17
|
const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
|
|
18
18
|
const { redactSensitive } = require("./redact");
|
|
@@ -169,7 +169,11 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
|
|
|
169
169
|
const result = evaluateUsageAlert(previousRecords, record, policy, lastAlertAt);
|
|
170
170
|
if (result.alert) {
|
|
171
171
|
state.usageAlertLastAt = Date.now();
|
|
172
|
-
|
|
172
|
+
// Owner-only: version/model/context internals must never surface to an
|
|
173
|
+
// external speaker, regardless of the usage-alert policy.
|
|
174
|
+
let usageOwnerOnly = true;
|
|
175
|
+
try { usageOwnerOnly = !require("./relationship").isCurrentSpeakerGuarded(); } catch (e) {}
|
|
176
|
+
if (announce && usageOwnerOnly) {
|
|
173
177
|
const baseline = result.trend?.baselineAvgContextTokens || 0;
|
|
174
178
|
const rate = result.trend?.latestRate || 0;
|
|
175
179
|
const contextLabel = usageScope === "turn_delta" ? "Context this turn" : "Session context total";
|
|
@@ -197,6 +201,11 @@ function chatEnvOverlay() {
|
|
|
197
201
|
const overlay = {};
|
|
198
202
|
if (adapter && adapter.id) overlay.OC_CHANNEL_ADAPTER = adapter.id;
|
|
199
203
|
if (channelId) overlay.OC_CHANNEL_ID = String(channelId);
|
|
204
|
+
// Raw per-user id (distinct from channelId on Kazee) so subprocess egress
|
|
205
|
+
// guards classify the speaker by person, not room — matching in-process
|
|
206
|
+
// relationship.speakerFor. On Telegram this equals channelId.
|
|
207
|
+
const uid = currentUserId();
|
|
208
|
+
if (uid) overlay.OC_CHANNEL_USER_ID = String(uid);
|
|
200
209
|
if (lb) {
|
|
201
210
|
overlay.OC_SEND_URL = lb.url;
|
|
202
211
|
overlay.OC_SEND_TOKEN = lb.token;
|
|
@@ -780,6 +789,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
780
789
|
const channelId = currentChannelId();
|
|
781
790
|
const adapter = currentAdapter();
|
|
782
791
|
const { settings } = state;
|
|
792
|
+
// Owner-vs-external, resolved once per turn. All internal-activity chatter
|
|
793
|
+
// (recall banners, tool/skill traces, usage alerts) is owner-only so nothing
|
|
794
|
+
// about how the assistant works leaks to an external speaker.
|
|
795
|
+
let externalSpeaker = false;
|
|
796
|
+
try { externalSpeaker = require("./relationship").isCurrentSpeakerGuarded(); } catch (e) {}
|
|
783
797
|
cwd = resolveRunCwd(cwd);
|
|
784
798
|
|
|
785
799
|
if (state.runningProcess) {
|
|
@@ -920,6 +934,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
920
934
|
// is invoked or its SKILL.md is written/patched. Deduped per turn.
|
|
921
935
|
const skillNotified = new Set();
|
|
922
936
|
const notifySkill = (key, text) => {
|
|
937
|
+
if (externalSpeaker) return; // never leak internal activity to a non-owner
|
|
923
938
|
if (skillNotified.has(key)) return;
|
|
924
939
|
skillNotified.add(key);
|
|
925
940
|
chatContext.run(store, () => send(text).catch(() => {}));
|
|
@@ -1085,14 +1100,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1085
1100
|
turnRecallTier = String(r.tier || "");
|
|
1086
1101
|
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1087
1102
|
const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
|
|
1103
|
+
// Debug traces are owner-only: recall/tool/engine internals must never
|
|
1104
|
+
// leak to an external speaker even when a debug toggle is left on.
|
|
1105
|
+
const traceOwnerOnly = !externalSpeaker;
|
|
1088
1106
|
// 🧠 packs/entities recall — gated by /recall.
|
|
1089
|
-
if (settings.showRecall) {
|
|
1107
|
+
if (traceOwnerOnly && settings.showRecall) {
|
|
1090
1108
|
const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
|
|
1091
1109
|
if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
|
|
1092
1110
|
else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
|
|
1093
1111
|
}
|
|
1094
1112
|
// 🔧 tools surfaced this turn (first-class discoverer nodes) — gated by /tooltrace.
|
|
1095
|
-
if (settings.showToolTrace) {
|
|
1113
|
+
if (traceOwnerOnly && settings.showToolTrace) {
|
|
1096
1114
|
const toolLines = fmt(r.tools || [], "🔧");
|
|
1097
1115
|
if (toolLines.length) send(`🔧 <b>Tools surfaced this turn</b>\n${toolLines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
|
|
1098
1116
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require("path");
|
|
|
7
7
|
const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, WHISPER_CLI, FFMPEG, config } = require("./config");
|
|
8
8
|
const CONFIG_DIR = require("../config-dir");
|
|
9
9
|
const { currentState } = require("./state");
|
|
10
|
-
const { currentAdapter, currentChannelId } = require("./context");
|
|
10
|
+
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
11
11
|
const { vault } = require("./vault-store");
|
|
12
12
|
const { transcriptPointerNote } = require("./transcripts");
|
|
13
13
|
const tasksStore = require("./tasks");
|
|
@@ -146,7 +146,10 @@ function buildSystemPrompt() {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
if (adapter && channelId) {
|
|
149
|
-
|
|
149
|
+
// Identify the speaker by their person id (userId), not the room —
|
|
150
|
+
// on Kazee channelId is the chat-document, so a channelId lookup would
|
|
151
|
+
// never match the owner's handle. On Telegram the two are equal.
|
|
152
|
+
const speaker = people.findByHandle(adapter.type, String(currentUserId() || channelId));
|
|
150
153
|
if (speaker) {
|
|
151
154
|
const recentNotes = (speaker.notes || []).slice(-3).map((n) => `- [${n.at?.slice(0, 10) || ""}] ${n.text}`).join("\n");
|
|
152
155
|
currentSpeakerBlock = `\n## Speaker\nYou are talking to ${speaker.name}${speaker.isOwner ? " (the owner)" : ""} on ${adapter.type}.${speaker.bio ? "\nBio: " + speaker.bio : ""}${recentNotes ? "\nRecent notes:\n" + recentNotes : ""}\n`;
|
package/package.json
CHANGED