@inetafrica/open-claudia 2.9.1 → 2.10.0

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.10.0
4
+ - **Unified multi-channel identity — foundation, gated OFF by default (Phase 0).** An owned bot is meant to be one mind across every channel its owner reaches it on; today it split-brains because conversation state (session, transcript, run-lock, queue) is keyed per `transport:channelId`, so an unlinked channel (voice bridge, a stray Kazee DM) becomes a separate assistant with its own history. This release lands the identity-resolution foundation behind a single flag, `OC_UNIFIED_IDENTITY` (default **off**). **With the flag off nothing changes** — every new code path is gated, so publishing this version is behaviourally identical to v2.9.3 until an operator sets `OC_UNIFIED_IDENTITY=1` and restarts. Boot log now prints `Unified identity: on|off` for visibility.
5
+ - **Resolution seam (0.1).** `identity.canonicalForChannel` — the single chokepoint every adapter (Telegram/Kazee/voice) routes through — gains a flag-gated branch: a *configured owner channel* (one whose `transport:channelId` matches an operator-declared owner id — a `TELEGRAM_CHAT_ID`, `KAZEE_OWNER_USER_ID`, or `VOICE_OWNER_USER_ID`) with no explicit `/link` now resolves to the single owner canonical (the owner's first-linked handle, matching `people.linkHandle`'s primary convention). Because it's the one seam, state/transcript/scheduler all unify downstream for free. Owner detection uses only trusted, operator-set signals — never a guess — so a stranger's channel can never be merged by mistake (verified: a non-owner channel still resolves to its own default bucket).
6
+ - **One-time owner-state merge (0.2).** The first inbound message from a configured owner channel that was previously siloed folds that channel's pre-existing state + sessions into the owner's canonical bucket (`state.autoLinkOwnerChannel`, called from the router before any state is read). The merge is **non-destructive** (it folds, never overwrites), **idempotent** (in-memory guard plus a naturally-no-op re-migrate once the source is gone — safe across restarts), and **backs up** `state.json`/`sessions.json`/`identities.json` to `~/.open-claudia/backups/` before touching anything. It deliberately does **not** write into `identities.json`, so turning the flag back off cleanly reverts routing while the already-merged history stays with the owner.
7
+ - **Agent-space / OpenClaw ready.** No new dependencies, no new native modules, and no new *required* env or files — the Docker `:latest` image and the npm package build and run exactly as before with the flag off, so existing single-user deployments are unaffected and the multi-user owned-agent rollout can flip the flag per-deployment when ready. New `test-unified-identity.js` (wired into `npm test`) proves both flag states in isolated child processes: flag-off byte-identical resolution + inert auto-link; flag-on unification, stranger isolation, and the merge (result, idempotence, buckets folded, sessions preserved, backups written).
8
+ - **Explicitly deferred (not shipped hot):** the unified serialized-queue reply-routing fix (when the owner sends from two channels *simultaneously* under the flag, a queued message currently drains in the finishing turn's origin scope — Phase 1), per-person persona packs (Phase 2), and the independent external-speaker guardrail/enforcer (Phase 3). The flag stays off until at least the Phase 1 queue-scope fix lands, so none of these are live-exposed.
9
+
10
+ ## v2.9.3
11
+ - **Bot advertises a distinct "thinking" state before its first token (Kazee parity Phase 2).** The typing pipeline now carries an additive `state` discriminator (`'thinking' | 'typing'`) — no new socket event. The runner advertises `thinking` from the moment a turn starts until the first stream event (text delta, assistant content, tool call, or result), then flips to plain `typing`; Kazee clients render "<name> is thinking…" in the header/chat-list for that pre-token window and fall back to normal typing text otherwise. The Telegram adapter ignores `state` (its chat action has no such distinction), so the change is inert there. Pairs with the chat-central `message:interactiveUpdated` broadcast (server-authoritative button selection) landing in the same release across chat-central/web/mobile.
12
+ - **Ships the v2.9.2 work that never published.** v2.9.2 (Kazee REST media fallback when a socket send lacks a media path, plus an explicit `typing:stop` on turn end) was committed to main but no tag was ever pushed, so CI never published it. This tag carries it to npm alongside the thinking-state change.
13
+
3
14
  ## v2.9.1
4
15
  - **Post-turn reviewer no longer rewrites pack sections blind — context starvation fixed.** The reviewer prompt showed only the pack *index* (name + description), so a State rewrite was regenerated from the turn text alone and silently destroyed durable facts the turn didn't restate; worse, with no project name in the turn text it could attribute the work to the wrong pack entirely (observed live: an AQUELLE product-shot turn filed under Alpha Protein, wiping a promoted root-cause fact). The runner now passes the turn's **active packs** (recall-surfaced ∪ explicitly opened) into `reviewTurn`; the prompt includes their **full current Stance/Procedure/State + recent journal**, with two new rules: attribute updates to an active pack unless the turn clearly names another, and treat section rewrites as **merges** — carry forward every still-true fact, drop only what the turn contradicted or completed.
5
16
  - **Structural enforcement, not just prompt guidance.** `applyAction` drops section rewrites (State/Stance/Procedure) targeting any pack whose current content the reviewer did not see — journal lines still land; the announce line notes the drop. Applies to the update path, the exists-on-create path, and the versioned-duplicate fold.
package/bot.js CHANGED
@@ -203,6 +203,7 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
203
203
  console.log(`Soul: ${SOUL_FILE}`);
204
204
  console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
205
205
  console.log(`Onboarded: ${isOnboarded()}`);
206
+ console.log(`Unified identity: ${require("./core/config").UNIFIED_IDENTITY ? "ON" : "off"}`);
206
207
 
207
208
  // Notify owner that bot is back online via every adapter that knows
208
209
  // an owner channel. Kazee skipped: ownerUserId is a user id, not a
@@ -256,6 +256,9 @@ class KazeeAdapter {
256
256
  body.type = "interactive";
257
257
  body.interactive = { buttons };
258
258
  }
259
+ // Progressive delivery of the bot's own answer — tell the server this is a
260
+ // streaming edit so it skips editHistory + the "edited" marker.
261
+ if (opts.streaming) body.streaming = true;
259
262
  try {
260
263
  await this._request("PUT", `/message/${encodeURIComponent(messageId)}`, body);
261
264
  } catch (e) {
@@ -299,30 +302,56 @@ class KazeeAdapter {
299
302
  console.error("Kazee upload returned no media id:", JSON.stringify(upload).slice(0, 300));
300
303
  return false;
301
304
  }
302
- // Post the message via the v2 socket event `message:send`, which
303
- // accepts `mediaIds` referencing already-saved Media docs. We avoid
304
- // the REST sendMessage action because it tries to re-Media.create
305
- // from media_url and that call omits required schema fields
306
- // (chat/bucketName/fileName/minioPath) it always 500s.
305
+ const type = this._kazeeFileType(fileName);
306
+ // Prefer the v2 socket event `message:send` (real-time fan-out), which
307
+ // accepts `mediaIds` referencing already-saved Media docs. If the socket
308
+ // is down or the ack errors, fall back to the REST sendMessage action —
309
+ // it now accepts already-uploaded media via `media_ids` (previously it
310
+ // only took media_url and re-Media.create'd, which always 500'd).
307
311
  const payload = {
308
312
  chatId: channelId,
309
313
  content: caption || "",
310
- type: this._kazeeFileType(fileName),
314
+ type,
311
315
  findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
312
316
  mediaIds: [mediaId],
313
317
  };
314
- const res = await this._socketEmit("message:send", payload, 30000);
315
- if (res && res.code) {
316
- console.error("Kazee message:send error:", res.code, res.message);
317
- return false;
318
+ try {
319
+ const res = await this._socketEmit("message:send", payload, 30000);
320
+ if (res && res.code) {
321
+ console.error(`Kazee message:send error (${res.code}), falling back to REST:`, res.message);
322
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption);
323
+ }
324
+ return true;
325
+ } catch (sockErr) {
326
+ console.error("Kazee message:send unavailable, falling back to REST:", sockErr.message);
327
+ return await this._sendMediaViaRest(channelId, mediaId, type, caption);
318
328
  }
319
- return true;
320
329
  } catch (e) {
321
330
  console.error("Kazee sendFile error:", e.message);
322
331
  return false;
323
332
  }
324
333
  }
