@inetafrica/open-claudia 2.10.0 → 2.12.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 +18 -0
- package/core/config.js +6 -5
- package/core/dream.js +13 -5
- package/core/entities.js +68 -15
- package/core/pack-review.js +31 -3
- package/core/people.js +24 -0
- package/core/queue-drain.js +25 -0
- package/core/recall/discoverer.js +22 -2
- package/core/router.js +6 -3
- package/core/runner.js +37 -9
- package/core/system-prompt.js +53 -2
- package/package.json +10 -3
- package/test-persona-packs.js +126 -0
- package/test-persona-pipeline.js +79 -0
- package/test-queue-routing.js +52 -0
- package/test-recall-evolution.js +214 -0
- package/test-recall-relationship-gate.js +42 -0
- package/test-speaker-persona.js +89 -0
- package/test-unified-identity.js +106 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Speaker persona injection: the deterministic per-turn block that primes the
|
|
2
|
+
// bot on WHO it's talking to (their persona pack) so it adapts voice + scope.
|
|
3
|
+
// Rides the uncached tail (R13). These tests pin: the block renders the
|
|
4
|
+
// speaker's Role/Style/Knows, suppresses an OWNER's Mandate but shows a
|
|
5
|
+
// non-owner's, dedupes within a session, and is silent when there's no persona.
|
|
6
|
+
|
|
7
|
+
const assert = require("assert");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "speaker-persona-"));
|
|
13
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
14
|
+
process.env.ENTITIES_DIR = path.join(tmp, "entities");
|
|
15
|
+
process.env.PEOPLE_FILE = path.join(tmp, "people.json");
|
|
16
|
+
|
|
17
|
+
const people = require("./core/people");
|
|
18
|
+
const entities = require("./core/entities");
|
|
19
|
+
const { runInChat } = require("./core/context");
|
|
20
|
+
const { buildSpeakerPersonaBlock } = require("./core/system-prompt");
|
|
21
|
+
|
|
22
|
+
const adapter = { id: "telegram", type: "telegram" };
|
|
23
|
+
const CH_OWNER = "111";
|
|
24
|
+
const CH_EXT = "222";
|
|
25
|
+
const CH_BARE = "333";
|
|
26
|
+
|
|
27
|
+
// ── seed an owner with a persona pack (Mandate present but must be hidden) ──
|
|
28
|
+
const owner = people.add({ name: "Ada Owner", isOwner: true });
|
|
29
|
+
people.linkHandle(owner.id, { adapter: "telegram", channelId: CH_OWNER });
|
|
30
|
+
entities.upsertEntity({
|
|
31
|
+
name: "Ada Owner", type: "person", relationship: "owner",
|
|
32
|
+
role: "Founder and primary operator.",
|
|
33
|
+
style: "Terse and technical. No hand-holding.",
|
|
34
|
+
knows: "Fluent in the codebase, kubernetes, gitops.",
|
|
35
|
+
mandate: "Full trust — no guardrails apply.",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── seed an external contact with a Mandate that MUST surface ──
|
|
39
|
+
const ext = people.add({ name: "Zephyr Contact" });
|
|
40
|
+
people.linkHandle(ext.id, { adapter: "telegram", channelId: CH_EXT });
|
|
41
|
+
entities.upsertEntity({
|
|
42
|
+
name: "Zephyr Contact", type: "person", relationship: "external",
|
|
43
|
+
style: "Friendly but guarded.",
|
|
44
|
+
mandate: "May only discuss invoicing status. Never mention internal systems.",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── a person with a handle but NO persona pack → silent ──
|
|
48
|
+
const bare = people.add({ name: "Bare Person" });
|
|
49
|
+
people.linkHandle(bare.id, { adapter: "telegram", channelId: CH_BARE });
|
|
50
|
+
|
|
51
|
+
const inChat = (channelId, fn) => runInChat({ adapter, channelId, transport: "telegram" }, fn);
|
|
52
|
+
|
|
53
|
+
// Owner: Role/Style/Knows shown, Mandate suppressed (owner has no guardrails).
|
|
54
|
+
const ownerBlock = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
|
|
55
|
+
assert.ok(ownerBlock.includes("Ada Owner"), "owner block names the speaker");
|
|
56
|
+
assert.ok(ownerBlock.includes("(owner)"), "owner block shows relationship");
|
|
57
|
+
assert.ok(ownerBlock.includes("Founder and primary operator."), "owner Role shown");
|
|
58
|
+
assert.ok(ownerBlock.includes("Terse and technical"), "owner Style shown");
|
|
59
|
+
assert.ok(ownerBlock.includes("Fluent in the codebase"), "owner Knows shown");
|
|
60
|
+
assert.ok(!ownerBlock.includes("Full trust"), "owner Mandate is NOT injected (no guardrails on owner)");
|
|
61
|
+
assert.ok(!/Mandate/.test(ownerBlock), "no Mandate row for the owner at all");
|
|
62
|
+
|
|
63
|
+
// External: Style shown AND Mandate surfaced (it scopes the turn).
|
|
64
|
+
const extBlock = inChat(CH_EXT, () => buildSpeakerPersonaBlock());
|
|
65
|
+
assert.ok(extBlock.includes("Zephyr Contact"), "external block names the speaker");
|
|
66
|
+
assert.ok(extBlock.includes("(external)"), "external relationship shown");
|
|
67
|
+
assert.ok(extBlock.includes("Friendly but guarded."), "external Style shown");
|
|
68
|
+
assert.ok(extBlock.includes("May only discuss invoicing status"), "external Mandate IS injected");
|
|
69
|
+
|
|
70
|
+
// Bare person (handle, no pack): nothing to inject.
|
|
71
|
+
const bareBlock = inChat(CH_BARE, () => buildSpeakerPersonaBlock());
|
|
72
|
+
assert.strictEqual(bareBlock, "", "no persona pack → empty block");
|
|
73
|
+
|
|
74
|
+
// Unknown channel (no person): silent.
|
|
75
|
+
const strangerBlock = inChat("999", () => buildSpeakerPersonaBlock());
|
|
76
|
+
assert.strictEqual(strangerBlock, "", "unknown speaker → empty block");
|
|
77
|
+
|
|
78
|
+
// ── dedupe: within one session the same speaker is injected once ──
|
|
79
|
+
// (First call above already stamped CH_OWNER for session "new"; the repeat
|
|
80
|
+
// must come back empty so we don't re-bill the tail every turn.)
|
|
81
|
+
const ownerAgain = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
|
|
82
|
+
assert.strictEqual(ownerAgain, "", "same speaker re-injected within a session is deduped");
|
|
83
|
+
|
|
84
|
+
// ── a persona edit (new `updated` stamp) re-injects even in the same session ──
|
|
85
|
+
entities.upsertEntity({ name: "Ada Owner", style: "Even terser now." });
|
|
86
|
+
const ownerAfterEdit = inChat(CH_OWNER, () => buildSpeakerPersonaBlock());
|
|
87
|
+
assert.ok(ownerAfterEdit.includes("Even terser now."), "a persona edit re-injects the fresh block");
|
|
88
|
+
|
|
89
|
+
console.log("speaker persona 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
|
+
}
|