@inetafrica/open-claudia 3.0.20 โ 3.0.24
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 +11 -0
- package/channels/spaces/adapter.js +174 -1
- package/channels/spaces/client.js +25 -4
- package/channels/spaces/state.js +23 -1
- package/core/actions.js +3 -2
- package/core/context.js +13 -2
- package/core/handlers.js +16 -15
- package/core/identity.js +9 -0
- package/core/router.js +20 -0
- package/core/run-context.js +20 -2
- package/core/runner.js +6 -3
- package/core/scheduler.js +4 -0
- package/core/state.js +54 -2
- package/core/turn-observer.js +11 -1
- package/package.json +1 -1
- package/test-provider-gateway.js +1 -0
- package/test-provider-language.js +10 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.24 โ Spaces threads read like a group chat; memory notes go quiet unless you're watching
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **"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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
3
14
|
## v3.0.20 โ Spaces answers tags, and knows the owner
|
|
4
15
|
|
|
5
16
|
- **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;
|
|
@@ -272,7 +283,10 @@ class SpacesAdapter {
|
|
|
272
283
|
}
|
|
273
284
|
|
|
274
285
|
// Await every registered message listener (the router turn resolves when the
|
|
275
|
-
// agent run completes) so the per-task chain doesn't advance early.
|
|
286
|
+
// agent run completes) so the per-task chain doesn't advance early. Task
|
|
287
|
+
// context is no longer stitched onto envelope.text here โ the router pulls it
|
|
288
|
+
// via buildThreadContext() and renders it, keeping envelope.text pristine for
|
|
289
|
+
// command detection and the credential scrub.
|
|
276
290
|
async _deliverMessage(envelope) {
|
|
277
291
|
for (const fn of [...(this._listeners.message || [])]) {
|
|
278
292
|
try { await fn(envelope); }
|
|
@@ -280,6 +294,165 @@ class SpacesAdapter {
|
|
|
280
294
|
}
|
|
281
295
|
}
|
|
282
296
|
|
|
297
|
+
// Channel-agnostic thread-context provider (ChannelAdapter contract). The
|
|
298
|
+
// router calls this on an engaged turn and prepends the returned contextBlock
|
|
299
|
+
// to the prompt, so the agent arrives on a Spaces thread already knowing what
|
|
300
|
+
// it's looking at โ the task's metadata AND every comment posted since it last
|
|
301
|
+
// replied โ instead of answering blind to one bare comment. Two read-only GETs
|
|
302
|
+
// (task + comments); any failure returns null so the reply still goes out.
|
|
303
|
+
//
|
|
304
|
+
// "Task as a group chat the bot is a member of": untagged comments never fire a
|
|
305
|
+
// turn (ENGAGE_TYPES gates that), but they're caught up here the next time the
|
|
306
|
+
// bot IS addressed. The delta is server-derived from a watermark, so a missed
|
|
307
|
+
// poll or a restart self-heals โ the next engage still pulls the true set.
|
|
308
|
+
//
|
|
309
|
+
// The same _commentsSince() delta is the exact input a future Haiku activation
|
|
310
|
+
// agent would judge to decide whether to chip in unprompted โ left as a seam,
|
|
311
|
+
// not built.
|
|
312
|
+
async buildThreadContext(envelope) {
|
|
313
|
+
const taskId = envelope?.spaces?.taskId;
|
|
314
|
+
if (!taskId) return null;
|
|
315
|
+
|
|
316
|
+
let task = null;
|
|
317
|
+
try { task = await this.client.getTask(taskId); }
|
|
318
|
+
catch (e) { task = null; }
|
|
319
|
+
|
|
320
|
+
const lines = ["[Kazee Spaces thread context]"];
|
|
321
|
+
lines.push(`Task: ${(task && task.title) || envelope.spaces?.title || "(untitled task)"}`);
|
|
322
|
+
|
|
323
|
+
if (task && typeof task === "object") {
|
|
324
|
+
const meta = [];
|
|
325
|
+
if (task.status) meta.push(`Status: ${task.status}`);
|
|
326
|
+
if (task.priority) meta.push(`Priority: ${task.priority}`);
|
|
327
|
+
if (task.dueDate) meta.push(`Due: ${String(task.dueDate).slice(0, 10)}`);
|
|
328
|
+
if (meta.length) lines.push(meta.join(" ยท "));
|
|
329
|
+
|
|
330
|
+
const people = this._collectPeople(task);
|
|
331
|
+
if (people.length) lines.push(`Assignees: ${people.join(", ")}`);
|
|
332
|
+
|
|
333
|
+
const desc = contentText(task.description || task.content || "");
|
|
334
|
+
if (desc) {
|
|
335
|
+
const clipped = desc.length > TASK_DESC_LIMIT
|
|
336
|
+
? desc.slice(0, TASK_DESC_LIMIT).trimEnd() + " โฆ[truncated]"
|
|
337
|
+
: desc;
|
|
338
|
+
lines.push(`Description: ${clipped}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
lines.push(`(Full task + entire comment history on demand: \`spaces\` tool โ read --task-id ${taskId}.)`);
|
|
342
|
+
|
|
343
|
+
// Catch-up: comments since the bot's last engaged turn, minus the triggering
|
|
344
|
+
// comment (already the main prompt). Advance the watermark to the newest
|
|
345
|
+
// comment seen so nothing is re-injected next time.
|
|
346
|
+
try {
|
|
347
|
+
const { catchUp, watermark, seenIds } = await this._commentsSince(taskId, envelope.text || "");
|
|
348
|
+
if (catchUp.length) {
|
|
349
|
+
lines.push("");
|
|
350
|
+
lines.push("Comments since your last reply (oldestโnewest):");
|
|
351
|
+
for (const c of catchUp) lines.push(` [${c.ago}] ${c.who}: ${c.body}`);
|
|
352
|
+
}
|
|
353
|
+
if (watermark) { try { spacesState.setContextWatermark(taskId, watermark); } catch (e) {} }
|
|
354
|
+
// Read receipt: the bot has now read this thread โ mark those comments seen
|
|
355
|
+
// so a human sees the bot on "seen by" like a teammate. Fire-and-forget.
|
|
356
|
+
if (seenIds && seenIds.length) this._markSeen(taskId, seenIds);
|
|
357
|
+
} catch (e) { /* catch-up is best-effort; metadata still ships */ }
|
|
358
|
+
|
|
359
|
+
const who = envelope.from?.name || "Someone";
|
|
360
|
+
lines.push("");
|
|
361
|
+
lines.push(`${who} just commented โ reply to this:`);
|
|
362
|
+
|
|
363
|
+
return { contextBlock: lines.join("\n") };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Fire-and-forget read-receipt write. Deliberately not awaited by
|
|
367
|
+
// buildThreadContext: the engaged reply must never wait on it, and any failure
|
|
368
|
+
// (a scope 403 if the bot token lacks comments.update, a transport blip) is
|
|
369
|
+
// swallowed. It self-verifies on the first real engage โ a persistent 403 in
|
|
370
|
+
// the logs is the signal that the token needs the comments.update grant.
|
|
371
|
+
_markSeen(taskId, commentIds) {
|
|
372
|
+
Promise.resolve()
|
|
373
|
+
.then(() => this.client.markCommentsSeen({ taskId, commentIds }))
|
|
374
|
+
.catch((e) => console.error("spaces mark-seen:", e.message));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Read-only comment delta for a task: everything newer than the stored context
|
|
378
|
+
// watermark, capped and clipped, with the triggering comment (matched by body)
|
|
379
|
+
// removed so it isn't echoed. Also returns the watermark to advance to (the
|
|
380
|
+
// newest comment's createdAt). Shared shape so a future activation agent can
|
|
381
|
+
// reuse it to reason over "what's been said since I last spoke".
|
|
382
|
+
async _commentsSince(taskId, triggeringText) {
|
|
383
|
+
let comments = [];
|
|
384
|
+
try { comments = await this.client.listComments(taskId, 100); }
|
|
385
|
+
catch (e) { return { catchUp: [], watermark: null, seenIds: [] }; }
|
|
386
|
+
if (!Array.isArray(comments) || !comments.length) return { catchUp: [], watermark: null, seenIds: [] };
|
|
387
|
+
|
|
388
|
+
const ts = (c) => Date.parse(c.createdAt || c.updatedAt || 0) || 0;
|
|
389
|
+
comments.sort((a, b) => ts(a) - ts(b));
|
|
390
|
+
const watermark = comments[comments.length - 1].createdAt || null;
|
|
391
|
+
// Every comment the bot just pulled into this turn is one it has now read โ
|
|
392
|
+
// the id set for the read receipt. The server no-ops the bot's own and
|
|
393
|
+
// already-seen comments, so passing the whole thread is safe and idempotent.
|
|
394
|
+
const seenIds = comments.map((c) => c._id || c.id).filter(Boolean).map(String);
|
|
395
|
+
|
|
396
|
+
const since = spacesState.getContextWatermark(taskId);
|
|
397
|
+
const sinceMs = since ? Date.parse(since) || 0 : 0;
|
|
398
|
+
const trigger = contentText(triggeringText || "").trim();
|
|
399
|
+
|
|
400
|
+
const fresh = comments.filter((c) => (sinceMs ? ts(c) > sinceMs : true));
|
|
401
|
+
const rendered = fresh
|
|
402
|
+
.map((c) => {
|
|
403
|
+
const au = c.authorId || c.author || c.userId || {};
|
|
404
|
+
const who = (typeof au === "object")
|
|
405
|
+
? (`${au.firstName || ""} ${au.lastName || ""}`.trim() || au.name || au.email || "?")
|
|
406
|
+
: String(au || "?");
|
|
407
|
+
const body = contentText(c.content || "");
|
|
408
|
+
return { who, body: body.trim(), createdAt: c.createdAt };
|
|
409
|
+
})
|
|
410
|
+
.filter((c) => c.body && c.body !== trigger)
|
|
411
|
+
.map((c) => ({
|
|
412
|
+
who: c.who,
|
|
413
|
+
ago: this._ago(c.createdAt),
|
|
414
|
+
body: c.body.length > CATCHUP_BODY_LIMIT ? c.body.slice(0, CATCHUP_BODY_LIMIT).trimEnd() + " โฆ" : c.body,
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
// Newest N โ a long-idle thread's very first engage shows recent history, not
|
|
418
|
+
// the entire backlog.
|
|
419
|
+
const catchUp = rendered.slice(-CATCHUP_MAX);
|
|
420
|
+
return { catchUp, watermark, seenIds };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Compact relative-time label for a comment timestamp.
|
|
424
|
+
_ago(iso) {
|
|
425
|
+
const t = Date.parse(iso || 0);
|
|
426
|
+
if (!t) return "?";
|
|
427
|
+
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
|
428
|
+
if (s < 60) return `${s}s ago`;
|
|
429
|
+
const m = Math.floor(s / 60);
|
|
430
|
+
if (m < 60) return `${m}m ago`;
|
|
431
|
+
const h = Math.floor(m / 60);
|
|
432
|
+
if (h < 24) return `${h}h ago`;
|
|
433
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Gather human-readable names attached to a task, deduped. Spaces uses a
|
|
437
|
+
// singular `assignee` object plus optional `assignees`/`members`/`participants`
|
|
438
|
+
// arrays depending on the endpoint, so we defensively merge all of them.
|
|
439
|
+
_collectPeople(task) {
|
|
440
|
+
const names = [];
|
|
441
|
+
const seen = new Set();
|
|
442
|
+
const add = (u) => {
|
|
443
|
+
if (!u || typeof u !== "object") return;
|
|
444
|
+
const full = `${u.firstName || ""} ${u.lastName || ""}`.trim() || u.name || u.email;
|
|
445
|
+
if (!full) return;
|
|
446
|
+
const key = String(u.id || u._id || full);
|
|
447
|
+
if (seen.has(key)) return;
|
|
448
|
+
seen.add(key);
|
|
449
|
+
names.push(full);
|
|
450
|
+
};
|
|
451
|
+
add(task.assignee);
|
|
452
|
+
for (const m of (task.assignees || task.members || task.participants || [])) add(m);
|
|
453
|
+
return names;
|
|
454
|
+
}
|
|
455
|
+
|
|
283
456
|
// send() posts a comment back to the task thread. channelId is
|
|
284
457
|
// "task:<taskId>"; opts.mentions is an array of {userId,label}; opts.replyTo
|
|
285
458
|
// threads the reply under a comment id (parentId).
|
|
@@ -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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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 };
|
package/channels/spaces/state.js
CHANGED
|
@@ -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,
|
|
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/actions.js
CHANGED
|
@@ -575,19 +575,20 @@ async function handleAction(envelope) {
|
|
|
575
575
|
};
|
|
576
576
|
const p = presets[d.slice(3)];
|
|
577
577
|
if (!p) return;
|
|
578
|
+
const cronProject = getProjectKey(state);
|
|
578
579
|
scheduler.addJob({
|
|
579
580
|
kind: "cron",
|
|
580
581
|
adapter: envelope.adapter.id,
|
|
581
582
|
adapterType: envelope.adapter.type,
|
|
582
583
|
channelId: String(envelope.channelId),
|
|
583
584
|
canonicalUserId: envelope.canonicalUserId,
|
|
584
|
-
project:
|
|
585
|
+
project: cronProject,
|
|
585
586
|
prompt: p.prompt,
|
|
586
587
|
label: p.label,
|
|
587
588
|
source: "user",
|
|
588
589
|
schedule: p.schedule,
|
|
589
590
|
});
|
|
590
|
-
await send(`Added: ${p.label} for ${
|
|
591
|
+
await send(`Added: ${p.label} for ${cronProject}`);
|
|
591
592
|
return;
|
|
592
593
|
}
|
|
593
594
|
if (d === "cp:clear") {
|
package/core/context.js
CHANGED
|
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
|
|
|
6
6
|
|
|
7
7
|
const chatContext = new AsyncLocalStorage();
|
|
8
8
|
|
|
9
|
-
function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw }, fn) {
|
|
10
|
-
return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw }, fn);
|
|
9
|
+
function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey }, fn) {
|
|
10
|
+
return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey }, fn);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function currentStore() {
|
|
@@ -44,6 +44,16 @@ function currentTransport() {
|
|
|
44
44
|
return s ? s.transport : null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// The per-thread conversation key for this turn (e.g. "telegram:6251055967",
|
|
48
|
+
// "spaces:task:42"). Distinct from the (possibly unified) canonical speaker
|
|
49
|
+
// identity: identity says WHO is talking, conversationKey says WHICH thread โ
|
|
50
|
+
// so the owner's Telegram DM and the owner's Spaces task keep separate live
|
|
51
|
+
// conversations even though both resolve to the same canonical self.
|
|
52
|
+
function currentConversationKey() {
|
|
53
|
+
const s = currentStore();
|
|
54
|
+
return s && s.conversationKey ? s.conversationKey : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
module.exports = {
|
|
48
58
|
chatContext,
|
|
49
59
|
runInChat,
|
|
@@ -52,5 +62,6 @@ module.exports = {
|
|
|
52
62
|
currentCanonicalUserId,
|
|
53
63
|
currentUserId,
|
|
54
64
|
currentTransport,
|
|
65
|
+
currentConversationKey,
|
|
55
66
|
currentRaw,
|
|
56
67
|
};
|
package/core/handlers.js
CHANGED
|
@@ -15,7 +15,7 @@ const { register } = require("./commands");
|
|
|
15
15
|
const { send, deleteMessage } = require("./io");
|
|
16
16
|
const {
|
|
17
17
|
currentState, saveState, rootSession,
|
|
18
|
-
freshUsage, getActiveProvider, getProviderSession, setProviderSession, clearProviderSession,
|
|
18
|
+
freshUsage, getActiveProvider, getProjectKey, getProviderSession, setProviderSession, clearProviderSession,
|
|
19
19
|
getProviderSettings,
|
|
20
20
|
getProjectSessions, getLastProjectSession, userOwnsProviderSession,
|
|
21
21
|
createSessionCallbackToken, linkIdentity,
|
|
@@ -207,7 +207,7 @@ function switchProviderState(providerId, options = {}) {
|
|
|
207
207
|
};
|
|
208
208
|
state.settings.backend = provider;
|
|
209
209
|
if (Object.prototype.hasOwnProperty.call(options, "model")) state.settings.model = options.model;
|
|
210
|
-
const project = state
|
|
210
|
+
const project = getProjectKey(state);
|
|
211
211
|
if (project && !getProviderSession(state, provider, project)) {
|
|
212
212
|
const last = getLastProjectSession(state.userId, project, provider);
|
|
213
213
|
if (last) setProviderSession(state, provider, project, last.id);
|
|
@@ -237,9 +237,10 @@ function selectProjectState(project, options = {}) {
|
|
|
237
237
|
clearTransientQueue(state, "Project changed before the queued run started");
|
|
238
238
|
state.currentSession = { name: project.name.trim(), dir: project.dir || null };
|
|
239
239
|
const provider = getActiveProvider(state);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
const key = getProjectKey(state);
|
|
241
|
+
const last = provider ? getLastProjectSession(state.userId, key, provider) : null;
|
|
242
|
+
if (last) setProviderSession(state, provider, key, last.id);
|
|
243
|
+
const sessionId = getProviderSession(state, provider, key);
|
|
243
244
|
state.isFirstMessage = !sessionId;
|
|
244
245
|
const persisted = persistSessionControl(state, options, () => {
|
|
245
246
|
state.currentSession = previous.currentSession;
|
|
@@ -251,7 +252,7 @@ function selectProjectState(project, options = {}) {
|
|
|
251
252
|
|
|
252
253
|
function startNewConversation(options = {}) {
|
|
253
254
|
const state = options.state || currentState();
|
|
254
|
-
const project = state
|
|
255
|
+
const project = getProjectKey(state);
|
|
255
256
|
if (!project || (options.projectName && options.projectName !== project)) {
|
|
256
257
|
return { ok: false, error: Object.assign(new Error("The selected project changed"), { code: "STALE_PROJECT" }) };
|
|
257
258
|
}
|
|
@@ -277,7 +278,7 @@ function startNewConversation(options = {}) {
|
|
|
277
278
|
function endCurrentSession(options = {}) {
|
|
278
279
|
const state = options.state || currentState();
|
|
279
280
|
const previous = { currentSession: state.currentSession, isFirstMessage: state.isFirstMessage };
|
|
280
|
-
const project = state
|
|
281
|
+
const project = getProjectKey(state) || "Workspace";
|
|
281
282
|
clearTransientQueue(state, "The conversation ended before the queued run started");
|
|
282
283
|
state.currentSession = rootSession();
|
|
283
284
|
state.isFirstMessage = true;
|
|
@@ -292,7 +293,7 @@ function endCurrentSession(options = {}) {
|
|
|
292
293
|
function activeContinueOptions(state = currentState()) {
|
|
293
294
|
const provider = getActiveProvider(state);
|
|
294
295
|
if (!provider) return null;
|
|
295
|
-
const sessionId = getProviderSession(state, provider, state
|
|
296
|
+
const sessionId = getProviderSession(state, provider, getProjectKey(state));
|
|
296
297
|
return sessionId ? { provider, resumeSessionId: sessionId } : null;
|
|
297
298
|
}
|
|
298
299
|
|
|
@@ -301,7 +302,7 @@ function activeContinueOptions(state = currentState()) {
|
|
|
301
302
|
function startSession(name, resumeSessionId, options = {}) {
|
|
302
303
|
const state = currentState();
|
|
303
304
|
const provider = options.provider || getActiveProvider(state);
|
|
304
|
-
const projectName =
|
|
305
|
+
const projectName = getProjectKey(state);
|
|
305
306
|
|
|
306
307
|
if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend before starting a conversation.");
|
|
307
308
|
|
|
@@ -891,10 +892,10 @@ register({
|
|
|
891
892
|
const state = currentState();
|
|
892
893
|
const provider = getActiveProvider(state);
|
|
893
894
|
if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend first.");
|
|
894
|
-
const project = state
|
|
895
|
+
const project = getProjectKey(state);
|
|
895
896
|
const sessions = getProjectSessions(state.userId, project, provider)
|
|
896
897
|
.filter((session) => session.selectable !== false);
|
|
897
|
-
if (sessions.length === 0) return send("No past conversations
|
|
898
|
+
if (sessions.length === 0) return send("No past conversations in this thread.");
|
|
898
899
|
const rows = sessions.slice(0, 10).map((s) => {
|
|
899
900
|
const date = new Date(s.lastUsed).toLocaleDateString();
|
|
900
901
|
const active = getProviderSession(state, provider, project) === s.id ? " (active)" : "";
|
|
@@ -905,12 +906,12 @@ register({
|
|
|
905
906
|
sessionId: s.id,
|
|
906
907
|
});
|
|
907
908
|
return [{
|
|
908
|
-
text: `${s.title}${active} ยท ${s.provider}
|
|
909
|
+
text: `${s.title}${active} ยท ${s.provider} โ ${date}`,
|
|
909
910
|
callback_data: `ss:${token}`,
|
|
910
911
|
}];
|
|
911
912
|
});
|
|
912
|
-
rows.push([{ text: "New conversation", callback_data: `new:${
|
|
913
|
-
send(`Conversations in
|
|
913
|
+
rows.push([{ text: "New conversation", callback_data: `new:${project}` }]);
|
|
914
|
+
send(`Conversations in this thread:`, { keyboard: { inline_keyboard: rows } });
|
|
914
915
|
},
|
|
915
916
|
});
|
|
916
917
|
|
|
@@ -1054,7 +1055,7 @@ register({
|
|
|
1054
1055
|
}
|
|
1055
1056
|
send(
|
|
1056
1057
|
`Recall debug: ${settings.showRecall ? "on" : "off"}\n\n` +
|
|
1057
|
-
"When on, I post a short \"๐ง Recall this turn\" line before each reply, showing which packs/entities surfaced (and, on the discoverer engine, why).
|
|
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.",
|
|
1058
1059
|
{ keyboard: { inline_keyboard: [
|
|
1059
1060
|
[{ text: "On", callback_data: "rcl:on" }, { text: "Off", callback_data: "rcl:off" }],
|
|
1060
1061
|
] } },
|
package/core/identity.js
CHANGED
|
@@ -18,6 +18,14 @@ function defaultCanonicalForChannel(transport, channelId) {
|
|
|
18
18
|
return channelKey(transport, channelId);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// The conversation (thread) key for a channel. Deliberately identical to the
|
|
22
|
+
// pre-unification default canonical: a thread is keyed by its raw channel
|
|
23
|
+
// identity, never by the unified speaker canonical. This is what keeps each
|
|
24
|
+
// surface's live conversation separate even when the speaker is one being.
|
|
25
|
+
function conversationKeyFor(transport, channelId) {
|
|
26
|
+
return channelKey(transport, channelId);
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
function loadIdentities() {
|
|
22
30
|
const raw = readJsonWithFallback(IDENTITIES_FILE, null);
|
|
23
31
|
return {
|
|
@@ -187,6 +195,7 @@ module.exports = {
|
|
|
187
195
|
normalizeCanonicalUserId,
|
|
188
196
|
channelKey,
|
|
189
197
|
defaultCanonicalForChannel,
|
|
198
|
+
conversationKeyFor,
|
|
190
199
|
isConfiguredOwnerChannel,
|
|
191
200
|
ownerCanonical,
|
|
192
201
|
channelIsOwner,
|
package/core/router.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require("fs");
|
|
6
6
|
const path = require("path");
|
|
7
7
|
const { runInChat } = require("./context");
|
|
8
|
+
const { conversationKeyFor } = require("./identity");
|
|
8
9
|
const { dispatch } = require("./commands");
|
|
9
10
|
const { handleAction } = require("./actions");
|
|
10
11
|
const { isChatAuthorized } = require("./access");
|
|
@@ -79,6 +80,7 @@ function scope(envelope, fn) {
|
|
|
79
80
|
userId: envelope.userId,
|
|
80
81
|
transport: envelope.adapter?.type,
|
|
81
82
|
raw: envelope.raw,
|
|
83
|
+
conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId),
|
|
82
84
|
}, fn);
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -454,6 +456,24 @@ async function handleText(envelope) {
|
|
|
454
456
|
}
|
|
455
457
|
}
|
|
456
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
|
+
|
|
457
477
|
return runAgent(prompt, runContext.cwd, envelope.messageId, {
|
|
458
478
|
runContext,
|
|
459
479
|
imagePaths,
|
package/core/run-context.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
4
|
const path = require("path");
|
|
5
|
+
const { currentConversationKey } = require("./context");
|
|
5
6
|
|
|
6
7
|
function cloneSerializable(value, label = "run context value") {
|
|
7
8
|
if (value === undefined) return undefined;
|
|
@@ -23,7 +24,18 @@ function deepFreeze(value, seen = new Set()) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function projectSnapshot(state, cwd, requestedProject) {
|
|
26
|
-
|
|
27
|
+
let value = requestedProject;
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
// No explicit project โ this is a live chat turn: key the conversation
|
|
30
|
+
// (provider session) by the per-thread conversation key so each surface
|
|
31
|
+
// keeps its own session. dir stays the workspace root. Outside a turn
|
|
32
|
+
// (background jobs, tests) there is no conversation key โ fall back to the
|
|
33
|
+
// shared currentSession, preserving prior behaviour byte-for-byte.
|
|
34
|
+
const convo = currentConversationKey();
|
|
35
|
+
value = convo
|
|
36
|
+
? { name: convo, dir: state.currentSession?.dir || cwd || null }
|
|
37
|
+
: state.currentSession;
|
|
38
|
+
}
|
|
27
39
|
if (value && typeof value === "object") {
|
|
28
40
|
const dir = value.dir || value.path || cwd || null;
|
|
29
41
|
return {
|
|
@@ -146,7 +158,13 @@ function sameCapturedProject(state, runContext) {
|
|
|
146
158
|
const live = state?.currentSession;
|
|
147
159
|
const captured = runContext?.project;
|
|
148
160
|
if (!live || !captured) return !live && !captured;
|
|
149
|
-
|
|
161
|
+
// "Same project" now means "same live conversation thread". Compare the
|
|
162
|
+
// captured key against the live conversation key (falling back to the shared
|
|
163
|
+
// currentSession name outside a chat turn), so a run that resumes/compacts is
|
|
164
|
+
// matched to the thread it was admitted on โ not to whichever thread most
|
|
165
|
+
// recently touched the shared currentSession.
|
|
166
|
+
const liveName = currentConversationKey() || live.name || "";
|
|
167
|
+
return String(liveName) === String(captured.name || "")
|
|
150
168
|
&& String(live.dir || live.path || "") === String(captured.dir || captured.path || "");
|
|
151
169
|
}
|
|
152
170
|
|
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/core/scheduler.js
CHANGED
|
@@ -328,6 +328,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
328
328
|
userId,
|
|
329
329
|
transport: adapter.type,
|
|
330
330
|
raw: null,
|
|
331
|
+
// Key the resumed conversation by the job's saved thread, not the live
|
|
332
|
+
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
333
|
+
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
334
|
+
conversationKey: projectDescriptor(job).name,
|
|
331
335
|
};
|
|
332
336
|
|
|
333
337
|
return runInChat(chat, async () => {
|
package/core/state.js
CHANGED
|
@@ -20,7 +20,7 @@ const {
|
|
|
20
20
|
channelKey,
|
|
21
21
|
identities,
|
|
22
22
|
} = require("./identity");
|
|
23
|
-
const { currentChannelId, currentCanonicalUserId } = require("./context");
|
|
23
|
+
const { currentChannelId, currentCanonicalUserId, currentConversationKey } = require("./context");
|
|
24
24
|
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
25
25
|
const {
|
|
26
26
|
defaultMigrationSources,
|
|
@@ -385,7 +385,16 @@ function getActiveProvider(state) {
|
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
function getProjectKey(state, projectName) {
|
|
388
|
-
|
|
388
|
+
if (projectName !== undefined) return projectNameFrom(projectName);
|
|
389
|
+
// Conversation-per-thread: with no explicit project the caller means "the
|
|
390
|
+
// current thread", whose key is the live conversation key โ so each surface
|
|
391
|
+
// (Telegram DM, Spaces task, Kazee chat) owns a separate provider session and
|
|
392
|
+
// usage bucket, even when they resolve to one unified canonical speaker.
|
|
393
|
+
// Outside a chat turn (background jobs, CLI, tests) there is no conversation
|
|
394
|
+
// key, so we fall back to currentSession โ byte-identical to prior behaviour.
|
|
395
|
+
const convo = currentConversationKey();
|
|
396
|
+
if (convo) return convo;
|
|
397
|
+
return projectNameFrom(state?.currentSession);
|
|
389
398
|
}
|
|
390
399
|
|
|
391
400
|
function getProviderSession(state, providerId, projectName) {
|
|
@@ -558,6 +567,48 @@ function createUserState(userId) {
|
|
|
558
567
|
return state;
|
|
559
568
|
}
|
|
560
569
|
|
|
570
|
+
// Carry the legacy shared "Workspace" conversation into its per-thread key on
|
|
571
|
+
// the channel that owns it, so moving to conversation-per-thread keying never
|
|
572
|
+
// resets a live conversation. In-memory + non-destructive: the "Workspace"
|
|
573
|
+
// bucket is copied (not moved) and the natural post-run save persists it, so a
|
|
574
|
+
// restart before that save simply re-seeds deterministically. Only the "home"
|
|
575
|
+
// channel โ the one whose raw identity equals this state's canonical โ inherits
|
|
576
|
+
// it; secondary unified channels (the owner's Spaces task) start clean, which
|
|
577
|
+
// is the whole point (they must not resume another thread's session).
|
|
578
|
+
function seedConversationContinuity(state) {
|
|
579
|
+
const convo = currentConversationKey();
|
|
580
|
+
if (!convo || convo === "Workspace") return;
|
|
581
|
+
if (normalizeCanonicalUserId(convo) !== normalizeCanonicalUserId(state.userId)) return;
|
|
582
|
+
if (!isRecord(state.activeSessions)) return;
|
|
583
|
+
const legacy = state.activeSessions.Workspace;
|
|
584
|
+
if (!isRecord(legacy) || isRecord(state.activeSessions[convo])) return;
|
|
585
|
+
state.activeSessions[convo] = { ...legacy };
|
|
586
|
+
if (isRecord(state.usageBySession)) {
|
|
587
|
+
const prefix = "Workspace";
|
|
588
|
+
for (const [key, usage] of Object.entries(state.usageBySession)) {
|
|
589
|
+
if (key.startsWith(prefix) && isRecord(usage)) {
|
|
590
|
+
const moved = `${convo}${key.slice(prefix.length)}`;
|
|
591
|
+
if (!state.usageBySession[moved]) state.usageBySession[moved] = { ...usage };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
state.usageBySession = boundUsageBySession(state.usageBySession);
|
|
595
|
+
}
|
|
596
|
+
// Carry the shared "Workspace" session history (sessions.json) into the
|
|
597
|
+
// per-thread key too, so /sessions listing and resume-ownership checks โ both
|
|
598
|
+
// keyed by conversationKey โ keep the home channel's existing conversations.
|
|
599
|
+
// Best-effort and run-once: reached only on the same first call that seeds
|
|
600
|
+
// activeSessions above (later calls early-return at the activeSessions guard).
|
|
601
|
+
try {
|
|
602
|
+
const uid = normalizeCanonicalUserId(state.userId);
|
|
603
|
+
const all = loadSessions();
|
|
604
|
+
const userSessions = all[uid];
|
|
605
|
+
if (isRecord(userSessions) && Array.isArray(userSessions.Workspace) && !userSessions[convo]) {
|
|
606
|
+
userSessions[convo] = userSessions.Workspace.map((record) => ({ ...record, project: convo }));
|
|
607
|
+
saveSessions(all, { strict: false });
|
|
608
|
+
}
|
|
609
|
+
} catch (e) {}
|
|
610
|
+
}
|
|
611
|
+
|
|
561
612
|
function getUserState(userId) {
|
|
562
613
|
const id = normalizeCanonicalUserId(userId);
|
|
563
614
|
if (!userStates.has(id)) userStates.set(id, createUserState(id));
|
|
@@ -571,6 +622,7 @@ function getUserState(userId) {
|
|
|
571
622
|
}
|
|
572
623
|
}
|
|
573
624
|
state.channelId = currentChannelId() || state.channelId;
|
|
625
|
+
try { seedConversationContinuity(state); } catch (e) {}
|
|
574
626
|
return state;
|
|
575
627
|
}
|
|
576
628
|
|
package/core/turn-observer.js
CHANGED
|
@@ -32,6 +32,16 @@ function createTurnObserver({ state, store }) {
|
|
|
32
32
|
try { guarded = chatContext.run(store, () => require("./relationship").isCurrentSpeakerGuarded()); }
|
|
33
33
|
catch (e) { guarded = true; }
|
|
34
34
|
|
|
35
|
+
// Spaces is a customer-facing surface that renders neither Telegram HTML nor
|
|
36
|
+
// internal recall/tool banners meaningfully โ a <b> banner shows the literal
|
|
37
|
+
// tags. Suppress all owner-only chat visibility there; memory + graph feeding
|
|
38
|
+
// still run, only the chat emissions are muted.
|
|
39
|
+
let suppressChatVisibility = false;
|
|
40
|
+
try {
|
|
41
|
+
const adapter = chatContext.run(store, () => currentAdapter());
|
|
42
|
+
suppressChatVisibility = adapter?.type === "spaces";
|
|
43
|
+
} catch (e) { suppressChatVisibility = false; }
|
|
44
|
+
|
|
35
45
|
const packsLib = require("./packs");
|
|
36
46
|
const entitiesLib = require("./entities");
|
|
37
47
|
const toolsLib = require("./tools");
|
|
@@ -40,7 +50,7 @@ function createTurnObserver({ state, store }) {
|
|
|
40
50
|
// Deduped per turn: a node touched twice announces once.
|
|
41
51
|
const notified = new Set();
|
|
42
52
|
const notify = (key, text, opts) => {
|
|
43
|
-
if (guarded) return;
|
|
53
|
+
if (guarded || suppressChatVisibility) return;
|
|
44
54
|
if (notified.has(key)) return;
|
|
45
55
|
notified.add(key);
|
|
46
56
|
try { chatContext.run(store, () => send(text, opts).catch(() => {})); }
|
package/package.json
CHANGED
package/test-provider-gateway.js
CHANGED
|
@@ -404,6 +404,7 @@ function queueProbe() {
|
|
|
404
404
|
currentChannelId: () => store.channelId,
|
|
405
405
|
currentAdapter: () => store.adapter,
|
|
406
406
|
currentUserId: () => store.userId,
|
|
407
|
+
currentConversationKey: () => store.conversationKey || null,
|
|
407
408
|
});
|
|
408
409
|
installStub("./core/io", {
|
|
409
410
|
send: async () => ({ ok: true, messageId: "queued", editable: true }),
|
|
@@ -32,14 +32,16 @@ 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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
35
|
+
// 3.0.24 approved by Sumeet 2026-07-14: turn-context add-on (an engaged Spaces
|
|
36
|
+
// turn gets task metadata + every comment since the bot's last reply, via the
|
|
37
|
+
// channel-agnostic buildThreadContext() adapter seam the router prepends) PLUS
|
|
38
|
+
// /recall now gates the "๐ง Jotted this down" memory-write announcements โ memory
|
|
39
|
+
// still writes every turn, the chat note only surfaces when /recall is on
|
|
40
|
+
// (state.settings.showRecall), matching how the recall/tool banners already gate.
|
|
41
|
+
// 3.0.23 shipped the turn-context work but its CI build failed on THIS gate
|
|
42
|
+
// (package.json bumped without updating the assertion) โ nothing published under
|
|
43
|
+
// 3.0.23; 3.0.24 is the corrected re-ship (same pattern as 3.0.21 โ 3.0.22).
|
|
44
|
+
assert.strictEqual(pkg.version, "3.0.24", "release version must remain unchanged without explicit approval");
|
|
43
45
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
44
46
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
45
47
|
}
|