@inetafrica/open-claudia 3.0.22 → 3.0.25

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.25 — The bot types back on a Spaces task
4
+
5
+ - **"Open Claudia is typing…" now shows on a Spaces task thread while the bot composes.** Core already runs a typing heartbeat around every agent turn (calling `adapter.typing()` every ~4s and `adapter.typingStop()` at turn-end); on Spaces those were no-op stubs. They now re-emit the exact `comment:typing` event a human client sends over the adapter's existing authenticated socket, so a teammate watching a task sees the bot compose like any other member — the same three-dot indicator the web and mobile apps already render.
6
+ - **Part 4 of the cross-repo typing indicator.** The backend (`comment:typing` re-broadcast to the `space:<id>` room, identity stamped from the authenticated socket, no persistence), web, and mobile surfaces shipped in prior deploys — human↔human typing was already live end-to-end. This closes the loop so the bot participates too.
7
+ - **Minimal by construction.** The adapter caches each task's `spaceId` (from the notification payload and the thread-context fetch) so the heartbeat — which only knows the `task:<id>` channel — can name the room. The emit sends just `targetId + spaceId + isTyping`; the server derives who is typing. Fully best-effort: no socket, no cached space, or a transport blip is a silent no-op, so a typing dot can never affect or delay the actual reply.
8
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
9
+
10
+ ## v3.0.24 — Spaces threads read like a group chat; memory notes go quiet unless you're watching
11
+
12
+ - **The "🧠 Jotted this down" memory notes are now gated behind `/recall`.** After each turn the pack reviewer still writes to long-term memory exactly as before — but the chat announcement of what it filed away only surfaces when `/recall` is on (`state.settings.showRecall`), the same toggle that already gates the recall/tool banners. Off by default, so ordinary turns stay clean; flip `/recall on` to watch memory (and recall) work. That announcement was the one learning-surface bypassing the gate; it no longer does.
13
+ - **Re-ship of v3.0.23, which never published.** Its tag build failed the version-approval CI gate — `package.json` was bumped without mirroring the assertion in `test-provider-language.js`. Same gate that caught v3.0.21; corrected here as v3.0.24, exactly as v3.0.22 re-shipped v3.0.21.
14
+ - **An engaged Spaces turn now arrives knowing the whole thread, not just one bare comment.** When a tag/reply/assignment engages the bot on a task, the router pulls a **thread-context block** from the adapter and prepends it to the prompt: the task's metadata (title, status/priority/due, assignees, a clipped description) **plus every comment posted since the bot last replied**. So the bot answers like a member who's been reading along, instead of replying blind. The full task + entire comment history stays one `spaces read` tool call away.
15
+ - **"Group chat the bot is a member of."** Untagged comments still never trigger a turn (the engage gate is unchanged — mentions/replies/assignments only). But the next time the bot *is* addressed, everything said in between is caught up in one batched injection. The delta is derived server-side from a per-task **context watermark**, so a missed poll or a restart self-heals — the next engage still pulls the true set. The bot's own prior reply is re-surfaced too, which keeps continuity across auto-compaction.
16
+ - **Bounded by construction.** The catch-up is capped (newest 12 comments, each body clipped) so a busy thread can't blow the prompt; the very first engage on a long-idle task shows recent history, not the entire backlog. Auto-compaction absorbs the rest — no new wiring, it's already per-thread and token-triggered.
17
+ - **Rendered cleanly, not stitched into the message.** Context is delivered via a new channel-agnostic `buildThreadContext()` adapter method that the router renders — `envelope.text` stays pristine, so command detection and the credential scrub are unaffected (the old approach mutated the message text). The seam is generic: Kazee group rooms can implement the same method later, and the since-last-reply delta is exactly the input a future lightweight activation agent would judge to decide whether to chip in unprompted — provisioned, not built.
18
+ - **The bot now leaves a read receipt, like a teammate.** When an engaged turn catches up on a thread, it marks every comment it just read as *seen by* the bot (`comments/mark-seen` → server `$addToSet`s the bot into each comment's `seenBy` and broadcasts `comment:seen`). So a human watching a task sees the bot appear on "seen by" exactly like another member the moment it reads their message — not only when it replies. Fire-and-forget and fully swallowed: it rides the same member/admin `comments.update` scope the reply already uses, and a scope or network failure degrades to silent (never breaks or delays a turn). Only ever fires on an engaged turn — the awareness poll still never mutates Spaces state.
19
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically (FROM the pre-baked `:base`). Existing pods pick this up on their next approved upgrade.
20
+
3
21
  ## v3.0.20 — Spaces answers tags, and knows the owner
4
22
 
5
23
  - **A tag now actually gets a reply.** The Spaces server labels notifications with **plural** categories (`mentions`, `comments`, `reactions`, `task_updated`), but the conversational plane's engage set only matched the singular `mention` — so every real `@`-tag was silently downgraded to awareness-only and the bot never spoke. `ENGAGE_TYPES` now matches the plural forms (`mentions`/`replies`, singular aliases kept for safety). Plain comments, reactions and task updates stay awareness-only, so the "untagged comments hit the radar without auto-answering" rule is unchanged. Confirmed live: a real `mentions` notification was arriving and being dropped.
@@ -30,6 +30,17 @@ const spacesState = require("./state");
30
30
  // never auto-reply.
31
31
  const ENGAGE_TYPES = new Set(["mention", "mentions", "reply", "replies", "task_assigned"]);
32
32
 
33
+ // Description clip length for the front-loaded task context. Long descriptions
34
+ // are deliberately truncated — the agent can pull the full task + comment
35
+ // history on demand via the `spaces` tool's read verb, so we keep the prompt
36
+ // lean instead of pasting a wall of text every turn.
37
+ const TASK_DESC_LIMIT = 800;
38
+ // Catch-up bounds for the "comments since your last reply" block. Cap the count
39
+ // (also bounds the very first engage, which has no watermark) and clip each body
40
+ // so a busy thread can't blow the prompt. Auto-compaction absorbs the rest.
41
+ const CATCHUP_MAX = 12;
42
+ const CATCHUP_BODY_LIMIT = 500;
43
+
33
44
  class SpacesAdapter {
34
45
  constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", taskLoopMode = "advisory", pollMs }) {
35
46
  this.id = id;
@@ -48,6 +59,7 @@ class SpacesAdapter {
48
59
  this._socket = null;
49
60
  this._seen = new Set(); // notification-id dedup across socket + poll
50
61
  this._taskChains = new Map(); // taskId -> tail promise, for per-task serialization
62
+ this._taskSpace = new Map(); // taskId -> spaceId, so typing() can name the room
51
63
  this._pollTimer = null;
52
64
  this._initTimer = null;
53
65
  }
@@ -219,6 +231,10 @@ class SpacesAdapter {
219
231
  const n = this._normalize(payload);
220
232
  if (!n.taskId) return;
221
233
 
234
+ // Cache the task's space so typing() can address the space:<id> room later —
235
+ // the heartbeat only gets a channelId ("task:<id>"), not the spaceId.
236
+ if (n.spaceId) this._taskSpace.set(String(n.taskId), String(n.spaceId));
237
+
222
238
  // Receiving any Spaces notification proves membership → mark connected. This
223
239
  // is the socket half of the hybrid "connected ⇒ conversational" signal; no
224
240
  // env flag needed to turn auto-replies on.
@@ -272,7 +288,10 @@ class SpacesAdapter {
272
288
  }
273
289
 
274
290
  // Await every registered message listener (the router turn resolves when the
275
- // agent run completes) so the per-task chain doesn't advance early.
291
+ // agent run completes) so the per-task chain doesn't advance early. Task
292
+ // context is no longer stitched onto envelope.text here — the router pulls it
293
+ // via buildThreadContext() and renders it, keeping envelope.text pristine for
294
+ // command detection and the credential scrub.
276
295
  async _deliverMessage(envelope) {
277
296
  for (const fn of [...(this._listeners.message || [])]) {
278
297
  try { await fn(envelope); }
@@ -280,6 +299,174 @@ class SpacesAdapter {
280
299
  }
281
300
  }
282
301
 
302
+ // Channel-agnostic thread-context provider (ChannelAdapter contract). The
303
+ // router calls this on an engaged turn and prepends the returned contextBlock
304
+ // to the prompt, so the agent arrives on a Spaces thread already knowing what
305
+ // it's looking at — the task's metadata AND every comment posted since it last
306
+ // replied — instead of answering blind to one bare comment. Two read-only GETs
307
+ // (task + comments); any failure returns null so the reply still goes out.
308
+ //
309
+ // "Task as a group chat the bot is a member of": untagged comments never fire a
310
+ // turn (ENGAGE_TYPES gates that), but they're caught up here the next time the
311
+ // bot IS addressed. The delta is server-derived from a watermark, so a missed
312
+ // poll or a restart self-heals — the next engage still pulls the true set.
313
+ //
314
+ // The same _commentsSince() delta is the exact input a future Haiku activation
315
+ // agent would judge to decide whether to chip in unprompted — left as a seam,
316
+ // not built.
317
+ async buildThreadContext(envelope) {
318
+ const taskId = envelope?.spaces?.taskId;
319
+ if (!taskId) return null;
320
+
321
+ let task = null;
322
+ try { task = await this.client.getTask(taskId); }
323
+ catch (e) { task = null; }
324
+
325
+ // Cache the task's space for typing() — buildThreadContext runs at the very
326
+ // start of an engaged turn, before the typing heartbeat first fires.
327
+ if (task && typeof task === "object") {
328
+ const sid = task.spaceId && typeof task.spaceId === "object"
329
+ ? (task.spaceId.id || task.spaceId._id || null)
330
+ : (task.spaceId || null);
331
+ if (sid) this._taskSpace.set(String(taskId), String(sid));
332
+ }
333
+
334
+ const lines = ["[Kazee Spaces thread context]"];
335
+ lines.push(`Task: ${(task && task.title) || envelope.spaces?.title || "(untitled task)"}`);
336
+
337
+ if (task && typeof task === "object") {
338
+ const meta = [];
339
+ if (task.status) meta.push(`Status: ${task.status}`);
340
+ if (task.priority) meta.push(`Priority: ${task.priority}`);
341
+ if (task.dueDate) meta.push(`Due: ${String(task.dueDate).slice(0, 10)}`);
342
+ if (meta.length) lines.push(meta.join(" · "));
343
+
344
+ const people = this._collectPeople(task);
345
+ if (people.length) lines.push(`Assignees: ${people.join(", ")}`);
346
+
347
+ const desc = contentText(task.description || task.content || "");
348
+ if (desc) {
349
+ const clipped = desc.length > TASK_DESC_LIMIT
350
+ ? desc.slice(0, TASK_DESC_LIMIT).trimEnd() + " …[truncated]"
351
+ : desc;
352
+ lines.push(`Description: ${clipped}`);
353
+ }
354
+ }
355
+ lines.push(`(Full task + entire comment history on demand: \`spaces\` tool → read --task-id ${taskId}.)`);
356
+
357
+ // Catch-up: comments since the bot's last engaged turn, minus the triggering
358
+ // comment (already the main prompt). Advance the watermark to the newest
359
+ // comment seen so nothing is re-injected next time.
360
+ try {
361
+ const { catchUp, watermark, seenIds } = await this._commentsSince(taskId, envelope.text || "");
362
+ if (catchUp.length) {
363
+ lines.push("");
364
+ lines.push("Comments since your last reply (oldest→newest):");
365
+ for (const c of catchUp) lines.push(` [${c.ago}] ${c.who}: ${c.body}`);
366
+ }
367
+ if (watermark) { try { spacesState.setContextWatermark(taskId, watermark); } catch (e) {} }
368
+ // Read receipt: the bot has now read this thread — mark those comments seen
369
+ // so a human sees the bot on "seen by" like a teammate. Fire-and-forget.
370
+ if (seenIds && seenIds.length) this._markSeen(taskId, seenIds);
371
+ } catch (e) { /* catch-up is best-effort; metadata still ships */ }
372
+
373
+ const who = envelope.from?.name || "Someone";
374
+ lines.push("");
375
+ lines.push(`${who} just commented — reply to this:`);
376
+
377
+ return { contextBlock: lines.join("\n") };
378
+ }
379
+
380
+ // Fire-and-forget read-receipt write. Deliberately not awaited by
381
+ // buildThreadContext: the engaged reply must never wait on it, and any failure
382
+ // (a scope 403 if the bot token lacks comments.update, a transport blip) is
383
+ // swallowed. It self-verifies on the first real engage — a persistent 403 in
384
+ // the logs is the signal that the token needs the comments.update grant.
385
+ _markSeen(taskId, commentIds) {
386
+ Promise.resolve()
387
+ .then(() => this.client.markCommentsSeen({ taskId, commentIds }))
388
+ .catch((e) => console.error("spaces mark-seen:", e.message));
389
+ }
390
+
391
+ // Read-only comment delta for a task: everything newer than the stored context
392
+ // watermark, capped and clipped, with the triggering comment (matched by body)
393
+ // removed so it isn't echoed. Also returns the watermark to advance to (the
394
+ // newest comment's createdAt). Shared shape so a future activation agent can
395
+ // reuse it to reason over "what's been said since I last spoke".
396
+ async _commentsSince(taskId, triggeringText) {
397
+ let comments = [];
398
+ try { comments = await this.client.listComments(taskId, 100); }
399
+ catch (e) { return { catchUp: [], watermark: null, seenIds: [] }; }
400
+ if (!Array.isArray(comments) || !comments.length) return { catchUp: [], watermark: null, seenIds: [] };
401
+
402
+ const ts = (c) => Date.parse(c.createdAt || c.updatedAt || 0) || 0;
403
+ comments.sort((a, b) => ts(a) - ts(b));
404
+ const watermark = comments[comments.length - 1].createdAt || null;
405
+ // Every comment the bot just pulled into this turn is one it has now read —
406
+ // the id set for the read receipt. The server no-ops the bot's own and
407
+ // already-seen comments, so passing the whole thread is safe and idempotent.
408
+ const seenIds = comments.map((c) => c._id || c.id).filter(Boolean).map(String);
409
+
410
+ const since = spacesState.getContextWatermark(taskId);
411
+ const sinceMs = since ? Date.parse(since) || 0 : 0;
412
+ const trigger = contentText(triggeringText || "").trim();
413
+
414
+ const fresh = comments.filter((c) => (sinceMs ? ts(c) > sinceMs : true));
415
+ const rendered = fresh
416
+ .map((c) => {
417
+ const au = c.authorId || c.author || c.userId || {};
418
+ const who = (typeof au === "object")
419
+ ? (`${au.firstName || ""} ${au.lastName || ""}`.trim() || au.name || au.email || "?")
420
+ : String(au || "?");
421
+ const body = contentText(c.content || "");
422
+ return { who, body: body.trim(), createdAt: c.createdAt };
423
+ })
424
+ .filter((c) => c.body && c.body !== trigger)
425
+ .map((c) => ({
426
+ who: c.who,
427
+ ago: this._ago(c.createdAt),
428
+ body: c.body.length > CATCHUP_BODY_LIMIT ? c.body.slice(0, CATCHUP_BODY_LIMIT).trimEnd() + " …" : c.body,
429
+ }));
430
+
431
+ // Newest N — a long-idle thread's very first engage shows recent history, not
432
+ // the entire backlog.
433
+ const catchUp = rendered.slice(-CATCHUP_MAX);
434
+ return { catchUp, watermark, seenIds };
435
+ }
436
+
437
+ // Compact relative-time label for a comment timestamp.
438
+ _ago(iso) {
439
+ const t = Date.parse(iso || 0);
440
+ if (!t) return "?";
441
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
442
+ if (s < 60) return `${s}s ago`;
443
+ const m = Math.floor(s / 60);
444
+ if (m < 60) return `${m}m ago`;
445
+ const h = Math.floor(m / 60);
446
+ if (h < 24) return `${h}h ago`;
447
+ return `${Math.floor(h / 24)}d ago`;
448
+ }
449
+
450
+ // Gather human-readable names attached to a task, deduped. Spaces uses a
451
+ // singular `assignee` object plus optional `assignees`/`members`/`participants`
452
+ // arrays depending on the endpoint, so we defensively merge all of them.
453
+ _collectPeople(task) {
454
+ const names = [];
455
+ const seen = new Set();
456
+ const add = (u) => {
457
+ if (!u || typeof u !== "object") return;
458
+ const full = `${u.firstName || ""} ${u.lastName || ""}`.trim() || u.name || u.email;
459
+ if (!full) return;
460
+ const key = String(u.id || u._id || full);
461
+ if (seen.has(key)) return;
462
+ seen.add(key);
463
+ names.push(full);
464
+ };
465
+ add(task.assignee);
466
+ for (const m of (task.assignees || task.members || task.participants || [])) add(m);
467
+ return names;
468
+ }
469
+
283
470
  // send() posts a comment back to the task thread. channelId is
284
471
  // "task:<taskId>"; opts.mentions is an array of {userId,label}; opts.replyTo
285
472
  // threads the reply under a comment id (parentId).
@@ -300,13 +487,32 @@ class SpacesAdapter {
300
487
  }
301
488
  }
302
489
 
303
- // Spaces has no per-thread bot typing indicator, and comment edit/delete are
304
- // out of scope for v1. These keep the adapter contract total so core code can
305
- // call them unconditionally.
490
+ // Comment edit/delete are out of scope for v1. These keep the adapter contract
491
+ // total so core code can call them unconditionally.
306
492
  async edit() { /* not supported */ }
307
493
  async delete() { /* not supported */ }
308
- async typing() { /* no-op */ }
309
- async typingStop() { /* no-op */ }
494
+
495
+ // Ephemeral "Open Claudia is typing…" on the task thread. Core's typing
496
+ // heartbeat (runner.js) calls typing() every ~4s during a run and typingStop()
497
+ // at turn-end; we re-emit the same comment:typing event a human client sends,
498
+ // so teammates watching the thread see the bot compose like anyone else. The
499
+ // backend stamps identity from the authenticated socket and re-broadcasts to
500
+ // space:<id> (excluding us), so we only send targetId + spaceId + isTyping.
501
+ // Best-effort: no socket, no cached space, or a transport blip is silently a
502
+ // no-op — a typing dot must never affect the actual reply.
503
+ async typing(channelId) { this._emitTyping(channelId, true); }
504
+ async typingStop(channelId) { this._emitTyping(channelId, false); }
505
+
506
+ _emitTyping(channelId, isTyping) {
507
+ try {
508
+ if (!this._socket?.connected) return;
509
+ const taskId = String(channelId || "").replace(/^task:/, "");
510
+ if (!taskId) return;
511
+ const spaceId = this._taskSpace.get(taskId);
512
+ if (!spaceId) return;
513
+ this._socket.emit("comment:typing", { targetType: "task", targetId: taskId, spaceId, isTyping: !!isTyping });
514
+ } catch (e) { /* typing is best-effort */ }
515
+ }
310
516
  async sendVoice() { return false; }
311
517
  async sendPhoto() { return false; }
312
518
  async sendFile() { return false; }
@@ -5,10 +5,14 @@
5
5
  // tool logs a human in for a bearer; here the bot presents its own kzb_ token
6
6
  // directly (Spaces accepts it — verified via central /api/principal/verify).
7
7
  //
8
- // Everything here is READ-ONLY except createComment (the conversational reply)
9
- // the data/awareness plane never mutates Spaces state, so the poller carries
10
- // no write risk. Notification "seen" tracking is a local cursor, not a
11
- // server-side mark-read, precisely to keep that plane read-only.
8
+ // Everything here is READ-ONLY except two writes, both member/admin-tier and
9
+ // both only fired on an ENGAGED turn (never from the awareness poll):
10
+ // createComment — the conversational reply (comments.create).
11
+ // markCommentsSeen — the read receipt: marks the thread the bot just read as
12
+ // seen by the bot, so a human sees it on "seen by" like a teammate
13
+ // (comments.update; server $addToSets seenBy + broadcasts comment:seen).
14
+ // The awareness/poll plane still never mutates Spaces state — notification "seen"
15
+ // tracking there is a local cursor, not a server-side mark-read.
12
16
 
13
17
  const https = require("https");
14
18
  const http = require("http");
@@ -178,6 +182,23 @@ class SpacesClient {
178
182
  const res = await this._post("/api/comments", payload);
179
183
  return (res && (res.comment || res.data)) || res || null;
180
184
  }
185
+
186
+ // The second write: mark a set of task comments as seen by the bot — the read
187
+ // receipt. The server $addToSets the bot into each comment's seenBy (skipping
188
+ // ones it already saw or authored) and broadcasts a `comment:seen` socket
189
+ // event, so a human sees the bot on "seen by" exactly like another teammate.
190
+ // Needs the comments.update scope — the same member/admin tier the working
191
+ // createComment already relies on. Best-effort at the call site.
192
+ async markCommentsSeen({ taskId, commentIds }) {
193
+ const ids = (Array.isArray(commentIds) ? commentIds : []).map(String).filter(Boolean);
194
+ if (!ids.length) return null;
195
+ const res = await this._post("/api/comments/mark-seen", {
196
+ targetType: "task",
197
+ targetId: taskId,
198
+ commentIds: ids,
199
+ });
200
+ return res || null;
201
+ }
181
202
  }
182
203
 
183
204
  module.exports = { SpacesClient, flattenContent, contentText, buildCommentContent, asArray };
@@ -92,6 +92,27 @@ function getCursor(taskId) {
92
92
  return s.tasks[String(taskId || "")] || null;
93
93
  }
94
94
 
95
+ // Context watermark: the createdAt (ISO) of the newest comment the bot has
96
+ // already folded into an engaged turn for this task. buildThreadContext reads
97
+ // it to compute the "comments since my last reply" delta and advances it after
98
+ // each engage, so the same comment is never re-injected. Distinct from the
99
+ // notification cursor above (which tracks awareness activity, not turn context).
100
+ function setContextWatermark(taskId, iso) {
101
+ const id = String(taskId || "");
102
+ if (!id || !iso) return;
103
+ const s = load();
104
+ const cur = s.tasks[id] || { count: 0 };
105
+ cur.contextAt = String(iso);
106
+ s.tasks[id] = cur;
107
+ save();
108
+ }
109
+
110
+ function getContextWatermark(taskId) {
111
+ const s = load();
112
+ const t = s.tasks[String(taskId || "")];
113
+ return (t && t.contextAt) || null;
114
+ }
115
+
95
116
  // Mark that a task has had an agent turn emitted for it (from any source: a
96
117
  // socket/poll notification OR the reconciliation poll). The reconciliation poll
97
118
  // reads this to avoid re-engaging a task the socket already handled — dedup is
@@ -212,7 +233,8 @@ function controlSnapshot() {
212
233
  }
213
234
 
214
235
  module.exports = {
215
- markProcessed, recordCursor, getCursor, markTaskEngaged, taskEngaged, snapshot, STATE_FILE,
236
+ markProcessed, recordCursor, getCursor, setContextWatermark, getContextWatermark,
237
+ markTaskEngaged, taskEngaged, snapshot, STATE_FILE,
216
238
  markSocketConnected, markRestConnected, setCentralConnected, isConnected,
217
239
  setMuted, setTaskLoopMode, taskLoopMode, conversationalActive, controlSnapshot,
218
240
  };
package/core/handlers.js CHANGED
@@ -1055,7 +1055,7 @@ register({
1055
1055
  }
1056
1056
  send(
1057
1057
  `Recall debug: ${settings.showRecall ? "on" : "off"}\n\n` +
1058
- "When on, I post a short \"🧠 Recall this turn\" line before each reply, showing which packs/entities surfaced (and, on the discoverer engine, why). Lets you watch recall work.",
1058
+ "When on, I post a short \"🧠 Recall this turn\" line before each reply, showing which packs/entities surfaced (and, on the discoverer engine, why), plus the \"🧠 Jotted this down\" notes whenever I update long-term memory. When off, recall and memory-writes still happen — you just don't see the banners.",
1059
1059
  { keyboard: { inline_keyboard: [
1060
1060
  [{ text: "On", callback_data: "rcl:on" }, { text: "Off", callback_data: "rcl:off" }],
1061
1061
  ] } },
package/core/router.js CHANGED
@@ -456,6 +456,24 @@ async function handleText(envelope) {
456
456
  }
457
457
  }
458
458
 
459
+ // Thread-membership context. Multi-participant thread surfaces (Kazee Spaces
460
+ // tasks today; Kazee group rooms later) can supply a context block — thread
461
+ // metadata plus any messages posted since the bot last replied — so an
462
+ // engaged turn arrives knowing the whole thread, not just the one triggering
463
+ // message. The adapter owns WHAT to fetch; the router owns WHEN and HOW to
464
+ // render it. Channels without the method (Telegram, Kazee DM, voice, web) are
465
+ // untouched. Best-effort: a failure never blocks the turn.
466
+ if (typeof envelope.adapter?.buildThreadContext === "function") {
467
+ try {
468
+ const threadCtx = await envelope.adapter.buildThreadContext(envelope);
469
+ if (threadCtx && threadCtx.contextBlock) {
470
+ prompt = `${threadCtx.contextBlock}\n\n${prompt}`;
471
+ }
472
+ } catch (e) {
473
+ console.error("router: buildThreadContext:", e.message);
474
+ }
475
+ }
476
+
459
477
  return runAgent(prompt, runContext.cwd, envelope.messageId, {
460
478
  runContext,
461
479
  imagePaths,
package/core/runner.js CHANGED
@@ -1836,9 +1836,12 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1836
1836
  return service;
1837
1837
  }
1838
1838
 
1839
- async function handoffMemoryReview(result, runContext, store) {
1839
+ async function handoffMemoryReview(result, runContext, store, state) {
1840
1840
  if (!result.ok || !result.text || runContext.purpose !== "foreground") return;
1841
1841
  const recall = result.recallMetadata || {};
1842
+ // Memory is always written; the chat announcement of what was jotted is a
1843
+ // /recall-gated debug surface — silent unless the owner turned recall on.
1844
+ const showRecall = !!state?.settings?.showRecall;
1842
1845
  packReview.reviewTurn({
1843
1846
  userText: runContext.userPrompt,
1844
1847
  assistantText: result.text,
@@ -1853,7 +1856,7 @@ async function handoffMemoryReview(result, runContext, store) {
1853
1856
  return speaker ? { name: speaker.name, isOwner: !!speaker.isOwner } : null;
1854
1857
  } catch (_) { return null; }
1855
1858
  })(),
1856
- announce: (text) => chatContext.run(store, () => send(text)),
1859
+ announce: showRecall ? (text) => chatContext.run(store, () => send(text)) : null,
1857
1860
  });
1858
1861
  }
1859
1862
 
@@ -1916,7 +1919,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1916
1919
  if (!capture && service.turnObserver && runContext.purpose === "foreground") {
1917
1920
  try { service.turnObserver.onTurnSettled(!!result.ok); } catch (e) { /* best-effort */ }
1918
1921
  }
1919
- if (!internalCapture) await handoffMemoryReview(result, runContext, store);
1922
+ if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
1920
1923
  if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1921
1924
  state.settings.budget = null;
1922
1925
  try { saveState({ strict: true }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.22",
3
+ "version": "3.0.25",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,18 +32,15 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.22 approved by Sumeet 2026-07-14 ("Yeh push out"): per-thread conversations
36
- // + Spaces output hygiene. The live conversation now keys by THREAD
37
- // (transport:channelId via currentConversationKey off AsyncLocalStorage) instead
38
- // of by canonical identity alone, so Telegram DM and each Spaces task get isolated
39
- // sessions/transcripts while memory + guardrails + soul stay one global brain.
40
- // seedConversationContinuity does a one-time non-destructive COPY of the legacy
41
- // "Workspace" session/usage/history into the home conversationKey (anchored by
42
- // canonical match; secondary channels start clean). turn-observer suppresses the
43
- // Spaces <b> banner + tool-trace leak. 3.0.21 shipped the same work but its CI
44
- // build failed on THIS gate (package.json bumped without the assertion) — nothing
45
- // published under 3.0.21; 3.0.22 is the corrected re-ship.
46
- assert.strictEqual(pkg.version, "3.0.22", "release version must remain unchanged without explicit approval");
35
+ // 3.0.25 approved by Sumeet 2026-07-15 ("Ship it"): the bot now emits its own
36
+ // comment:typing on a Spaces task thread. Core's typing heartbeat drives the
37
+ // SpacesAdapter typing()/typingStop() (previously no-op stubs), which re-emit the
38
+ // same ephemeral event a human client sends, so teammates watching a task see
39
+ // "Open Claudia is typing…" while it composes a reply. Part 4 of the cross-repo
40
+ // typing-indicator work (backend/web/mobile shipped in prior deploys); the
41
+ // backend stamps identity from the authenticated socket and re-broadcasts to the
42
+ // space room, so the adapter only sends targetId + spaceId + isTyping.
43
+ assert.strictEqual(pkg.version, "3.0.25", "release version must remain unchanged without explicit approval");
47
44
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
48
45
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
49
46
  }