@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 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;
@@ -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) {
@@ -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) return true; // stale button — swallow so it doesn't fall through
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 !== id.split(":")[0]);
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 (id === "kazee") {
1684
- saveEnvKey("KAZEE_URL", "");
1685
- saveEnvKey("KAZEE_BOT_TOKEN", "");
1686
- saveEnvKey("KAZEE_OWNER_USER_ID", "");
1687
- config.KAZEE_URL = "";
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
- return send(`Removed channel: ${id}`);
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
- const ok = await adapter.send(channelId, text, { keyboard });
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,
@@ -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
- function speakerFor(adapterType, channelId) {
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, channelId);
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, channelId); } catch (e) {}
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
- if (announce) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
  }
@@ -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
- const speaker = people.findByHandle(adapter.type, channelId);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.14.3",
3
+ "version": "2.14.5",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {