@inetafrica/open-claudia 2.10.0 → 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 +8 -0
- package/core/config.js +6 -5
- package/core/queue-drain.js +25 -0
- package/core/router.js +6 -3
- package/core/runner.js +27 -9
- package/package.json +6 -3
- package/test-queue-routing.js +52 -0
- package/test-recall-evolution.js +214 -0
- package/test-unified-identity.js +106 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## v2.10.0
|
|
4
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.
|
|
5
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).
|
package/core/config.js
CHANGED
|
@@ -94,11 +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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// OC_UNIFIED_IDENTITY=
|
|
101
|
-
|
|
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);
|
|
102
103
|
const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
|
|
103
104
|
const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
|
|
104
105
|
const WHISPER_CLI = config.WHISPER_CLI || "";
|
|
@@ -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
|
@@ -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
|
-
//
|
|
30
|
-
//
|
|
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
|
|
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) {
|
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
|
-
|
|
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
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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 =
|
|
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 ${
|
|
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 =
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.
|
|
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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.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
|
+
}
|