@inetafrica/open-claudia 2.15.0 → 3.0.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.
Files changed (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +66 -3
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +115 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +21 -4
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +117 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +121 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +170 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ async function probe() {
12
+ const {
13
+ PromptConstructionError,
14
+ buildTurnPrompt,
15
+ createPromptBuilder,
16
+ promptDedupeIdentity,
17
+ } = require("./core/system-prompt");
18
+ const { getProvider } = require("./core/providers");
19
+ const { runInChat } = require("./core/context");
20
+ const { currentState } = require("./core/state");
21
+
22
+ assert.strictEqual(typeof buildTurnPrompt, "function");
23
+ assert.strictEqual(typeof createPromptBuilder, "function");
24
+ assert.strictEqual(typeof promptDedupeIdentity, "function");
25
+
26
+ const adapter = {
27
+ id: "telegram-fixture",
28
+ type: "telegram",
29
+ send: async () => "fixture-message",
30
+ typing: async () => {},
31
+ };
32
+
33
+ await runInChat({
34
+ adapter,
35
+ channelId: "fixture-channel",
36
+ canonicalUserId: "fixture-user",
37
+ userId: "fixture-channel",
38
+ transport: "telegram",
39
+ raw: null,
40
+ }, async () => {
41
+ const state = currentState();
42
+ state.currentSession = { name: "fixture-project", dir: process.env.WORKSPACE };
43
+ state.settings = { ...state.settings, backend: "claude" };
44
+ state.activeSessions["fixture-project"] = { claude: "claude-session", codex: "codex-session" };
45
+
46
+ let recallCalls = 0;
47
+ const promptBuilder = createPromptBuilder({
48
+ async buildRecallContext() {
49
+ recallCalls += 1;
50
+ return {
51
+ dynamicContext: [
52
+ "## Active Open Claudia skills / context packs",
53
+ "PACK CONTEXT SENTINEL",
54
+ "## Known entities",
55
+ "ENTITY CONTEXT SENTINEL",
56
+ "## Tools that may help here",
57
+ "TOOL CONTEXT SENTINEL",
58
+ ].join("\n"),
59
+ metadata: { status: "included", engine: "fixture", omitted: false },
60
+ };
61
+ },
62
+ });
63
+
64
+ const common = {
65
+ canonicalUserId: "fixture-user",
66
+ project: { name: "fixture-project", dir: process.env.WORKSPACE },
67
+ providerSettings: { model: null, effort: null, permissionMode: null },
68
+ channelId: "fixture-channel",
69
+ origin: { transport: "telegram", channelId: "fixture-channel" },
70
+ };
71
+ const claudeContext = { ...common, provider: "claude", sessionId: "claude-session" };
72
+ const codexContext = { ...common, provider: "codex", sessionId: "codex-session" };
73
+
74
+ const claude = await promptBuilder.buildTurnPrompt("FIRST USER SENTINEL", {
75
+ runContext: claudeContext,
76
+ provider: getProvider("claude"),
77
+ });
78
+ const codex = await promptBuilder.buildTurnPrompt("SECOND USER SENTINEL", {
79
+ runContext: codexContext,
80
+ provider: getProvider("codex"),
81
+ });
82
+
83
+ for (const bundle of [claude, codex]) {
84
+ assert.strictEqual(typeof bundle.coreInstructions, "string");
85
+ assert.strictEqual(typeof bundle.dynamicContext, "string");
86
+ assert.ok(bundle.coreInstructions.includes("SOUL POLICY SENTINEL"), "soul is mandatory core policy");
87
+ assert.ok(bundle.coreInstructions.includes("PERSONA SENTINEL"), "persona is included in core policy");
88
+ assert.ok(bundle.coreInstructions.includes("LESSON SENTINEL"), "lessons are included in core policy");
89
+ assert.ok(bundle.coreInstructions.includes("## Delivery"), "delivery guidance is mandatory");
90
+ assert.ok(bundle.coreInstructions.includes("## Background work"), "scheduler guidance is mandatory");
91
+ assert.ok(bundle.coreInstructions.includes("Persistent todo list"), "task guidance is mandatory");
92
+ assert.ok(bundle.coreInstructions.includes("## Tools are"), "tool guidance is mandatory");
93
+ assert.ok(bundle.coreInstructions.includes("channel context is already in the env"), "gateway guidance is mandatory");
94
+ assert.ok(bundle.dynamicContext.includes("## Runtime state (current turn)"));
95
+ assert.ok(bundle.dynamicContext.includes("## Project Transcript Memory"));
96
+ assert.ok(bundle.dynamicContext.includes("## Speaker"));
97
+ assert.ok(bundle.dynamicContext.includes("PACK CONTEXT SENTINEL"));
98
+ assert.ok(bundle.dynamicContext.includes("ENTITY CONTEXT SENTINEL"));
99
+ assert.ok(bundle.dynamicContext.includes("TOOL CONTEXT SENTINEL"));
100
+ assert.deepStrictEqual(bundle.recallMetadata, { status: "included", engine: "fixture", omitted: false });
101
+ assert.ok(bundle.transcriptPaths && bundle.transcriptPaths.transcriptPath);
102
+ assert.ok(!bundle.coreInstructions.includes("Claude Code harness"));
103
+ assert.ok(!bundle.dynamicContext.includes("Claude Code harness"));
104
+ }
105
+
106
+ assert.strictEqual(claude.coreInstructions, codex.coreInstructions, "provider-neutral core is byte-identical");
107
+ assert.notStrictEqual(claude.userPrompt, codex.userPrompt, "the user prompt remains per-turn data");
108
+ assert.ok(claude.dynamicContext.includes(getProvider("claude").nativeToolsNote));
109
+ assert.ok(codex.dynamicContext.includes(getProvider("codex").nativeToolsNote));
110
+ assert.ok(!claude.dynamicContext.includes(getProvider("codex").nativeToolsNote));
111
+ assert.strictEqual(recallCalls, 2);
112
+
113
+ const again = await promptBuilder.buildTurnPrompt("THIRD USER SENTINEL", {
114
+ runContext: claudeContext,
115
+ provider: getProvider("claude"),
116
+ });
117
+ assert.strictEqual(again.coreInstructions, claude.coreInstructions, "core instructions stay byte-stable within a session");
118
+
119
+ const identityA = promptDedupeIdentity({ runContext: claudeContext });
120
+ const identityB = promptDedupeIdentity({ runContext: { ...claudeContext, sessionId: "next-session" } });
121
+ const identityC = promptDedupeIdentity({ runContext: { ...claudeContext, provider: "codex" } });
122
+ const identityD = promptDedupeIdentity({ runContext: { ...claudeContext, project: { name: "other", dir: "/other" } } });
123
+ assert.notStrictEqual(identityA, identityB, "session participates in dedupe identity");
124
+ assert.notStrictEqual(identityA, identityC, "provider participates in dedupe identity");
125
+ assert.notStrictEqual(identityA, identityD, "project participates in dedupe identity");
126
+ assert.notStrictEqual(
127
+ promptDedupeIdentity({ runContext: { ...claudeContext, sessionId: null, runId: "new-run-1" } }),
128
+ promptDedupeIdentity({ runContext: { ...claudeContext, sessionId: null, runId: "new-run-2" } }),
129
+ "separate admitted runs cannot share the pre-session dedupe bucket",
130
+ );
131
+
132
+ const brokenMandatory = createPromptBuilder({
133
+ buildCoreInstructions() { throw new Error("fixture mandatory failure"); },
134
+ });
135
+ await assert.rejects(
136
+ () => brokenMandatory.buildTurnPrompt("RAW MUST NOT ESCAPE", {
137
+ runContext: claudeContext,
138
+ provider: getProvider("claude"),
139
+ }),
140
+ (error) => error instanceof PromptConstructionError
141
+ && error.code === "PROMPT_CONSTRUCTION_FAILED"
142
+ && error.stage === "coreInstructions"
143
+ && !String(error.bundle || "").includes("RAW MUST NOT ESCAPE"),
144
+ );
145
+
146
+ const brokenSpeaker = createPromptBuilder({
147
+ buildSpeakerContext() { throw new Error("fixture speaker failure"); },
148
+ });
149
+ await assert.rejects(
150
+ () => brokenSpeaker.buildTurnPrompt("RAW MUST NOT ESCAPE", {
151
+ runContext: claudeContext,
152
+ provider: getProvider("claude"),
153
+ }),
154
+ (error) => error instanceof PromptConstructionError && error.stage === "speakerContext",
155
+ );
156
+
157
+ const recallFailOpen = createPromptBuilder({
158
+ async buildRecallContext() { throw new Error("fixture optional recall failure"); },
159
+ });
160
+ const omitted = await recallFailOpen.buildTurnPrompt("RECALL FAILURE USER SENTINEL", {
161
+ runContext: claudeContext,
162
+ provider: getProvider("claude"),
163
+ });
164
+ assert.strictEqual(omitted.userPrompt, "RECALL FAILURE USER SENTINEL");
165
+ assert.strictEqual(omitted.recallMetadata.status, "omitted");
166
+ assert.strictEqual(omitted.recallMetadata.omitted, true);
167
+ assert.ok(omitted.coreInstructions.includes("## Delivery"));
168
+ assert.ok(omitted.dynamicContext.includes("## Runtime state"));
169
+
170
+ const concurrent = createPromptBuilder({
171
+ async buildRecallContext(prompt) {
172
+ await new Promise((resolve) => setTimeout(resolve, prompt.startsWith("SLOW") ? 20 : 1));
173
+ return {
174
+ dynamicContext: `RECALL ${prompt}`,
175
+ metadata: { status: "included", omitted: false, marker: prompt },
176
+ };
177
+ },
178
+ });
179
+ const [slow, fast] = await Promise.all([
180
+ concurrent.buildTurnPrompt("SLOW RECALL", { runContext: claudeContext, provider: getProvider("claude") }),
181
+ concurrent.buildTurnPrompt("FAST RECALL", { runContext: codexContext, provider: getProvider("codex") }),
182
+ ]);
183
+ assert.strictEqual(slow.recallMetadata.marker, "SLOW RECALL");
184
+ assert.strictEqual(fast.recallMetadata.marker, "FAST RECALL");
185
+ assert.ok(slow.dynamicContext.includes("RECALL SLOW RECALL"));
186
+ assert.ok(fast.dynamicContext.includes("RECALL FAST RECALL"));
187
+ });
188
+ }
189
+
190
+ if (process.argv[2] === "--probe") {
191
+ probe().then(() => console.log("provider core prompt OK")).catch((error) => {
192
+ console.error(error.stack || error.message);
193
+ process.exit(1);
194
+ });
195
+ } else {
196
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-core-prompt-"));
197
+ const configDir = path.join(root, "config");
198
+ const workspace = path.join(root, "workspace");
199
+ fs.mkdirSync(configDir, { recursive: true });
200
+ fs.mkdirSync(workspace, { recursive: true });
201
+ fs.writeFileSync(path.join(configDir, "soul.md"), "SOUL POLICY SENTINEL\n");
202
+ fs.writeFileSync(path.join(configDir, "persona.md"), "PERSONA SENTINEL — warm, direct, careful, and consistent across every supported provider.\n");
203
+ fs.writeFileSync(path.join(configDir, "lessons.md"), "- LESSON SENTINEL (src: fixture-pack)\n");
204
+ fs.writeFileSync(path.join(configDir, "people.json"), JSON.stringify([{
205
+ id: "fixture-person",
206
+ name: "Fixture Speaker",
207
+ isOwner: true,
208
+ bio: "Prompt parity tester",
209
+ handles: [{ adapter: "telegram", channelId: "fixture-channel", canonicalUserId: "fixture-user" }],
210
+ notes: [],
211
+ primaryChannel: { adapter: "telegram", channelId: "fixture-channel" },
212
+ }]));
213
+
214
+ const childEnv = {
215
+ HOME: root,
216
+ PATH: path.dirname(process.execPath),
217
+ TMPDIR: process.env.TMPDIR || os.tmpdir(),
218
+ TZ: "UTC",
219
+ OPEN_CLAUDIA_TEST: "1",
220
+ OPEN_CLAUDIA_CONFIG_DIR: configDir,
221
+ PROJECT_TRANSCRIPTS: "1",
222
+ RECALL_DEBUG: "0",
223
+ WORKSPACE: workspace,
224
+ CLAUDE_PATH: process.execPath,
225
+ CODEX_PATH: process.execPath,
226
+ };
227
+ const child = spawnSync(process.execPath, [__filename, "--probe"], {
228
+ cwd: __dirname,
229
+ env: childEnv,
230
+ encoding: "utf8",
231
+ timeout: 20000,
232
+ maxBuffer: 2 * 1024 * 1024,
233
+ });
234
+ try {
235
+ assert.ifError(child.error);
236
+ assert.strictEqual(child.status, 0, child.stderr || "provider core prompt probe failed");
237
+ assert.match(child.stdout, /provider core prompt OK/);
238
+ process.stdout.write(child.stdout);
239
+
240
+ const failureProbe = [
241
+ "const { createPromptBuilder } = require('./core/system-prompt');",
242
+ "const state = { userId: 'fixture-user', currentSession: { name: 'fixture', dir: process.cwd() }, settings: { backend: 'claude' }, activeSessions: {}, providerSettings: {} };",
243
+ "const runContext = { canonicalUserId: 'fixture-user', project: { name: 'fixture', dir: process.cwd() }, provider: 'claude', sessionId: null, runId: 'failure-probe' };",
244
+ "createPromptBuilder().buildTurnPrompt('RAW MUST NOT ESCAPE', { state, runContext, provider: { id: 'claude', nativeToolsNote: 'fixture' } }).then(() => process.exit(2), (error) => {",
245
+ " if (error.code !== 'PROMPT_CONSTRUCTION_FAILED' || error.stage !== 'coreInstructions' || String(error.bundle || '').includes('RAW MUST NOT ESCAPE')) process.exit(3);",
246
+ " process.stdout.write('mandatory soul failure closed\\n');",
247
+ "});",
248
+ ].join("\n");
249
+ for (const contents of [null, ""]) {
250
+ if (contents === null) fs.rmSync(path.join(configDir, "soul.md"), { force: true });
251
+ else fs.writeFileSync(path.join(configDir, "soul.md"), contents);
252
+ const failed = spawnSync(process.execPath, ["-e", failureProbe], {
253
+ cwd: __dirname,
254
+ env: childEnv,
255
+ encoding: "utf8",
256
+ timeout: 20000,
257
+ });
258
+ assert.strictEqual(failed.status, 0, failed.stderr || "missing/empty soul must fail closed");
259
+ assert.match(failed.stdout, /mandatory soul failure closed/);
260
+ }
261
+ } finally {
262
+ fs.rmSync(root, { recursive: true, force: true });
263
+ }
264
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+
9
+ const root = __dirname;
10
+
11
+ function read(relativePath) {
12
+ return fs.readFileSync(path.join(root, relativePath), "utf8");
13
+ }
14
+
15
+ function sourceFiles(relativePath) {
16
+ const absolute = path.join(root, relativePath);
17
+ if (!fs.statSync(absolute).isDirectory()) return [relativePath];
18
+ return fs.readdirSync(absolute, { withFileTypes: true }).flatMap((entry) => {
19
+ const child = path.join(relativePath, entry.name);
20
+ if (entry.isDirectory()) return sourceFiles(child);
21
+ return entry.isFile() && child.endsWith(".js") ? [child] : [];
22
+ });
23
+ }
24
+
25
+ const activeFiles = [
26
+ ...sourceFiles("core"),
27
+ ...sourceFiles("bin"),
28
+ ...sourceFiles("channels"),
29
+ "bot.js",
30
+ "bot-agent.js",
31
+ "health.js",
32
+ "setup.js",
33
+ "web.js",
34
+ ];
35
+ const activeSource = Object.fromEntries(activeFiles.map((file) => [file, read(file)]));
36
+
37
+ function matches(pattern, allow = new Set()) {
38
+ return Object.entries(activeSource)
39
+ .filter(([file, source]) => !allow.has(file) && pattern.test(source))
40
+ .map(([file]) => file);
41
+ }
42
+
43
+ assert.deepStrictEqual(matches(/\brunClaude(?:Capture|Silent)?\b/), [], "deprecated runner names must be removed");
44
+ assert.deepStrictEqual(matches(/\bbuildClaudeArgs\b/), [], "provider argv construction belongs only in adapters");
45
+ assert.deepStrictEqual(matches(/\brunLegacy(?:Capture|Foreground|Silent)\b/), [], "the dead legacy runner must be deleted");
46
+ assert.deepStrictEqual(matches(/\bDEFAULT_CLAUDE_MODEL\b/), [], "Claude's default model belongs inside its adapter");
47
+ assert.deepStrictEqual(
48
+ matches(/\bspawnSubagent\b/, new Set(["core/subagent.js"])),
49
+ [],
50
+ "direct utility callers must not bypass the provider-neutral utility service",
51
+ );
52
+
53
+ const claudePathAllowlist = new Set([
54
+ "core/auth-flow.js",
55
+ "core/config.js",
56
+ "core/provider-status.js",
57
+ "core/providers/claude.js",
58
+ "setup.js",
59
+ "web.js",
60
+ ]);
61
+ assert.deepStrictEqual(matches(/\bCLAUDE_PATH\b/, claudePathAllowlist), [], "CLAUDE_PATH leaked outside Claude auth/setup scope");
62
+
63
+ for (const file of ["core/runner.js", "core/run-context.js", "core/system-prompt.js", "core/handlers.js", "core/actions.js"]) {
64
+ assert.doesNotMatch(
65
+ activeSource[file],
66
+ /\b(?:lastSessionId|codexSessionId|cursorSessionId)\b/,
67
+ `${file} must use provider/project session helpers`,
68
+ );
69
+ }
70
+ assert.doesNotMatch(
71
+ activeSource["core/state.js"],
72
+ /Object\.defineProperty\(state,\s*legacyKey/,
73
+ "migration aliases must not become live runtime accessors",
74
+ );
75
+ assert.doesNotMatch(activeSource["core/run-context.js"], /\bsessionKey\b/, "run admission stores provider identity, not a legacy pointer key");
76
+
77
+ assert.doesNotMatch(activeSource["core/state.js"], /function normalizeProviderId\(providerId, fallback = ["']claude["']\)/);
78
+ assert.doesNotMatch(activeSource["core/state.js"], /:\s*["']claude["'];\s*\n\s*if \(!Object\.prototype\.hasOwnProperty\.call\(settings, ["']compactWindow["']\)/);
79
+ assert.doesNotMatch(activeSource["core/system-prompt.js"], /state\.settings\?\.backend \|\| ["']claude["']/);
80
+ assert.doesNotMatch(activeSource["setup.js"], /providerId \|\| ["']claude["']/);
81
+
82
+ const packageJson = JSON.parse(read("package.json"));
83
+ assert.doesNotMatch(packageJson.scripts.test, /\bCLAUDE_PATH=node\b/, "the legacy suite cannot require Claude globally");
84
+
85
+ const runner = require("./core/runner");
86
+ for (const name of ["runClaude", "runClaudeCapture", "runClaudeSilent"]) {
87
+ assert.strictEqual(Object.prototype.hasOwnProperty.call(runner, name), false, `${name} export must be removed`);
88
+ }
89
+
90
+ console.log("provider coupling audit OK");
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const RESULT_PREFIX = "__PROVIDER_DREAM_RESULT__";
12
+
13
+ function isolate(kind) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `open-claudia-dream-${kind}-`));
15
+ process.env.OPEN_CLAUDIA_TEST = "1";
16
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = path.join(root, "config");
17
+ process.env.PACKS_DIR = path.join(root, "packs");
18
+ process.env.ENTITIES_DIR = path.join(root, "entities");
19
+ process.env.LESSONS_FILE = path.join(root, "lessons.md");
20
+ process.env.PERSONA_FILE = path.join(root, "persona.md");
21
+ process.env.IDEAS_FILE = path.join(root, "ideas.md");
22
+ process.env.RECALL_GRAPH_DB = path.join(root, "recall-graph.db");
23
+ process.env.TRANSCRIPTS_DIR = path.join(root, "transcripts");
24
+ process.env.HOME = root;
25
+ process.env.WORKSPACE = root;
26
+ process.env.RECALL_METRICS = "on";
27
+ process.env.DREAM_INTROSPECT = "on";
28
+ process.env.DREAM_SELF_APPLY = "on";
29
+ process.env.DREAM_EFFORT = "max";
30
+ process.env.DREAM_TIER = "high";
31
+ fs.mkdirSync(process.env.OPEN_CLAUDIA_CONFIG_DIR, { recursive: true });
32
+ return root;
33
+ }
34
+
35
+ function registryFor(provider) {
36
+ return {
37
+ getProvider(id) {
38
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
39
+ return provider;
40
+ },
41
+ listProviders: () => [provider],
42
+ providerStatus(id) {
43
+ if (id !== provider.id) throw Object.assign(new Error(`unknown provider ${id}`), { code: "UNKNOWN_PROVIDER" });
44
+ return {
45
+ id,
46
+ availability: { state: "available", executable: provider.executable(), reason: null },
47
+ compatibility: { state: "compatible", reason: null },
48
+ auth: { state: "authenticated", reason: null },
49
+ };
50
+ },
51
+ };
52
+ }
53
+
54
+ function packSemantics(pack) {
55
+ return {
56
+ dir: pack.dir,
57
+ name: pack.name,
58
+ description: pack.description,
59
+ tags: pack.tags,
60
+ kind: pack.kind,
61
+ skill: pack.skill,
62
+ sections: pack.sections,
63
+ };
64
+ }
65
+
66
+ function entitySemantics(entity) {
67
+ return {
68
+ slug: entity.slug,
69
+ name: entity.name,
70
+ type: entity.type,
71
+ description: entity.description,
72
+ relationship: entity.relationship,
73
+ sections: entity.sections,
74
+ };
75
+ }
76
+
77
+ async function providerProbe(kind) {
78
+ const root = isolate(kind);
79
+ const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
80
+ const dreamDecision = {
81
+ merges: [],
82
+ umbrellas: [],
83
+ parents: [],
84
+ retag: [{ pack: "project", description: "Provider-neutral dream consolidation fixture", tags: ["dream", "provider", "memory"] }],
85
+ entity_merges: [],
86
+ entity_notes: [{ entity: "parity-harness", notes: "Runs the same deterministic dream decisions through either provider." }],
87
+ archive: [],
88
+ lessons: [],
89
+ persona: "too short",
90
+ tuning: null,
91
+ report: "I tidied the provider-parity memory and kept the durable pieces aligned. 💤",
92
+ };
93
+ const introspectionDecision = {
94
+ lessons_add: [{ text: "Always record the provider and model used for a dream.", src: "project", trigger: "again, tell me which provider dreamed" }],
95
+ doc_edits: [{ pack: "project", section: "State", to: "Dream and introspection both completed through the selected utility provider.", why: "provider parity was verified" }],
96
+ entity_edits: [{ entity: "parity-harness", notes: "Verifies consolidation and introspection through Claude and Codex." }],
97
+ ideas: [{ scope: "oc", text: "Show dream provider metadata in the morning summary." }],
98
+ persona: null,
99
+ report: "I checked my own memory loop and left a clear provider audit trail. 🪞",
100
+ };
101
+
102
+ const { createClaudeProvider } = require("./core/providers/claude");
103
+ const { createCodexProvider } = require("./core/providers/codex");
104
+ const createProvider = kind === "claude" ? createClaudeProvider : createCodexProvider;
105
+ const provider = createProvider({
106
+ resolveExecutable: () => fixture,
107
+ defaultModel: `${kind}-high-fixture`,
108
+ buildEnv: () => ({ PATH: process.env.PATH || "", HOME: root, FAKE_AGENT_KIND: kind }),
109
+ getAuthStatus: () => ({ state: "authenticated", reason: null }),
110
+ });
111
+ const originalBuildUtilityInvocation = provider.buildUtilityInvocation;
112
+ const captures = [];
113
+ provider.buildUtilityInvocation = (context) => {
114
+ const invocation = originalBuildUtilityInvocation(context);
115
+ const index = captures.length;
116
+ const capture = path.join(root, `${String(index).padStart(2, "0")}-${context.purpose}.json`);
117
+ captures.push({ capture, purpose: context.purpose });
118
+ invocation.env.FAKE_AGENT_CAPTURE = capture;
119
+ invocation.env.FAKE_AGENT_TEXT = JSON.stringify(context.purpose === "introspection" ? introspectionDecision : dreamDecision);
120
+ return invocation;
121
+ };
122
+
123
+ const model = `${kind}-dream-exact`;
124
+ const { createUtilityAgentService } = require("./core/utility-agent");
125
+ const utility = createUtilityAgentService({
126
+ registry: registryFor(provider),
127
+ env: {
128
+ UTILITY_PROVIDER: "active",
129
+ DEFAULT_PROVIDER: kind,
130
+ [`DREAM_MODEL_${kind.toUpperCase()}`]: model,
131
+ },
132
+ });
133
+ const { MemoryMutationQueue } = require("./core/memory-mutation-queue");
134
+ const queue = new MemoryMutationQueue({
135
+ lockDir: path.join(root, "dream.lock"),
136
+ journalFile: path.join(root, "dream-intents.jsonl"),
137
+ lockPollMs: 5,
138
+ });
139
+ const calls = [];
140
+ const runUtility = (prompt, options) => {
141
+ calls.push({
142
+ purpose: options.purpose,
143
+ provider: options.provider || null,
144
+ tier: options.tier,
145
+ readOnly: options.readOnly,
146
+ hasSchema: !!options.outputSchema,
147
+ effort: options.effort,
148
+ promptBytes: Buffer.byteLength(prompt),
149
+ });
150
+ return utility.spawnUtilityAgent(prompt, options);
151
+ };
152
+
153
+ const dream = require("./core/dream");
154
+ assert.strictEqual(typeof dream.createDreamService, "function", "dream exposes an injectable provider service");
155
+ const service = dream.createDreamService({ spawnUtilityAgent: runUtility, mutationQueue: queue });
156
+ const packs = require("./core/packs");
157
+ const entities = require("./core/entities");
158
+ const lessons = require("./core/lessons");
159
+ const ideas = require("./core/ideas");
160
+ const persona = require("./core/persona");
161
+ dream.writeDreamState({ legacyField: "preserved" });
162
+ packs.createPack({
163
+ dir: "project",
164
+ name: "Project",
165
+ description: "dream provider parity project",
166
+ stance: "Keep provider-neutral memory durable.",
167
+ procedure: "Use deterministic provider fixtures.",
168
+ state: "Dream provider parity is pending.",
169
+ });
170
+ entities.upsertEntity({ name: "Parity Harness", type: "system", description: "dream fixture", notes: "Initial fixture notes." });
171
+ const originalPersona = `Open Claudia stays direct, warm, and precise while testing memory. ${"She preserves durable facts and speaks with calm confidence. ".repeat(2)}`.trim();
172
+ persona.savePersona(originalPersona);
173
+
174
+ const beforePreview = packSemantics(packs.readPack("project"));
175
+ const preview = await service.previewDream();
176
+ assert.strictEqual(preview.provider, kind);
177
+ assert.strictEqual(preview.model, model);
178
+ assert.deepStrictEqual(preview.decision, dream.parseDream(JSON.stringify(dreamDecision)));
179
+ assert.deepStrictEqual(packSemantics(packs.readPack("project")), beforePreview, "dry-run preview never applies its decision");
180
+
181
+ const result = await service.runDream({ trigger: "cron" });
182
+ assert.strictEqual(result.provider, kind);
183
+ assert.strictEqual(result.model, model);
184
+ assert.strictEqual(result.introspectionProvider, kind);
185
+ assert.strictEqual(result.introspectionModel, model);
186
+ assert.strictEqual(result.trigger, "cron");
187
+ assert.match(result.message, new RegExp(`${kind}.*${model}`), "morning summary names the provider and model");
188
+ assert.match(result.message, /provider audit trail/i, "introspection narrative remains in the summary");
189
+
190
+ assert.deepStrictEqual(calls.map((call) => call.purpose), ["dream", "dream", "introspection"]);
191
+ for (const call of calls) {
192
+ assert.strictEqual(call.provider, null, "global dream selection is resolved by DEFAULT_PROVIDER when no chat is active");
193
+ assert.strictEqual(call.tier, "high");
194
+ assert.strictEqual(call.readOnly, true);
195
+ assert.strictEqual(call.hasSchema, true);
196
+ assert.strictEqual(call.effort, "max");
197
+ assert.ok(call.promptBytes > 100);
198
+ }
199
+
200
+ const project = packs.readPack("project");
201
+ const entity = entities.findEntity("Parity Harness");
202
+ assert.strictEqual(project.description, "Provider-neutral dream consolidation fixture");
203
+ assert.deepStrictEqual(project.tags, ["dream", "provider", "memory"]);
204
+ assert.strictEqual(project.sections.State, "Dream and introspection both completed through the selected utility provider.");
205
+ assert.strictEqual(entity.sections.Notes, "Verifies consolidation and introspection through Claude and Codex.");
206
+ assert.strictEqual(lessons.listLessons()[0].text, "Always record the provider and model used for a dream.");
207
+ assert.strictEqual(ideas.listIdeas()[0].text, "Show dream provider metadata in the morning summary.");
208
+ assert.strictEqual(persona.loadPersona(), originalPersona, "persona bounds still reject an undersized model proposal");
209
+
210
+ const state = dream.readDreamState();
211
+ assert.strictEqual(state.provider, kind);
212
+ assert.strictEqual(state.model, model);
213
+ assert.strictEqual(state.introspection.provider, kind);
214
+ assert.strictEqual(state.introspection.model, model);
215
+ assert.strictEqual(state.lastRunStatus, "succeeded");
216
+ assert.strictEqual(state.legacyField, "preserved", "provider metadata is an additive, backward-compatible dream-state migration");
217
+
218
+ assert.ok(result.reportPath && fs.existsSync(result.reportPath));
219
+ const report = fs.readFileSync(result.reportPath, "utf8");
220
+ assert.match(report, new RegExp(`Provider: ${kind}`));
221
+ assert.match(report, new RegExp(`Model: ${model}`));
222
+ assert.match(report, new RegExp(`Introspection: ${kind}.*${model}`));
223
+ const snapshots = require("./core/recall/metrics").readKpi();
224
+ const snapshot = snapshots.at(-1);
225
+ assert.strictEqual(snapshot.providers.dream, kind);
226
+ assert.strictEqual(snapshot.providers.introspection, kind);
227
+ assert.strictEqual(snapshot.models.dream, model);
228
+ assert.strictEqual(snapshot.models.introspection, model);
229
+
230
+ assert.ok(fs.existsSync(path.join(process.env.PACKS_DIR, "project", "PACK.md.bak")), "dream updates retain last-good backups");
231
+ assert.ok(fs.existsSync(`${process.env.PERSONA_FILE}.bak`) || fs.existsSync(path.join(root, "config", "backup")),
232
+ "persona proposal handling retains recovery data without bypassing bounds");
233
+ for (const { capture, purpose } of captures) {
234
+ const record = JSON.parse(fs.readFileSync(capture, "utf8"));
235
+ assert.strictEqual(record.stdin.present, true, `${purpose} prompt uses protected stdin`);
236
+ assert.ok(record.argv.includes(model));
237
+ assert.ok(record.argv.includes(kind === "claude" ? "plan" : "read-only"));
238
+ if (kind === "claude") {
239
+ assert.ok(record.argv.includes("--json-schema"));
240
+ assert.ok(record.argv.includes("max"), "Claude retains maximum dream effort");
241
+ } else {
242
+ assert.ok(record.argv.includes("--output-schema"));
243
+ assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*xhigh/.test(arg)), "generic max effort maps to Codex's strongest supported value");
244
+ }
245
+ }
246
+
247
+ // Provider/model failure still leaves the load-bearing deterministic phases
248
+ // running, and preserves the attempted provider/model in the result.
249
+ packs.createPack({ dir: "family", name: "Family", description: "prefix parent fixture" });
250
+ packs.createPack({ dir: "family-child", name: "Family Child", description: "prefix child fixture" });
251
+ process.env.DREAM_INTROSPECT = "off";
252
+ const failingService = dream.createDreamService({
253
+ mutationQueue: queue,
254
+ spawnUtilityAgent: async () => {
255
+ const error = Object.assign(new Error("provider failure fixture"), {
256
+ code: "UTILITY_PROVIDER_ERROR",
257
+ result: { provider: kind, model, effort: kind === "claude" ? "max" : "xhigh", fallbackUsed: false },
258
+ });
259
+ throw error;
260
+ },
261
+ });
262
+ const failed = await failingService.runDream({ trigger: "failure-fixture" });
263
+ process.env.DREAM_INTROSPECT = "on";
264
+ assert.strictEqual(failed.provider, kind);
265
+ assert.strictEqual(failed.model, model);
266
+ assert.match(failed.report, /skipped the AI-led merges/i);
267
+ assert.strictEqual(packs.readPack("family-child").parent, "family", "deterministic filing survives provider failure");
268
+
269
+ return {
270
+ provider: kind,
271
+ mutations: {
272
+ pack: packSemantics(project),
273
+ entity: entitySemantics(entity),
274
+ lessons: lessons.listLessons().map(({ text, src }) => ({ text, src })),
275
+ ideas: ideas.listIdeas().map(({ scope, text }) => ({ scope, text })),
276
+ persona: persona.loadPersona(),
277
+ },
278
+ };
279
+ }
280
+
281
+ function runChild(kind) {
282
+ const child = spawnSync(process.execPath, [__filename, "--probe", kind], {
283
+ cwd: __dirname,
284
+ env: { ...process.env },
285
+ encoding: "utf8",
286
+ timeout: 60000,
287
+ });
288
+ if (child.status !== 0) throw new Error(`${kind} dream probe failed (${child.status})\n${child.stdout}\n${child.stderr}`);
289
+ const line = child.stdout.split("\n").find((entry) => entry.startsWith(RESULT_PREFIX));
290
+ if (!line) throw new Error(`${kind} dream probe returned no result\n${child.stdout}\n${child.stderr}`);
291
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
292
+ }
293
+
294
+ async function main() {
295
+ if (process.argv[2] === "--probe") {
296
+ const result = await providerProbe(process.argv[3]);
297
+ console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`);
298
+ return;
299
+ }
300
+ const claude = runChild("claude");
301
+ const codex = runChild("codex");
302
+ assert.deepStrictEqual(codex.mutations, claude.mutations, "Claude and Codex dream decisions produce identical durable mutations");
303
+ const cliSource = fs.readFileSync(path.join(__dirname, "bin", "dream.js"), "utf8");
304
+ assert.ok(!cliSource.includes("spawnSubagent"), "manual dry-run no longer bypasses utility-provider selection");
305
+ assert.match(cliSource, /previewDream/);
306
+ console.log("provider dream OK");
307
+ }
308
+
309
+ main().catch((error) => {
310
+ console.error(error.stack || error.message);
311
+ process.exitCode = 1;
312
+ });