@inetafrica/open-claudia 3.0.19 → 3.0.22

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.20 — Spaces answers tags, and knows the owner
4
+
5
+ - **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.
6
+ - **The owner is recognised on Spaces.** Spaces principals are central/Kazee user ids, but `isConfiguredOwnerChannel` had no `spaces` branch, so the owner's own comments resolved to a fresh `spaces:<id>` canonical instead of their owner identity — classing the owner as **external** (default-deny) on every Spaces task. Added a `spaces` branch mapping to `KAZEE_OWNER_USER_ID` (the same id Spaces/Kazee/central share), so the owner is recognised, their Spaces turns unify into their one canonical brain, and the relationship guardrail no longer misfires on them.
7
+ - **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.
8
+
3
9
  ## v3.0.19 — flattened to the top level (the folder-project model is gone)
4
10
 
5
11
  - **The bot always runs at the top-level workspace now — no more "Pick a project first."** The per-folder session model (pick a subfolder of `WORKSPACE`, scope the conversation to it) has been removed. `currentSession` defaults to the workspace root (`{ name: "Workspace", dir: WORKSPACE }`) at state creation and on every migration/load, so it is never null. This directly fixes Spaces (and any non-Telegram channel that never picked a folder) replying with a dead "Pick a project first:" project-picker instead of engaging — the picker gate was the only thing blocking it.
@@ -21,8 +21,14 @@ const { SpacesClient, contentText } = require("./client");
21
21
  const { createSpacesSocket } = require("./socket");
22
22
  const spacesState = require("./state");
23
23
 
24
- // Notification `type`/`category` values that warrant a full agent turn.
25
- const ENGAGE_TYPES = new Set(["mention", "reply", "task_assigned"]);
24
+ // Notification `type`/`category` values that warrant a full agent turn. The
25
+ // Spaces server emits PLURAL categories (mentions, comments, reactions,
26
+ // task_updated), so the engage set must match the plural forms — matching only
27
+ // the singular "mention" silently dropped every real tag. Singular aliases are
28
+ // kept in case a socket payload ever uses them. Awareness-only types (comments,
29
+ // reactions, task_updated) are deliberately absent: they update the cursor but
30
+ // never auto-reply.
31
+ const ENGAGE_TYPES = new Set(["mention", "mentions", "reply", "replies", "task_assigned"]);
26
32
 
27
33
  class SpacesAdapter {
28
34
  constructor({ id = "spaces", url = "https://api.spaces.kazee.africa", token, ownerUserId = "", taskLoopMode = "advisory", pollMs }) {
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: state.currentSession.name,
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 ${state.currentSession.name}`);
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.currentSession?.name || null;
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 last = provider ? getLastProjectSession(state.userId, state.currentSession.name, provider) : null;
241
- if (last) setProviderSession(state, provider, state.currentSession.name, last.id);
242
- const sessionId = getProviderSession(state, provider, state.currentSession.name);
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.currentSession?.name || null;
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.currentSession?.name || "Workspace";
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.currentSession?.name);
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 = "Workspace";
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.currentSession.name;
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 for this project.");
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} · ${project} — ${date}`,
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:${state.currentSession.name}` }]);
913
- send(`Conversations in ${state.currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
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
 
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 {
@@ -46,6 +54,10 @@ function isConfiguredOwnerChannel(transport, channelId) {
46
54
  try { cfg = require("./config"); } catch (e) { return false; }
47
55
  if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
48
56
  if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
57
+ // Spaces principals are central/Kazee user ids, so the owner's Spaces actor id
58
+ // is the same configured Kazee owner id — recognise them on Spaces too, else
59
+ // the owner's own comments are classed external (default-deny).
60
+ if (t === "spaces") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
49
61
  if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
50
62
  // Web dashboard chat: fixed single-owner channel gated by the dashboard
51
63
  // session, so its identity is operator-implied rather than env-declared.
@@ -183,6 +195,7 @@ module.exports = {
183
195
  normalizeCanonicalUserId,
184
196
  channelKey,
185
197
  defaultCanonicalForChannel,
198
+ conversationKeyFor,
186
199
  isConfiguredOwnerChannel,
187
200
  ownerCanonical,
188
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
 
@@ -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
- const value = requestedProject !== undefined ? requestedProject : state.currentSession;
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
- return String(live.name || "") === String(captured.name || "")
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/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
- return projectNameFrom(projectName === undefined ? state?.currentSession : projectName);
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.19",
3
+ "version": "3.0.22",
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": {
@@ -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,15 +32,18 @@ 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.19 approved by Sumeet 2026-07-14 ("Rip it out"): the per-folder
36
- // project/session model is removed. The bot always runs at the top-level
37
- // workspace currentSession defaults to { name: "Workspace", dir: WORKSPACE }
38
- // and is never null which fixes Spaces (and any channel that never picked a
39
- // folder) replying with a dead "Pick a project first:" picker. Removed
40
- // /projects, /session, the project keyboard, the show:projects/s: callbacks,
41
- // the router's five session gates, and core/projects.js. Conversation-level
42
- // controls (/sessions, /new, /continue, /end) are unchanged.
43
- assert.strictEqual(pkg.version, "3.0.19", "release version must remain unchanged without explicit approval");
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");
44
47
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
45
48
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
46
49
  }