325
334
 
335
+ // REST fallback for posting an already-uploaded Media doc when the socket
336
+ // path is unavailable. Mirrors send()'s route (POST /chat/:id/message →
337
+ // sendMessage action) but references the saved media by id.
338
+ async _sendMediaViaRest(channelId, mediaId, type, caption) {
339
+ try {
340
+ await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, {
341
+ chat_id: channelId,
342
+ sender: this.botUserId,
343
+ content: caption || "",
344
+ type,
345
+ media_ids: [mediaId],
346
+ findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
347
+ });
348
+ return true;
349
+ } catch (e) {
350
+ console.error("Kazee REST media send failed:", e.message);
351
+ return false;
352
+ }
353
+ }
354
+
326
355
  // Promisified ack-style socket emit. chat-central v2 handlers respond
327
356
  // either with a success body or an { code, message, action } error.
328
357
  _socketEmit(event, payload, timeoutMs = 15000) {
@@ -355,8 +384,22 @@ class KazeeAdapter {
355
384
  return "file";
356
385
  }
357
386
 
358
- async typing(channelId) {
359
- try { this._socket?.emit?.("typing:start", { chatId: channelId }); } catch (e) {}
387
+ // `state` is an optional richer indicator: "thinking" (the bot is reasoning
388
+ // pre-first-token) vs the default "typing". Kazee clients render "thinking"
389
+ // distinctly; older clients ignore the field and fall back to "typing".
390
+ async typing(channelId, state) {
391
+ try {
392
+ const payload = { chatId: channelId };
393
+ if (state) payload.state = state;
394
+ this._socket?.emit?.("typing:start", payload);
395
+ } catch (e) {}
396
+ }
397
+
398
+ // Socket channels keep rendering "typing…" until an explicit stop. Telegram
399
+ // has no equivalent (its chat action self-expires), so this is Kazee-only and
400
+ // called via optional chaining from the core typing heartbeat cleanup.
401
+ async typingStop(channelId) {
402
+ try { this._socket?.emit?.("typing:stop", { chatId: channelId }); } catch (e) {}
360
403
  }
361
404
 
362
405
  async downloadMedia(media) {
@@ -351,7 +351,9 @@ class TelegramAdapter {
351
351
  }
352
352
  }
353
353
 
354
- async typing(channelId) {
354
+ // Telegram exposes only a single "typing" chat action, so the optional
355
+ // `state` (thinking vs typing) is accepted for signature parity but ignored.
356
+ async typing(channelId, _state) {
355
357
  try { await this.bot.sendChatAction(channelId, "typing"); } catch (e) {}
356
358
  }
357
359
 
package/channels/types.js CHANGED
@@ -55,7 +55,8 @@
55
55
  * delete(channelId, messageId): Promise<void>
56
56
  * sendVoice(channelId, oggPath): Promise<boolean>
57
57
  * sendFile(channelId, filePath, caption?): Promise<boolean>
58
- * typing(channelId): Promise<void>
58
+ * typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
59
+ * typingStop?(channelId): Promise<void> // optional — socket channels only
59
60
  * downloadMedia(mediaRef): Promise<localPath|null>
60
61
  * registerCommands(commands: CommandSpec[]): Promise<void>
61
62
  */
package/core/config.js CHANGED
@@ -94,6 +94,11 @@ const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL ||
94
94
  const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
95
95
  const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
96
96
  const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
97
+ // Unified multi-channel identity (Phase 0). Default OFF: when unset the bot
98
+ // behaves exactly as before — every code path added for unification is gated
99
+ // on this flag, so publishing is behaviourally inert until an operator flips
100
+ // OC_UNIFIED_IDENTITY=1 in .env and restarts.
101
+ const UNIFIED_IDENTITY = configTruthy(config.OC_UNIFIED_IDENTITY || process.env.OC_UNIFIED_IDENTITY, false);
97
102
  const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
98
103
  const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
99
104
  const WHISPER_CLI = config.WHISPER_CLI || "";
@@ -245,6 +250,7 @@ module.exports = {
245
250
  AUTO_COMPACT_TOKENS,
246
251
  MIN_COMPACT_INTERVAL_MS,
247
252
  PROJECT_TRANSCRIPTS,
253
+ UNIFIED_IDENTITY,
248
254
  TRANSCRIPT_MAX_ENTRY_CHARS,
249
255
  TRANSCRIPTS_DIR,
250
256
  WHISPER_CLI, WHISPER_MODEL, FFMPEG,
package/core/identity.js CHANGED
@@ -36,9 +36,55 @@ function saveIdentities() {
36
36
  try { fs.writeFileSync(IDENTITIES_FILE, JSON.stringify(identities, null, 2)); } catch (e) {}
37
37
  }
38
38
 
39
+ // A channel is a "configured owner channel" when its (transport, channelId)
40
+ // matches an owner identifier the operator set in .env — a telegram chat id,
41
+ // the Kazee owner user id, or the voice-bridge owner id. These are trusted,
42
+ // operator-declared signals (never guesses), so unifying them into the owner's
43
+ // canonical bucket cannot merge a stranger's state by mistake.
44
+ function isConfiguredOwnerChannel(transport, channelId) {
45
+ const t = String(transport || "").toLowerCase();
46
+ const c = String(channelId || "");
47
+ if (!c) return false;
48
+ let cfg;
49
+ try { cfg = require("./config"); } catch (e) { return false; }
50
+ if (t === "telegram") return (cfg.CHAT_IDS || []).map(String).includes(c);
51
+ if (t === "kazee") return !!cfg.config.KAZEE_OWNER_USER_ID && c === String(cfg.config.KAZEE_OWNER_USER_ID);
52
+ if (t === "voice") return c === String(cfg.config.VOICE_OWNER_USER_ID || "voice-owner");
53
+ return false;
54
+ }
55
+
56
+ // The owner's single stable canonical id = their first-linked handle's
57
+ // canonical (matching people.linkHandle's primary convention), falling back to
58
+ // the legacy telegram bootstrap id. Lazy + guarded so an early or partial
59
+ // people-module load during boot degrades to the telegram fallback rather than
60
+ // throwing on the identity-resolution hot path.
61
+ function ownerCanonical() {
62
+ try {
63
+ const people = require("./people");
64
+ const owner = (people.owners() || [])[0];
65
+ const h = owner && Array.isArray(owner.handles) ? owner.handles[0] : null;
66
+ if (h) return normalizeCanonicalUserId(h.canonicalUserId || defaultCanonicalForChannel(h.adapter, h.channelId));
67
+ } catch (e) {}
68
+ try { const { CHAT_ID } = require("./config"); return defaultCanonicalForChannel("telegram", CHAT_ID || ""); } catch (e) {}
69
+ return "";
70
+ }
71
+
39
72
  function canonicalForChannel(transport, channelId) {
40
73
  const key = channelKey(transport, channelId);
41
- return normalizeCanonicalUserId(identities.channels[key]) || defaultCanonicalForChannel(transport, channelId);
74
+ const explicit = normalizeCanonicalUserId(identities.channels[key]);
75
+ if (explicit) return explicit;
76
+ // Flag-gated (Phase 0): a configured owner channel with no explicit /link
77
+ // still resolves to the single owner canonical, so all of the owner's
78
+ // channels share one brain without a manual link. Flag OFF ⇒ byte-identical
79
+ // to prior behaviour (returns the default `transport:channelId`).
80
+ try {
81
+ const { UNIFIED_IDENTITY } = require("./config");
82
+ if (UNIFIED_IDENTITY && isConfiguredOwnerChannel(transport, channelId)) {
83
+ const owner = ownerCanonical();
84
+ if (owner) return owner;
85
+ }
86
+ } catch (e) {}
87
+ return defaultCanonicalForChannel(transport, channelId);
42
88
  }
43
89
 
44
90
  function canonicalForTelegram(chatId) {
@@ -79,6 +125,8 @@ module.exports = {
79
125
  normalizeCanonicalUserId,
80
126
  channelKey,
81
127
  defaultCanonicalForChannel,
128
+ isConfiguredOwnerChannel,
129
+ ownerCanonical,
82
130
  canonicalForChannel,
83
131
  canonicalForTelegram,
84
132
  canonicalForStoredUserKey,
package/core/io.js CHANGED
@@ -52,11 +52,11 @@ async function sendFile(filePath, caption) {
52
52
  return adapter.sendFile(channelId, filePath, caption);
53
53
  }
54
54
 
55
- async function typing() {
55
+ async function typing(state) {
56
56
  const adapter = currentAdapter();
57
57
  const channelId = currentChannelId();
58
58
  if (!adapter || !channelId) return;
59
- return adapter.typing(channelId);
59
+ return adapter.typing(channelId, state);
60
60
  }
61
61
 
62
62
  function splitMessage(text, maxLen = 4000) {
package/core/router.js CHANGED
@@ -10,7 +10,7 @@ const { handleAction } = require("./actions");
10
10
  const { isChatAuthorized } = require("./access");
11
11
  const introFlow = require("./intro-flow");
12
12
  const { send, deleteMessage } = require("./io");
13
- const { currentState } = require("./state");
13
+ const { currentState, autoLinkOwnerChannel } = require("./state");
14
14
  const { runClaude } = require("./runner");
15
15
  const { transcribeAudio } = require("./media");
16
16
  const { vault } = require("./vault-store");
@@ -55,6 +55,11 @@ function scope(envelope, fn) {
55
55
  async function onMessage(envelope) {
56
56
  return scope(envelope, async () => {
57
57
  try {
58
+ // Phase 0 unified identity (flag-gated; a no-op when the flag is off):
59
+ // fold a configured owner channel's pre-existing siloed state into the
60
+ // owner's canonical bucket on first contact, before any state is read.
61
+ try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
62
+
58
63
  // While a channel wizard is open, capture text input before slash
59
64
  // dispatch — except for /cancel, which always bails out.
60
65
  if (channelWizard.isAwaiting(envelope.canonicalUserId)) {
package/core/runner.js CHANGED
@@ -812,12 +812,30 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
812
812
  // receipt through the recall/discoverer phase and into streaming, not just
813
813
  // for the ~5s a single typing action lasts. Stored on state so /stop can
814
814
  // clear it instantly. Cleared on close/error/cancel.
815
+ // While true, the bot is reasoning before its first output token, so the
816
+ // typing heartbeat advertises the richer "thinking" state. Flipped to false
817
+ // the moment the first assistant text / tool_use / text_delta arrives.
818
+ state.thinkingPhase = true;
815
819
  const startTyping = () => {
816
820
  if (!adapter || !channelId) return;
817
- adapter.typing(channelId).catch(() => {});
818
- if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId).catch(() => {}), 4000);
821
+ adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
822
+ if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {}), 4000);
823
+ };
824
+ // Called on the first real output token to leave the thinking phase and, if
825
+ // the turn is still live, immediately advertise plain "typing" so the client
826
+ // flips without waiting for the next 4s heartbeat.
827
+ const endThinking = () => {
828
+ if (!state.thinkingPhase) return;
829
+ state.thinkingPhase = false;
830
+ if (adapter && channelId && state.typingHeartbeat) adapter.typing(channelId).catch(() => {});
831
+ };
832
+ const stopTyping = () => {
833
+ state.thinkingPhase = false;
834
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
835
+ // Telegram's chat action auto-expires, but socket-based channels (Kazee)
836
+ // keep showing "typing…" until told to stop. Optional on the adapter.
837
+ if (adapter && channelId) { try { adapter.typingStop?.(channelId); } catch (e) {} }
819
838
  };
820
- const stopTyping = () => { if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; } };
821
839
  // Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
822
840
  // it sets state.cancelRequested and we bail at the checkpoint before spawning.
823
841
  state.preparingRun = true;
@@ -1173,7 +1191,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1173
1191
  if (!state.statusMessageId && assistantText) {
1174
1192
  state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
1175
1193
  } else if (state.statusMessageId) {
1176
- await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts());
1194
+ await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
1177
1195
  }
1178
1196
  lastUpdate = display;
1179
1197
  }
@@ -1195,6 +1213,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1195
1213
  const lastNewline = state.streamBuffer.lastIndexOf("\n");
1196
1214
  state.streamBuffer = lastNewline >= 0 ? state.streamBuffer.slice(lastNewline + 1) : state.streamBuffer;
1197
1215
  for (const evt of events) {
1216
+ // Leave the "thinking" phase the moment the model produces any real
1217
+ // output — streamed text, a finished assistant block, a tool call, or a
1218
+ // terminal result. Idempotent (guards on state.thinkingPhase internally).
1219
+ if (state.thinkingPhase && (
1220
+ (evt.type === "stream_event" && evt.event?.type === "content_block_delta" && evt.event.delta?.type === "text_delta") ||
1221
+ (evt.type === "assistant" && evt.message?.content) ||
1222
+ (evt.type === "tool_call" && evt.subtype === "started") ||
1223
+ evt.type === "result"
1224
+ )) {
1225
+ endThinking();
1226
+ }
1198
1227
  // Voice latency probe: first "system" event = CLI ready (cold-start done);
1199
1228
  // "result" = generation finished (before the TTS tail drains).
1200
1229
  if (voiceStreaming && vlFirstSysAt == null && evt.type === "system") vlFirstSysAt = Date.now();
@@ -1399,7 +1428,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1399
1428
  const firstChunk = chunks[0];
1400
1429
 
1401
1430
  if (state.statusMessageId && chunks.length === 1) {
1402
- await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts());
1431
+ await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
1403
1432
  } else {
1404
1433
  const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
1405
1434
  if (!sent) await send(firstChunk);
package/core/state.js CHANGED
@@ -3,12 +3,18 @@
3
3
  // linked channels share the same conversation, settings, and usage.
4
4
 
5
5
  const fs = require("fs");
6
- const { STATE_FILE, SESSIONS_FILE, CHAT_ID } = require("./config");
6
+ const path = require("path");
7
+ const { STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE, CONFIG_DIR, CHAT_ID, UNIFIED_IDENTITY } = require("./config");
7
8
  const {
8
9
  normalizeCanonicalUserId,
9
10
  canonicalForTelegram,
10
11
  canonicalForStoredUserKey,
11
12
  setIdentityMapping,
13
+ isConfiguredOwnerChannel,
14
+ ownerCanonical,
15
+ defaultCanonicalForChannel,
16
+ channelKey,
17
+ identities,
12
18
  } = require("./identity");
13
19
  const { currentChannelId, currentCanonicalUserId } = require("./context");
14
20
 
@@ -245,6 +251,47 @@ function linkIdentity(transport, channelId, canonicalUserId) {
245
251
  return result;
246
252
  }
247
253
 
254
+ // ── Phase 0 unified identity: one-time owner-channel merge ───────────
255
+ // When the unified-identity flag is on, the first inbound message from a
256
+ // configured owner channel that was never explicitly /linked folds its
257
+ // pre-existing siloed state into the owner's canonical bucket. Idempotent
258
+ // (in-memory guard + a naturally-no-op migrate once the source is moved),
259
+ // non-destructive (merges, and backs up state/sessions/identities first), and
260
+ // completely inert when the flag is off. Deliberately does NOT write into
261
+ // identities.json — resolution comes from the flag+config path in
262
+ // identity.canonicalForChannel, so turning the flag back off cleanly reverts
263
+ // routing while the already-merged history stays with the owner.
264
+ const autoLinkedOwnerChannels = new Set();
265
+
266
+ function backupIdentityState() {
267
+ try {
268
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
269
+ const dir = path.join(CONFIG_DIR, "backups");
270
+ fs.mkdirSync(dir, { recursive: true });
271
+ for (const f of [STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE]) {
272
+ try { if (fs.existsSync(f)) fs.copyFileSync(f, path.join(dir, `${path.basename(f)}.${stamp}.bak`)); } catch (e) {}
273
+ }
274
+ } catch (e) {}
275
+ }
276
+
277
+ function autoLinkOwnerChannel(transport, channelId) {
278
+ if (!UNIFIED_IDENTITY) return null;
279
+ if (!isConfiguredOwnerChannel(transport, channelId)) return null;
280
+ const key = channelKey(transport, channelId);
281
+ if (autoLinkedOwnerChannels.has(key)) return null;
282
+ autoLinkedOwnerChannels.add(key);
283
+ // An explicit /link already unifies this channel — nothing to migrate.
284
+ if (Object.prototype.hasOwnProperty.call(identities.channels, key)) return null;
285
+ const to = ownerCanonical();
286
+ const from = defaultCanonicalForChannel(transport, channelId);
287
+ if (!to || from === to) return null;
288
+ const hasState = !!(savedState.users && savedState.users[from]) || !!loadSessions()[from];
289
+ if (!hasState) return null;
290
+ backupIdentityState();
291
+ migrateUserData(from, to);
292
+ return { from, to };
293
+ }
294
+
248
295
  module.exports = {
249
296
  userStates,
250
297
  savedState,
@@ -264,4 +311,5 @@ module.exports = {
264
311
  userOwnsClaudeSession,
265
312
  migrateUserData,
266
313
  linkIdentity,
314
+ autoLinkOwnerChannel,
267
315
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",