@inetafrica/open-claudia 2.9.3 → 2.11.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.11.0
4
+ - **Unified multi-channel identity is now LIVE by default (Phases 0 + 1).** v2.10.0 shipped the split-brain fix inert behind `OC_UNIFIED_IDENTITY` (default off). This release flips the default **on** and lands the Phase 1 queue-routing fix that made it safe to do so. The owner's Telegram, Kazee and voice channels now resolve to one canonical person out of the box — one state, one session, one transcript, one run-lock — so the bot stops fracturing into a separate assistant per channel. The flag stays as an operator **kill-switch** (`OC_UNIFIED_IDENTITY=0` + restart) for reversibility (risk R14); it is no longer a hold-back gate.
5
+ - **Person-scoped queue routing (Phase 1, 1.2) — the blocker that kept the flag off.** Under unified identity the owner's channels share one run-lock and message queue, so a message sent from one channel while a run is in flight on another used to drain in the *finishing* turn's async scope — a Kazee follow-up could be answered in the Telegram chat. Each queued message now **captures its origin channel** at enqueue time, and `drainQueuedMessages` peels off one same-origin run at a time (requeuing the rest so the natural close→drain tail-recursion handles the next group), scoping each drained reply to `chatContext.run(origin, …)`. Replies always route back to the channel the message came from; same-origin batches behave exactly as before. Grouping logic extracted to a pure, unit-tested `core/queue-drain.js`.
6
+ - **Cross-channel dedup (0.3).** `router.isDuplicate` is re-keyed from `adapter:channel:msg` to `canonical:adapter:msg` — a message redelivered against a different channel that resolves to the same person is now caught once the owner's channels are unified (adapter stays in the key, so distinct messages that merely share a numeric id across transports are never collapsed).
7
+ - **First-contact migration (0.2 / 1.1) fires automatically.** With the flag on, the first inbound message from a previously-siloed owner channel folds its pre-existing state + sessions into the owner bucket — non-destructive, idempotent, and backed up to `~/.open-claudia/backups/` first (unchanged from v2.10.0, now actually reached). Pre-migration per-channel transcripts remain as separate files and stay reachable via `transcript-search --all`; new history accretes under the unified person id (1.3).
8
+ - **Tests.** `test-unified-identity.js` gains a `default` (unset ⇒ on) child to lock the new default and proves an explicit `0` still reverts to strict per-channel resolution; new `test-queue-routing.js` covers origin keying, same-origin batching, mixed-origin split/requeue, and legacy (pre-capture) fallback. Full suite green.
9
+ - **Agent-space / OpenClaw:** no new deps or required env; Docker image builds identically. Existing single-owner deployments see unified behaviour for the owner's own channels only — strangers still resolve to their own isolated bucket (the per-person guardrail arrives in Phase 3).
10
+
11
+ ## v2.10.0
12
+ - **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.
13
+ - **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).
14
+ - **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.
15
+ - **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).
16
+ - **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.
17
+
3
18
  ## v2.9.3
4
19
  - **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.
5
20
  - **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.
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
package/core/config.js CHANGED
@@ -94,6 +94,12 @@ 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. Default ON: the owner's Telegram, Kazee and
98
+ // voice channels resolve to one canonical person, sharing one state, session,
99
+ // transcript and run-lock (the split-brain fix). The flag remains as an
100
+ // operator kill-switch for reversibility (R14) — set OC_UNIFIED_IDENTITY=0 in
101
+ // .env and restart to fall back to strict per-channel behaviour.
102
+ const UNIFIED_IDENTITY = configTruthy(config.OC_UNIFIED_IDENTITY || process.env.OC_UNIFIED_IDENTITY, true);
97
103
  const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
98
104
  const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
99
105
  const WHISPER_CLI = config.WHISPER_CLI || "";
