@inetafrica/open-claudia 2.14.2 → 2.14.4

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.14.4
4
+ - **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.
5
+ - **`/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.
6
+ - **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`).
7
+ - **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).
8
+ - **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."*
9
+ - **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.
10
+
11
+ ## v2.14.3
12
+ - **`/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"."*
13
+ - **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`.
14
+
3
15
  ## v2.14.2
4
16
  - **Token validation now hits the right service — the previous probe could never succeed.** v2.14.0/.1 validated a Kazee bot token by `GET {host}/api/me`, but chat-central has **no `/me` route** (confirmed against `src/config/routes.ts`) — every attempt 404'd. chat-central doesn't resolve identity itself; it delegates on every request to `@libraries/inet-central-auth`, which POSTs the opaque `kzb_…` token to **Central's `POST /api/principal/verify`**. `core/kazee-probe.js` now calls that endpoint: one request both **validates the token and returns the bot's own user id** (`principal.id`, with `principal.kind === "Bot"` enforced) plus its display name. Verified live end-to-end (real token → `ok` + botUserId + "Maya Bot"; bad token → clean 401).
5
17
  - **Central is hard-coded too (env-overridable).** New `CENTRAL_DEFAULT_URL = "https://central.inet.africa"` mirrors chat-central's own default; override with `CENTRAL_BASE_URL` for non-prod. The wizard now says "Verifying the bot token…" and greets the bot by name ("Connected as Maya Bot ✅"); `setup.js` prints "Verified as <name>."
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
 
@@ -122,8 +123,9 @@ async function handleText(envelope) {
122
123
  return;
123
124
  }
124
125
 
125
- // Text fallback for step 3 — the operator can still type a user id or
126
- // "skip" on their own channel instead of sending a Kazee message.
126
+ // Text fallback for step 3 — the operator can type "yes"/"no" to answer the
127
+ // owner-confirm prompt, or a user id / "skip", on their own channel instead of
128
+ // relying on the inline buttons (which may not round-trip on every channel).
127
129
  if (session.step === "awaiting-owner-message" || session.step === "confirming-owner") {
128
130
  if (text.toLowerCase() === "skip") {
129
131
  await finalizeOwner(envelope.canonicalUserId, session, "");
@@ -133,6 +135,30 @@ async function handleText(envelope) {
133
135
  await finalizeOwner(envelope.canonicalUserId, session, text);
134
136
  return;
135
137
  }
138
+ if (session.step === "confirming-owner") {
139
+ const t = text.toLowerCase();
140
+ if (t === "yes" || t === "y" || t === "yep" || t === "yeah") {
141
+ const cand = session.data.candidate;
142
+ if (cand?.userId) {
143
+ await finalizeOwner(envelope.canonicalUserId, session, cand.userId);
144
+ } else {
145
+ session.step = "awaiting-owner-message";
146
+ await send("Lost that candidate — send another message from Kazee.", { keyboard: cancelKeyboard });
147
+ }
148
+ return;
149
+ }
150
+ if (t === "no" || t === "n" || t === "nope") {
151
+ session.data.candidate = null;
152
+ session.step = "awaiting-owner-message";
153
+ await send("OK — still listening. Send a message from the owner's Kazee account.", { keyboard: cancelKeyboard });
154
+ return;
155
+ }
156
+ await send(
157
+ `Reply "yes" to confirm ${session.data.candidate?.name || "that account"} as the owner, "no" to keep listening, or "skip".`,
158
+ { keyboard: cancelKeyboard },
159
+ );
160
+ return;
161
+ }
136
162
  if (session.step === "awaiting-owner-message") {
137
163
  await send(
138
164
  "Still listening. Send a message from your Kazee account, paste a user id, or send \"skip\".",
@@ -162,7 +188,7 @@ async function tryCaptureOwner(envelope) {
162
188
  const who = name ? `${name} (${userId})` : userId;
163
189
  await sayToOrigin(
164
190
  session,
165
- `Got a Kazee message from ${who}.\n\nIs this you — the owner?`,
191
+ `Got a Kazee message from ${who}.\n\nIs this you — the owner? Tap a button below, or just reply "yes" / "no".`,
166
192
  { keyboard: { inline_keyboard: [
167
193
  [{ text: "Yes, that's me", callback_data: "chw:owner-yes" }],
168
194
  [{ text: "No, keep listening", callback_data: "chw:owner-no" }],
@@ -183,7 +209,12 @@ async function handleAction(envelope) {
183
209
  await cancel(envelope.canonicalUserId);
184
210
  return true;
185
211
  }
186
- 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
+ }
187
218
 
188
219
  if (payload === "chw:owner-yes") {
189
220
  const cand = session.data.candidate;
@@ -268,6 +299,23 @@ async function finalizeOwner(canonicalUserId, session, ownerUserId) {
268
299
  config.KAZEE_OWNER_USER_ID = ownerUserId;
269
300
  const adapter = registry.findAdapter("kazee");
270
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
+ }
271
319
  sessions.delete(canonicalUserId);
272
320
  await sayToOrigin(
273
321
  session,
package/core/enforcer.js CHANGED
Binary file
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 });
@@ -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.2",
3
+ "version": "2.14.4",
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": {