@@ -245,6 +251,7 @@ module.exports = {
245
251
  AUTO_COMPACT_TOKENS,
246
252
  MIN_COMPACT_INTERVAL_MS,
247
253
  PROJECT_TRANSCRIPTS,
254
+ UNIFIED_IDENTITY,
248
255
  TRANSCRIPT_MAX_ENTRY_CHARS,
249
256
  TRANSCRIPTS_DIR,
250
257
  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,
@@ -0,0 +1,25 @@
1
+ // Pure helpers for origin-aware queue draining. Kept separate from the runner
2
+ // so the grouping logic — which decides where a drained reply is routed under
3
+ // unified identity — is unit-testable without spawning a run.
4
+
5
+ // A queued message's origin channel, or a sentinel for legacy items that were
6
+ // queued before origin capture existed (those fall back to the finishing turn's
7
+ // context, exactly as before).
8
+ function originKey(item) {
9
+ return item && item.origin ? `${item.origin.transport}:${item.origin.channelId}` : "__current__";
10
+ }
11
+
12
+ // Split the leading run of same-origin messages off the front of the queue.
13
+ // Returns { group, rest }: `group` is the contiguous same-origin prefix (to run
14
+ // now, scoped to that origin), `rest` is everything after it (to be requeued so
15
+ // the natural close→drain tail-recursion handles the next origin group). For a
16
+ // non-empty queue, `group` always has at least one item.
17
+ function splitLeadingOriginGroup(queue) {
18
+ if (!Array.isArray(queue) || queue.length === 0) return { group: [], rest: [] };
19
+ const firstKey = originKey(queue[0]);
20
+ let i = 0;
21
+ while (i < queue.length && originKey(queue[i]) === firstKey) i++;
22
+ return { group: queue.slice(0, i), rest: queue.slice(i) };
23
+ }
24
+
25
+ module.exports = { originKey, splitLeadingOriginGroup };
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");
@@ -26,11 +26,14 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
26
26
  const channelWizard = require("./channel-wizard");
27
27
  require("./handlers"); // side-effect: register slash commands
28
28
 
29
- // Per-adapter dedup. Telegram message_ids are unique per chat, not
30
- // globally; same goes for Kazee. Namespace by adapter:channel:msg.
29
+ // Cross-channel dedup. Message ids are unique per adapter, not globally, so
30
+ // the adapter stays in the key. Keying by canonical (not raw channelId) means
31
+ // a message redelivered against a different channel that resolves to the same
32
+ // person is still caught once the owner's channels are unified.
31
33
  const processedMessages = new Set();
32
34
  function isDuplicate(envelope) {
33
- const key = `${envelope.adapter?.id || envelope.adapter?.type}:${envelope.channelId}:${envelope.messageId}`;
35
+ const canonical = envelope.canonicalUserId || envelope.channelId;
36
+ const key = `${canonical}:${envelope.adapter?.id || envelope.adapter?.type}:${envelope.messageId}`;
34
37
  if (processedMessages.has(key)) return true;
35
38
  processedMessages.add(key);
36
39
  if (processedMessages.size > 400) {
@@ -55,6 +58,11 @@ function scope(envelope, fn) {
55
58
  async function onMessage(envelope) {
56
59
  return scope(envelope, async () => {
57
60
  try {
61
+ // Phase 0 unified identity (flag-gated; a no-op when the flag is off):
62
+ // fold a configured owner channel's pre-existing siloed state into the
63
+ // owner's canonical bucket on first contact, before any state is read.
64
+ try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
65
+
58
66
  // While a channel wizard is open, capture text input before slash
59
67
  // dispatch — except for /cancel, which always bails out.
60
68
  if (channelWizard.isAwaiting(envelope.canonicalUserId)) {
package/core/runner.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  } = require("./config");
14
14
  const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
15
15
  const { chatContext, currentChannelId, currentAdapter } = require("./context");
16
+ const { splitLeadingOriginGroup } = require("./queue-drain");
16
17
  const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
17
18
  const { redactSensitive } = require("./redact");
18
19
  const { send, editMessage, sendVoice, sendVoiceEnd, splitMessage } = require("./io");
@@ -782,7 +783,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
782
783
  cwd = resolveRunCwd(cwd);
783
784
 
784
785
  if (state.runningProcess) {
785
- state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now() });
786
+ // Capture the origin channel so the drained reply routes back to where the
787
+ // message came from. Under unified identity the owner's channels share one
788
+ // run-lock and queue, so a Kazee message queued during a Telegram run must
789
+ // still be answered on Kazee — not wherever the finishing turn happens to be.
790
+ state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now(), origin: store ? { ...store } : null });
786
791
  await send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId });
787
792
  return;
788
793
  }
@@ -1132,10 +1137,19 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1132
1137
  };
1133
1138
 
1134
1139
  const drainQueuedMessages = async () => {
1135
- if (state.messageQueue.length > 0 && state.currentSession) {
1136
- const drained = state.messageQueue.splice(0);
1137
- if (drained.length === 1) {
1138
- const only = drained[0];
1140
+ if (state.messageQueue.length === 0 || !state.currentSession) return;
1141
+ const drained = state.messageQueue.splice(0);
1142
+ // Handle one same-origin run at a time; requeue the remainder so the
1143
+ // finishing turn's close→drain tail-recursion picks up the next origin
1144
+ // group. Each group's reply is scoped to the channel it came from, so a
1145
+ // message queued from one of the owner's channels during a run on another
1146
+ // is still answered on its own channel (unified identity, one run-lock).
1147
+ const { group, rest } = splitLeadingOriginGroup(drained);
1148
+ if (rest.length) state.messageQueue.unshift(...rest);
1149
+
1150
+ const runGroup = async () => {
1151
+ if (group.length === 1) {
1152
+ const only = group[0];
1139
1153
  await runClaude(only.prompt, state.currentSession.dir, only.replyToMsgId, only.opts);
1140
1154
  } else {
1141
1155
  const fmtTime = (ts) => {
@@ -1145,16 +1159,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1145
1159
  const ss = String(d.getSeconds()).padStart(2, "0");
1146
1160
  return `${hh}:${mm}:${ss}`;
1147
1161
  };
1148
- const numbered = drained.map((m, i) => `[${i + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1162
+ const numbered = group.map((m, idx) => `[${idx + 1}] (${fmtTime(m.queuedAt || Date.now())}) ${m.prompt}`).join("\n\n");
1149
1163
  const batched =
1150
- `While you were working the user sent these ${drained.length} follow-up messages (oldest first):\n\n` +
1164
+ `While you were working the user sent these ${group.length} follow-up messages (oldest first):\n\n` +
1151
1165
  `${numbered}\n\n` +
1152
1166
  `Treat each as a distinct follow-up request. Add them to your plan and handle them; ` +
1153
1167
  `if any contradicts an earlier one, prefer the newer one and call out the conflict.`;
1154
- const last = drained[drained.length - 1];
1168
+ const last = group[group.length - 1];
1155
1169
  await runClaude(batched, state.currentSession.dir, last.replyToMsgId, last.opts);
1156
1170
  }
1157
- }
1171
+ };
1172
+
1173
+ const origin = group[0].origin;
1174
+ if (origin) await chatContext.run(origin, runGroup);
1175
+ else await runGroup();
1158
1176
  };
1159
1177
 
1160
1178
  const updateProgress = async () => {
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.3",
3
+ "version": "2.11.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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -50,7 +50,10 @@
50
50
  "test-tool-guard.js",
51
51
  "test-tooling-mode.js",
52
52
  "test-tool-manifest.js",
53
- "test-approval-async.js"
53
+ "test-approval-async.js",
54
+ "test-recall-evolution.js",
55
+ "test-unified-identity.js",
56
+ "test-queue-routing.js"
54
57
  ],
55
58
  "keywords": [
56
59
  "claude",
@@ -0,0 +1,52 @@
1
+ // Origin-aware queue draining. Under unified identity the owner's channels
2
+ // share one run-lock and message queue, so a message queued from one channel
3
+ // during a run on another must still be answered on its own channel. These
4
+ // tests lock in the grouping that guarantees that routing.
5
+
6
+ const assert = require("assert");
7
+ const { originKey, splitLeadingOriginGroup } = require("./core/queue-drain");
8
+
9
+ const tg = (id) => ({ prompt: `m${id}`, origin: { transport: "telegram", channelId: "6251055967" } });
10
+ const kz = (id) => ({ prompt: `m${id}`, origin: { transport: "kazee", channelId: "abc" } });
11
+ const legacy = (id) => ({ prompt: `m${id}` }); // queued before origin capture
12
+
13
+ // originKey
14
+ assert.strictEqual(originKey(tg(1)), "telegram:6251055967", "telegram key");
15
+ assert.strictEqual(originKey(kz(1)), "kazee:abc", "kazee key");
16
+ assert.strictEqual(originKey(legacy(1)), "__current__", "legacy → sentinel");
17
+
18
+ // Empty queue
19
+ assert.deepStrictEqual(splitLeadingOriginGroup([]), { group: [], rest: [] }, "empty");
20
+
21
+ // All one origin → single group, no remainder (the common case: preserves the
22
+ // prior single-batch behaviour, now scoped to that origin).
23
+ {
24
+ const q = [tg(1), tg(2), tg(3)];
25
+ const { group, rest } = splitLeadingOriginGroup(q);
26
+ assert.strictEqual(group.length, 3, "same-origin: all grouped");
27
+ assert.strictEqual(rest.length, 0, "same-origin: nothing requeued");
28
+ }
29
+
30
+ // Mixed origins → only the leading same-origin run comes out; the rest is
31
+ // requeued so the next drain routes it to its own channel.
32
+ {
33
+ const q = [tg(1), tg(2), kz(3), tg(4)];
34
+ const { group, rest } = splitLeadingOriginGroup(q);
35
+ assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "leading telegram group");
36
+ assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3", "m4"], "kazee + later telegram requeued");
37
+ // Draining the remainder next peels off the kazee message on its own.
38
+ const next = splitLeadingOriginGroup(rest);
39
+ assert.deepStrictEqual(next.group.map((m) => m.prompt), ["m3"], "next drain: kazee alone");
40
+ assert.deepStrictEqual(next.rest.map((m) => m.prompt), ["m4"], "next drain: trailing telegram requeued");
41
+ }
42
+
43
+ // Legacy items (no origin) group together under the sentinel and fall back to
44
+ // the finishing turn's context — matching pre-change behaviour.
45
+ {
46
+ const q = [legacy(1), legacy(2), tg(3)];
47
+ const { group, rest } = splitLeadingOriginGroup(q);
48
+ assert.deepStrictEqual(group.map((m) => m.prompt), ["m1", "m2"], "legacy grouped");
49
+ assert.deepStrictEqual(rest.map((m) => m.prompt), ["m3"], "origin item requeued");
50
+ }
51
+
52
+ console.log("queue-routing OK");
@@ -0,0 +1,214 @@
1
+ // v2.6.59 evolution: dream-tunable knobs (bounded, env-pinned, revertible),
2
+ // walker cascade verdicts, per-node evidence, windowed health deltas, delta
3
+ // dreaming (dump selection + merge clusters), reviewer skip-gate, journal
4
+ // backfill dedupe, and version-stamped KPI aggregation.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ // Redirect EVERYTHING before any require: CONFIG_DIR derives from HOME, and
11
+ // metrics._resetForTest() deletes files — must never touch live telemetry.
12
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-evolution-"));
13
+ process.env.HOME = tmp;
14
+ process.env.PACKS_DIR = path.join(tmp, "packs");
15
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
16
+ delete process.env.RECALL_WALKER_MAX_CANDIDATES;
17
+
18
+ const tuning = require("./core/recall/tuning");
19
+ const metrics = require("./core/recall/metrics");
20
+ const disc = require("./core/recall/discoverer");
21
+ const review = require("./core/pack-review");
22
+ const dream = require("./core/dream");
23
+ const kpi = require("./core/recall/kpi");
24
+ const packs = require("./core/packs");
25
+
26
+ // ── tuning: bounds, precedence, revert ──
27
+ tuning._resetForTest();
28
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 14, "default value");
29
+ assert.strictEqual(tuning.get("cascade"), "on", "enum default");
30
+ assert.strictEqual(tuning.get("nope"), undefined, "unknown knob → undefined");
31
+
32
+ const ch = tuning.set("walkerMaxCandidates", 18, { reason: "test", evidence: "unit" });
33
+ assert.deepStrictEqual({ knob: ch.knob, from: ch.from, to: ch.to }, { knob: "walkerMaxCandidates", from: 14, to: 18 });
34
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 18, "set applies");
35
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 18), null, "no-op set rejected");
36
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 99).to, 20, "clamped to max");
37
+ assert.strictEqual(tuning.set("bogusKnob", 5), null, "unknown knob rejected");
38
+ assert.strictEqual(tuning.set("cascade", "sideways"), null, "illegal enum clamps to default → no-op");
39
+ assert.strictEqual(tuning.get("cascade") === "on" || tuning.get("cascade") === "off", true);
40
+
41
+ process.env.RECALL_WALKER_MAX_CANDIDATES = "10";
42
+ assert.strictEqual(tuning.get("walkerMaxCandidates"), 10, "env pin wins over tuning file");
43
+ assert.strictEqual(tuning.set("walkerMaxCandidates", 16), null, "env-pinned knob refuses dream writes");
44
+ delete process.env.RECALL_WALKER_MAX_CANDIDATES;
45
+
46
+ const rv = tuning.revert("walkerMaxCandidates", { reason: "test rollback" });
47
+ assert.strictEqual(rv.to, 18, "revert restores the value before the last change");
48
+ assert.ok(tuning.history().some((h) => /^rollback:/.test(h.reason)), "rollback recorded in history");
49
+ assert.ok(tuning.lastChange() && !/^rollback:/.test(tuning.lastChange().reason), "lastChange skips rollbacks");
50
+
51
+ // ── cascade verdicts: conservative, evidence-only ──
52
+ assert.strictEqual(disc.cascadeVerdict(null), null, "no history → judge");
53
+ assert.strictEqual(disc.cascadeVerdict({ seen: 6, kept: 0, open: 0, rev: 0 }), "drop", "seen≥6 never kept never used → drop");
54
+ assert.strictEqual(disc.cascadeVerdict({ seen: 5, kept: 4, open: 1 }), "keep", "keep-rate ≥0.8 with real use → keep");
55
+ assert.strictEqual(disc.cascadeVerdict({ seen: 5, kept: 4, open: 0, rev: 0 }), null, "high keep but never used → judge");
56
+ assert.strictEqual(disc.cascadeVerdict({ seen: 3, kept: 0, open: 0 }), null, "too little history → judge");
57
+ assert.strictEqual(disc.cascadeVerdict({ seen: 6, kept: 1, open: 0 }), null, "ambiguous middle → judge");
58
+
59
+ // ── metrics: node stats, rescues, co-rescue pairs, evidence ──
60
+ metrics._resetForTest();
61
+ for (let i = 0; i < 7; i++) {
62
+ metrics.logTurn({
63
+ query: "q", tier: "full",
64
+ seeds: [{ id: "pack:seeded", score: 3 }],
65
+ activated: [{ id: "pack:noisy", activation: 0.5, hop: 1 }],
66
+ cand: [{ id: "pack:seeded", act: false }, { id: "pack:noisy", act: true }],
67
+ kept: i < 3 ? [{ id: "pack:seeded", why: "w" }, { id: "pack:resc-a", why: "w" }, { id: "pack:resc-b", why: "w" }]
68
+ : [{ id: "pack:seeded", why: "w" }],
69
+ latencyMs: 1000, costUsd: 0.001,
70
+ });
71
+ }
72
+ metrics.logUse(["pack:seeded"]);
73
+ metrics.logUse(["pack:seeded"], "reviewer");
74
+ const ns = metrics.nodeStats().nodes;
75
+ assert.strictEqual(ns["pack:noisy"].seen, 7, "candidate seen count accumulates");
76
+ assert.strictEqual(ns["pack:noisy"].kept, 0, "never-kept stays zero");
77
+ assert.strictEqual(ns["pack:noisy"].act, 7, "graph-arrival count tracked");
78
+ assert.strictEqual(ns["pack:resc-a"].resc, 3, "kept-without-seed counts as rescue");
79
+ assert.strictEqual(ns["pack:seeded"].open, 1, "📖 open tracked per node");
80
+ assert.strictEqual(ns["pack:seeded"].rev, 1, "reviewer credit tracked per node");
81
+
82
+ const ev = metrics.evidence({ minSeen: 6, coRescueMin: 3 });
83
+ assert.ok(ev.neverKept.some((n) => n.id === "pack:noisy"), "noisy node lands in neverKept");
84
+ assert.ok(!ev.neverKept.some((n) => n.id === "pack:seeded"), "used node never in neverKept");
85
+ assert.ok(ev.coRescuePairs.some((p) => p.a === "pack:resc-a" && p.b === "pack:resc-b" && p.n === 3), "co-rescue pair counted");
86
+ assert.ok(ev.summary.turns === 7, "evidence carries the raw summary");
87
+
88
+ // cascadeVerdict against the accumulated stats: noisy is a drop, seeded needs more
89
+ assert.strictEqual(disc.cascadeVerdict(ns["pack:noisy"]), "drop", "real accumulated stats produce a drop verdict");
90
+
91
+ // ── dream: windowed deltas + health ──
92
+ const delta = dream.summaryDelta(
93
+ { turns: 10, opens: 5, keptTotal: 12, rescueTurns: 3, latencyMsTotal: 30000, shadowChecks: 4, shadowAgree: 4, byTier: { full: { n: 4, ms: 40000 }, seeds: { n: 6, ms: 600 } } },
94
+ { turns: 6, opens: 2, keptTotal: 6, rescueTurns: 1, latencyMsTotal: 10000, byTier: { full: { n: 2, ms: 10000 } } }
95
+ );
96
+ assert.strictEqual(delta.turns, 4);
97
+ assert.strictEqual(delta.opens, 3);
98
+ assert.deepStrictEqual(delta.byTier.full, { n: 2, ms: 30000 });
99
+ assert.deepStrictEqual(delta.byTier.seeds, { n: 6, ms: 600 });
100
+ const health = dream.healthOf({ turns: 10, rescueTurns: 3, keptTotal: 10, opens: 2, reviewerUses: 1, latencyMsTotal: 20000 });
101
+ assert.strictEqual(health.rescueRate, 0.3);
102
+ assert.strictEqual(health.noiseRate, 0.7);
103
+ assert.strictEqual(health.avgLatencyMs, 2000);
104
+
105
+ // ── dream: merge clusters (similarity + ability boundary) ──
106
+ const mkPack = (dir, name, description, kind) => ({ dir, name, description, tags: ["kazee", "mobile"], kind });
107
+ const clusters = dream.mergeClusters([
108
+ mkPack("kazee-mobile-deploy", "Kazee Mobile Deployment", "APK versionCode updater pipeline GitLab CI"),
109
+ mkPack("kazee-mobile-deployment", "Kazee Mobile Deployment", "APK versionCode updater pipeline GitLab"),
110
+ mkPack("villa-heatmap", "Villa WiFi Heatmap", "LAN hosted survey renders access points"),
111
+ ]);
112
+ assert.strictEqual(clusters.length, 1, "one similar pair clusters");
113
+ assert.deepStrictEqual([...clusters[0]].sort(), ["kazee-mobile-deploy", "kazee-mobile-deployment"]);
114
+ const abilitySplit = dream.mergeClusters([
115
+ mkPack("deploy-a", "Kazee Mobile Deployment", "APK versionCode updater pipeline", "ability"),
116
+ mkPack("deploy-b", "Kazee Mobile Deployment", "APK versionCode updater pipeline"),
117
+ ]);
118
+ assert.strictEqual(abilitySplit.length, 0, "ability/context never cluster together");
119
+
120
+ // ── dream: delta dump selection ──
121
+ const now = Date.now();
122
+ const iso = (d) => new Date(d).toISOString();
123
+ const corpus = [
124
+ { dir: "touched", updated: iso(now - 3600e3), tags: [] },
125
+ { dir: "stale", updated: iso(now - 40 * 86400e3), tags: [] },
126
+ { dir: "concernpack", updated: iso(now - 40 * 86400e3), tags: ["concern"] },
127
+ { dir: "clustered", updated: iso(now - 40 * 86400e3), tags: [] },
128
+ ];
129
+ const since = iso(now - 86400e3);
130
+ const dump = dream.selectDumpDirs(corpus, { sinceTs: since, fullSweep: false, clusters: [["clustered", "touched"]] });
131
+ assert.ok(dump.has("touched"), "touched pack dumped in full");
132
+ assert.ok(dump.has("concernpack"), "shared concern always dumped");
133
+ assert.ok(dump.has("clustered"), "merge-cluster member dumped");
134
+ assert.ok(!dump.has("stale"), "untouched pack stays an index row");
135
+ const sweep = dream.selectDumpDirs(corpus, { sinceTs: since, fullSweep: true, clusters: [] });
136
+ assert.strictEqual(sweep.size, 4, "full sweep dumps everything");
137
+
138
+ // ── reviewer skip-gate: only provably-empty turns skip ──
139
+ const pad = "The deployment pipeline completed and artifacts were archived. ".repeat(8); // ~500 chars, no preference words
140
+ assert.strictEqual(review.shouldSkipReview({ userText: "ok", assistantText: "", signals: {} }), "short");
141
+ assert.strictEqual(review.shouldSkipReview({ userText: "thanks!", assistantText: pad, signals: { tier: "skip" } }), "trivial");
142
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds", wrote: false } }), "lookup");
143
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds", wrote: true } }), null, "a write means review");
144
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "seeds" } }), null, "unknown wrote-state means review");
145
+ assert.strictEqual(review.shouldSkipReview({ userText: "remember the fup rule", assistantText: pad, signals: { tier: "seeds", wrote: false } }), null, "preference language escapes the skip");
146
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad, signals: { tier: "full", wrote: false } }), null, "full tier always reviews");
147
+ assert.strictEqual(review.shouldSkipReview({ userText: "fup status today", assistantText: pad + pad + pad, signals: { tier: "seeds", wrote: false } }), null, "long turns always review");
148
+
149
+ // ── dream: journal backfill dedupe ──
150
+ packs.createPack({ dir: "backfill-me", name: "Backfill", description: "test" });
151
+ const p = packs.readPack("backfill-me");
152
+ p.sections.Journal = [
153
+ "- [2026-06-01] Tuned the FUP enforcer thresholds and reran the dry-run",
154
+ "- [2026-06-02] Tuned the FUP enforcer thresholds and reran the dry-run",
155
+ "- [2026-06-03] Tuned FUP enforcer thresholds and reran the dry-run again",
156
+ "- [2026-06-04] Shipped the tiered recall gate with the candidate cap",
157
+ ].join("\n");
158
+ packs.writePack(p);
159
+ const backupRoot = path.join(tmp, "backup");
160
+ const jd = dream.journalBackfillDedupe(backupRoot);
161
+ assert.ok(jd.removed >= 2, `near-dup journal lines collapsed (removed ${jd.removed})`);
162
+ const after = packs.readPack("backfill-me").sections.Journal.split("\n").filter((l) => l.trim());
163
+ assert.ok(after.some((l) => /tiered recall gate/.test(l)), "distinct line survives");
164
+ assert.ok(after.some((l) => /FUP enforcer/.test(l)), "one representative of the dup family survives");
165
+ assert.ok(fs.existsSync(path.join(backupRoot, "packs", "backfill-me")), "deduped pack backed up first");
166
+
167
+ // ── KPI: version-stamped aggregation + snapshots + report ──
168
+ metrics._resetForTest();
169
+ const L = (o) => JSON.stringify(o) + "\n";
170
+ fs.writeFileSync(metrics.LOG_FILE, [
171
+ L({ ts: "2026-06-01T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }, { id: "pack:b" }], latencyMs: 10000, costUsd: 0.01 }),
172
+ L({ ts: "2026-06-01T00:01:00Z", v: "2.6.50", use: ["pack:a"] }),
173
+ L({ ts: "2026-06-02T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", gated: true, latencyMs: 5 }),
174
+ L({ ts: "2026-07-01T00:00:00Z", v: "2.6.59", model: "haiku", tier: "seeds", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }], latencyMs: 2000, costUsd: 0, auto: { kept: ["pack:a"], dropped: ["pack:z"] } }),
175
+ L({ ts: "2026-05-01T00:00:00Z", tier: "full", seeds: [], kept: [], latencyMs: 3000, walker: "failed" }),
176
+ ].join(""));
177
+ const rows = kpi.aggregate();
178
+ assert.strictEqual(rows.length, 3, "three version buckets (two stamped + legacy)");
179
+ assert.strictEqual(rows[0].v, kpi.LEGACY_VERSION, "unstamped records fall into the legacy bucket, ordered first");
180
+ const v50 = rows.find((r) => r.v === "2.6.50");
181
+ assert.strictEqual(v50.turns, 2);
182
+ assert.strictEqual(v50.gatedPct, 50);
183
+ assert.strictEqual(v50.rescuePct, 100, "kept-without-seed counts as a rescue turn");
184
+ assert.strictEqual(v50.noisePct, 50, "one of two kept nodes was used");
185
+ assert.deepStrictEqual(v50.models, ["haiku"]);
186
+ const v59 = rows.find((r) => r.v === "2.6.59");
187
+ assert.strictEqual(v59.rescuePct, 0);
188
+ assert.strictEqual(v59.autoKept, 1);
189
+ assert.strictEqual(v59.autoDropped, 1);
190
+ assert.strictEqual(rows.find((r) => r.v === kpi.LEGACY_VERSION).walkerFailPct, 100);
191
+
192
+ metrics.appendKpi({ trigger: "test", health: { rescueRate: 0.25, noiseRate: 0.5, avgLatencyMs: 8000 }, window: { turns: 40 } });
193
+ metrics.appendKpi({ trigger: "test", health: { rescueRate: 0.30, noiseRate: 0.4, avgLatencyMs: 7000 }, window: { turns: 55 } });
194
+ const snaps = metrics.readKpi();
195
+ assert.strictEqual(snaps.length, 2, "nightly snapshots append");
196
+ assert.ok(snaps[0].v && snaps[0].ts, "snapshots are version-stamped");
197
+
198
+ const table = kpi.renderTable(rows);
199
+ assert.ok(/2\.6\.50/.test(table) && /rescue/.test(table), "table renders versions and metrics");
200
+ const html = kpi.htmlReport();
201
+ assert.ok(/<svg/.test(html) && /2\.6\.59/.test(html) && /rescue/i.test(html), "HTML report has charts and versions");
202
+ const outPath = kpi.writeHtmlReport(path.join(tmp, "kpi.html"));
203
+ assert.ok(fs.existsSync(outPath), "report file written");
204
+
205
+ // ── dream: prompt carries evidence + knob table; parseDream accepts tuning ──
206
+ const prompt = dream.buildDreamPrompt({ sinceTs: since, fullSweep: false, clusters: [], evidence: ev, windowDelta: { turns: 4, rescueTurns: 1, keptTotal: 4, opens: 1, latencyMsTotal: 8000, byTier: {} } });
207
+ assert.ok(/RECALL EVIDENCE/.test(prompt), "evidence section present");
208
+ assert.ok(/walkerMaxCandidates/.test(prompt), "knob table present");
209
+ assert.ok(/delta night/.test(prompt), "delta framing present");
210
+ const parsed = dream.parseDream(JSON.stringify({ report: "hi", tuning: { knob: "episodeLimit", to: 4, reason: "r" } }));
211
+ assert.deepStrictEqual(parsed.tuning, { knob: "episodeLimit", to: 4, reason: "r" }, "tuning proposal parses");
212
+ assert.strictEqual(dream.parseDream(JSON.stringify({ report: "hi", tuning: "bogus" })).tuning, null, "non-object tuning ignored");
213
+
214
+ console.log("recall evolution OK");
@@ -0,0 +1,106 @@
1
+ // Unified multi-channel identity. Verifies, in hermetic child processes (fresh
2
+ // require cache per flag state), that:
3
+ // flag OFF (=0) → resolution is byte-identical to before; auto-link is inert.
4
+ // flag ON (=1) → configured owner channels resolve to one owner canonical,
5
+ // strangers stay isolated, and the first owner-channel contact
6
+ // folds its siloed state into the owner bucket (merge, idempotent,
7
+ // backed up, nothing lost).
8
+ // default (unset) → ON: the flag now defaults on, so an unset env resolves
9
+ // owner channels to the owner canonical. Only an explicit 0/off
10
+ // (the operator kill-switch) reverts to per-channel behaviour.
11
+ // Isolation: each child sets its own temp HOME so config-dir points at a
12
+ // throwaway .open-claudia — the real vault/state is never touched.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+ const assert = require("assert");
18
+ const { execFileSync } = require("child_process");
19
+
20
+ const REPO = __dirname;
21
+
22
+ function seedHome() {
23
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "oc-idtest-"));
24
+ const CFG = path.join(HOME, ".open-claudia");
25
+ fs.mkdirSync(CFG, { recursive: true });
26
+ // .env deliberately omits OC_UNIFIED_IDENTITY so the parent controls it via
27
+ // process.env per child.
28
+ fs.writeFileSync(path.join(CFG, ".env"), [
29
+ `WORKSPACE=${path.join(HOME, "ws")}`,
30
+ `CLAUDE_PATH=${process.execPath}`,
31
+ `TELEGRAM_BOT_TOKEN=dummy`,
32
+ `TELEGRAM_CHAT_ID=6251055967`,
33
+ `VOICE_OWNER_USER_ID=voice-owner`,
34
+ ``,
35
+ ].join("\n"));
36
+ fs.writeFileSync(path.join(CFG, "people.json"), JSON.stringify([{
37
+ id: "person_owner", name: "Owner", isOwner: true,
38
+ handles: [{ adapter: "telegram", channelId: "6251055967", canonicalUserId: "telegram:6251055967" }],
39
+ primaryChannel: { adapter: "telegram", channelId: "6251055967" }, notes: [],
40
+ }]));
41
+ fs.writeFileSync(path.join(CFG, "identities.json"), JSON.stringify({ channels: {}, preferred: {} }));
42
+ fs.writeFileSync(path.join(CFG, "state.json"), JSON.stringify({ users: {
43
+ "telegram:6251055967": { currentSession: { name: "tickets", dir: "/ws/tickets" }, lastSessionId: "t-sess",
44
+ settings: { model: "claude-opus-4-8" }, sessionUsage: { turns: 10, costUsd: 5 } },
45
+ "voice:voice-owner": { currentSession: { name: "hr", dir: "/ws/hr" }, lastSessionId: "v-sess",
46
+ settings: {}, sessionUsage: { turns: 3, costUsd: 1 } },
47
+ } }));
48
+ fs.writeFileSync(path.join(CFG, "sessions.json"), JSON.stringify({
49
+ "voice:voice-owner": { hr: [{ id: "v-sess", title: "HR", created: "x", lastUsed: "x" }] },
50
+ }));
51
+ return { HOME, CFG };
52
+ }
53
+
54
+ function runChild(mode) {
55
+ const { HOME, CFG } = seedHome();
56
+ process.env.HOME = HOME;
57
+ const id = require(path.join(REPO, "core/identity"));
58
+ const state = require(path.join(REPO, "core/state"));
59
+
60
+ if (mode === "off") {
61
+ assert.strictEqual(id.canonicalForChannel("voice", "voice-owner"), "voice:voice-owner", "off: voice stays siloed");
62
+ assert.strictEqual(id.canonicalForChannel("telegram", "6251055967"), "telegram:6251055967", "off: owner telegram unchanged");
63
+ assert.strictEqual(state.autoLinkOwnerChannel("voice", "voice-owner"), null, "off: auto-link inert");
64
+ const after = JSON.parse(fs.readFileSync(path.join(CFG, "state.json"), "utf-8"));
65
+ assert.ok("voice:voice-owner" in after.users, "off: voice bucket untouched");
66
+ } else if (mode === "default") {
67
+ // No OC_UNIFIED_IDENTITY in env or .env → the new default (ON) must apply.
68
+ assert.strictEqual(id.canonicalForChannel("voice", "voice-owner"), "telegram:6251055967", "default: unset resolves to owner (default ON)");
69
+ assert.strictEqual(id.canonicalForChannel("telegram", "9999999999"), "telegram:9999999999", "default: stranger stays isolated");
70
+ } else {
71
+ assert.strictEqual(id.ownerCanonical(), "telegram:6251055967", "on: ownerCanonical");
72
+ assert.strictEqual(id.canonicalForChannel("voice", "voice-owner"), "telegram:6251055967", "on: voice unified to owner");
73
+ assert.strictEqual(id.canonicalForChannel("telegram", "9999999999"), "telegram:9999999999", "on: stranger stays isolated");
74
+ const r1 = state.autoLinkOwnerChannel("voice", "voice-owner");
75
+ assert.deepStrictEqual(r1, { from: "voice:voice-owner", to: "telegram:6251055967" }, "on: migrate result");
76
+ assert.strictEqual(state.autoLinkOwnerChannel("voice", "voice-owner"), null, "on: idempotent second call");
77
+ const after = JSON.parse(fs.readFileSync(path.join(CFG, "state.json"), "utf-8"));
78
+ assert.ok(!("voice:voice-owner" in after.users), "on: voice bucket folded away");
79
+ assert.ok("telegram:6251055967" in after.users, "on: owner bucket present");
80
+ const sess = JSON.parse(fs.readFileSync(path.join(CFG, "sessions.json"), "utf-8"));
81
+ assert.ok(sess["telegram:6251055967"] && sess["telegram:6251055967"].hr, "on: hr session folded into owner");
82
+ const backups = fs.existsSync(path.join(CFG, "backups")) ? fs.readdirSync(path.join(CFG, "backups")) : [];
83
+ assert.ok(backups.length >= 3, "on: state/sessions/identities backed up before merge");
84
+ }
85
+
86
+ fs.rmSync(HOME, { recursive: true, force: true });
87
+ console.log(`unified-identity child(${mode}) OK`);
88
+ }
89
+
90
+ if (process.argv[2] === "--child") {
91
+ runChild(process.argv[3]);
92
+ } else {
93
+ for (const mode of ["off", "on", "default"]) {
94
+ const childEnv = { ...process.env };
95
+ if (mode === "on") childEnv.OC_UNIFIED_IDENTITY = "1";
96
+ else if (mode === "off") childEnv.OC_UNIFIED_IDENTITY = "0";
97
+ else delete childEnv.OC_UNIFIED_IDENTITY; // default: prove unset ⇒ ON
98
+ try {
99
+ execFileSync(process.execPath, [__filename, "--child", mode], { env: childEnv, stdio: "pipe" });
100
+ } catch (e) {
101
+ console.error(`unified-identity ${mode} FAILED:\n${e.stdout || ""}${e.stderr || ""}`);
102
+ process.exit(1);
103
+ }
104
+ }
105
+ console.log("unified-identity OK");
106
+ }