@inetafrica/open-claudia 2.14.8 → 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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -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 +105 -7
  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 +127 -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 +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
@@ -1,18 +1,16 @@
1
- // LLM relevance gate over keyword recall candidates. The FTS matchers in
2
- // packs.js/entities.js are high-recall but dumb — "zoom" pulls in the
3
- // video-gen pack, "kazee" pulls in every colleague. When keywords produce
4
- // candidates, a cheap model judges which are genuinely about the message.
5
- // Fail-open: any error, timeout, or unparseable reply keeps all candidates,
6
- // so recall never gets worse than the keyword baseline.
1
+ "use strict";
7
2
 
8
- const { spawnSubagent } = require("./subagent");
3
+ // LLM relevance gate over keyword recall candidates. Fail-open is deliberate:
4
+ // an unavailable provider, timeout, or malformed judgement retains the
5
+ // keyword baseline, so intelligence failure never makes classic recall worse.
9
6
 
10
- const FILTER_MODEL = process.env.RECALL_FILTER_MODEL || "haiku";
11
7
  const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
12
8
  const MESSAGE_CLIP_CHARS = 1500;
13
- // Quote + a window of recent turns: roomier than the message clip so the
14
- // newest history lines at the tail don't get sliced off.
15
9
  const CONTEXT_CLIP_CHARS = 3500;
10
+ const FILTER_OUTPUT_SCHEMA = Object.freeze({
11
+ type: "array",
12
+ items: { type: "string" },
13
+ });
16
14
 
17
15
  function enabled() {
18
16
  return (process.env.RECALL_FILTER || "on").toLowerCase() !== "off";
@@ -23,57 +21,84 @@ const FILTER_SYSTEM_PROMPT = [
23
21
  "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
24
22
  ].join("\n");
25
23
 
26
- // candidates: [{ id, name, description }]. opts.context: surrounding
27
- // conversation text (the message the user replied to and/or the last
28
- // few chat turns) used to disambiguate pronouns and terse follow-ups
29
- // like "push" or "the app" — context only, not a topic source in its
30
- // own right. Returns a Set of kept ids, or null to signal fail-open
31
- // (caller decides what that keeps).
32
- async function judgeRelevance(message, candidates, opts = {}) {
33
- if (!enabled() || candidates.length === 0) return null;
34
- const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
35
- const context = String(opts.context || "").slice(0, CONTEXT_CLIP_CHARS);
36
- const lines = candidates.map((c) => `- ${c.id}: ${c.name}${c.description ? ` — ${c.description}` : ""}`);
37
- const prompt = [
38
- "A user sent this message to their assistant:",
39
- "<<<",
40
- msg,
41
- ">>>",
42
- ...(context
43
- ? [
44
- "",
45
- "Surrounding conversation (the message it replied to and/or the most recent turns in this chat — context for resolving references like \"the app\", \"it\", or terse follow-ups like \"push\"; judge relevance against what the user's message is about in light of it):",
46
- "<<<",
47
- context,
48
- ">>>",
49
- ]
50
- : []),
51
- "",
52
- "These stored memory notes were keyword-matched as possibly relevant.",
53
- "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
54
- "Sharing an incidental word with the note (e.g. \"zoom\" matching a video tool, a person's employer matching a project name) is NOT relevance.",
55
- "If the conversation context shows the user means a different project than a candidate (e.g. they are discussing app A, and a candidate is the unrelated app B that merely shares words like \"mobile\" or \"gradient\"), drop that candidate.",
56
- "",
57
- "Candidates:",
58
- ...lines,
59
- "",
60
- `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
61
- ].join("\n");
62
- try {
63
- const { text } = await spawnSubagent(prompt, {
64
- model: FILTER_MODEL,
65
- systemPrompt: FILTER_SYSTEM_PROMPT,
66
- timeoutMs: FILTER_TIMEOUT_MS,
67
- });
68
- const match = String(text || "").match(/\[[\s\S]*?\]/);
69
- if (!match) return null;
70
- const ids = JSON.parse(match[0]);
71
- if (!Array.isArray(ids)) return null;
72
- const known = new Set(candidates.map((c) => c.id));
73
- return new Set(ids.filter((id) => typeof id === "string" && known.has(id)));
74
- } catch (e) {
75
- return null;
24
+ function parseIds(result) {
25
+ if (Array.isArray(result?.json)) return result.json;
26
+ const match = String(result?.text || "").match(/\[[\s\S]*?\]/);
27
+ if (!match) return null;
28
+ const parsed = JSON.parse(match[0]);
29
+ return Array.isArray(parsed) ? parsed : null;
30
+ }
31
+
32
+ function createRecallFilter(dependencies = {}) {
33
+ const runUtility = dependencies.spawnUtilityAgent || require("./utility-agent").spawnUtilityAgent;
34
+
35
+ async function judgeRelevance(message, candidates, opts = {}) {
36
+ if (!enabled() || candidates.length === 0) return null;
37
+ const msg = String(message || "").slice(0, MESSAGE_CLIP_CHARS);
38
+ const context = String(opts.context || "").slice(0, CONTEXT_CLIP_CHARS);
39
+ const lines = candidates.map((candidate) => (
40
+ `- ${candidate.id}: ${candidate.name}${candidate.description ? ` — ${candidate.description}` : ""}`
41
+ ));
42
+ const prompt = [
43
+ "A user sent this message to their assistant:",
44
+ "<<<",
45
+ msg,
46
+ ">>>",
47
+ ...(context
48
+ ? [
49
+ "",
50
+ "Surrounding conversation (the message it replied to and/or the most recent turns in this chat — context for resolving references like \"the app\", \"it\", or terse follow-ups like \"push\"; judge relevance against what the user's message is about in light of it):",
51
+ "<<<",
52
+ context,
53
+ ">>>",
54
+ ]
55
+ : []),
56
+ "",
57
+ "These stored memory notes were keyword-matched as possibly relevant.",
58
+ "Keep only the ones the message is GENUINELY about — the topic, project, or person itself.",
59
+ "Sharing an incidental word with the note (e.g. \"zoom\" matching a video tool, a person's employer matching a project name) is NOT relevance.",
60
+ "If the conversation context shows the user means a different project than a candidate (e.g. they are discussing app A, and a candidate is the unrelated app B that merely shares words like \"mobile\" or \"gradient\"), drop that candidate.",
61
+ "",
62
+ "Candidates:",
63
+ ...lines,
64
+ "",
65
+ `Reply with ONLY a JSON array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
66
+ ].join("\n");
67
+ try {
68
+ const result = await runUtility(prompt, {
69
+ purpose: "recall",
70
+ activeProvider: opts.provider || null,
71
+ tier: "low",
72
+ model: opts.model || null,
73
+ modelEnvPrefix: "RECALL_FILTER_MODEL",
74
+ legacyClaudeModelEnv: "RECALL_FILTER_MODEL",
75
+ systemPrompt: FILTER_SYSTEM_PROMPT,
76
+ readOnly: true,
77
+ outputSchema: FILTER_OUTPUT_SCHEMA,
78
+ timeoutMs: FILTER_TIMEOUT_MS,
79
+ cwd: opts.cwd || process.cwd(),
80
+ });
81
+ const ids = parseIds(result);
82
+ if (!ids) return null;
83
+ const known = new Set(candidates.map((candidate) => candidate.id));
84
+ return new Set(ids.filter((id) => typeof id === "string" && known.has(id)));
85
+ } catch (_) {
86
+ return null;
87
+ }
76
88
  }
89
+
90
+ return { judgeRelevance };
77
91
  }
78
92
 
79
- module.exports = { judgeRelevance };
93
+ const defaultFilter = createRecallFilter();
94
+
95
+ module.exports = {
96
+ CONTEXT_CLIP_CHARS,
97
+ FILTER_OUTPUT_SCHEMA,
98
+ FILTER_SYSTEM_PROMPT,
99
+ FILTER_TIMEOUT_MS,
100
+ MESSAGE_CLIP_CHARS,
101
+ createRecallFilter,
102
+ enabled,
103
+ judgeRelevance: defaultFilter.judgeRelevance,
104
+ };
package/core/router.js CHANGED
@@ -11,7 +11,7 @@ const { isChatAuthorized } = require("./access");
11
11
  const introFlow = require("./intro-flow");
12
12
  const { send, deleteMessage } = require("./io");
13
13
  const { currentState, autoLinkOwnerChannel } = require("./state");
14
- const { runClaude } = require("./runner");
14
+ const { admitRunContext, runAgent } = require("./runner");
15
15
  const { transcribeAudio } = require("./media");
16
16
  const { vault } = require("./vault-store");
17
17
  const { isOnboarded, handleOnboarding } = require("./onboarding");
@@ -26,6 +26,21 @@ 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
+ const USER_VISIBLE_PROVIDER_ERRORS = new Set([
30
+ "NO_AVAILABLE_PROVIDER",
31
+ "PROVIDER_UNAVAILABLE",
32
+ "PROVIDER_INCOMPATIBLE",
33
+ "PROVIDER_UNAUTHENTICATED",
34
+ ]);
35
+
36
+ async function deliverRouterError(error) {
37
+ const code = error?.code || "ROUTER_ERROR";
38
+ if (!USER_VISIBLE_PROVIDER_ERRORS.has(code)) return null;
39
+ const message = error?.message || "No compatible provider is available.";
40
+ await send(`${message}\n\nOpen /backend to review providers, or run /doctor for local compatibility and authentication checks.`);
41
+ return { ok: false, status: "failed", error: { code, message } };
42
+ }
43
+
29
44
  // Cross-channel dedup. Message ids are unique per adapter, not globally, so
30
45
  // the adapter stays in the key. Keying by canonical (not raw channelId) means
31
46
  // a message redelivered against a different channel that resolves to the same
@@ -109,6 +124,8 @@ async function onMessage(envelope) {
109
124
  if (envelope.type === "text") return handleText(envelope);
110
125
  } catch (e) {
111
126
  console.error("router:onMessage:", e.message);
127
+ const delivered = await deliverRouterError(e);
128
+ if (delivered) return delivered;
112
129
  }
113
130
  });
114
131
  }
@@ -133,6 +150,10 @@ async function handleVoice(envelope) {
133
150
  if (media.size && media.size > MAX_VOICE_SIZE) {
134
151
  return send(`Voice note too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
135
152
  }
153
+ const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
154
+ purpose: "foreground",
155
+ attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
156
+ });
136
157
  envelope.adapter.typing(envelope.channelId).catch(() => {});
137
158
  const tStart = Date.now();
138
159
  const oggPath = await envelope.adapter.downloadMedia(media);
@@ -145,7 +166,12 @@ async function handleVoice(envelope) {
145
166
  state.lastInputWasVoice = true;
146
167
  // Per-stage latency for the voice pipeline; the runner fills in the rest.
147
168
  state.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
148
- await runClaude(transcript, state.currentSession.dir, envelope.messageId);
169
+ return runAgent(transcript, runContext.cwd, envelope.messageId, {
170
+ runContext,
171
+ attachments: [{ type: "voice", path: oggPath, mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
172
+ lastInputWasVoice: true,
173
+ voiceStream: envelope.adapter?.type === "voice",
174
+ });
149
175
  } catch (err) { await send(`Voice failed: ${err.message}`); }
150
176
  }
151
177
 
@@ -156,13 +182,20 @@ async function handleAudio(envelope) {
156
182
  try {
157
183
  const media = envelope.media?.[0];
158
184
  if (!media) return;
185
+ const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
186
+ purpose: "foreground",
187
+ attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
188
+ });
159
189
  envelope.adapter.typing(envelope.channelId).catch(() => {});
160
190
  const p = await envelope.adapter.downloadMedia(media);
161
191
  const t = transcribeAudio(p);
162
192
  try { fs.unlinkSync(p); } catch (e) {}
163
193
  if (!t) return send("Couldn't transcribe.");
164
194
  await send(`Heard: "${t}"`, { replyTo: envelope.messageId });
165
- await runClaude(t, state.currentSession.dir, envelope.messageId);
195
+ return runAgent(t, runContext.cwd, envelope.messageId, {
196
+ runContext,
197
+ attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
198
+ });
166
199
  } catch (err) { await send(`Audio failed: ${err.message}`); }
167
200
  }
168
201
 
@@ -173,13 +206,26 @@ async function handlePhoto(envelope) {
173
206
  try {
174
207
  const items = envelope.media || [];
175
208
  if (!items.length) return;
209
+ const runContext = admitRunContext(envelope.caption || "", state.currentSession.dir, envelope.messageId, {
210
+ purpose: "foreground",
211
+ attachments: items.map((item) => ({ type: "image", mimeType: item.mimeType || "image/*", fileName: item.fileName || null })),
212
+ });
176
213
  const paths = [];
177
214
  for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
178
215
  const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
179
216
  const prompt = paths.length === 1
180
217
  ? `Image at ${paths[0]}\n\nView it, then: ${caption}`
181
218
  : `Images:\n${paths.map((p, i) => `${i + 1}. ${p}`).join("\n")}\n\nView all ${paths.length}, then: ${caption}`;
182
- await runClaude(prompt, state.currentSession.dir, envelope.messageId);
219
+ return runAgent(prompt, runContext.cwd, envelope.messageId, {
220
+ runContext,
221
+ imagePaths: paths,
222
+ attachments: paths.map((imagePath, index) => ({
223
+ type: "image",
224
+ path: imagePath,
225
+ mimeType: items[index]?.mimeType || "image/*",
226
+ fileName: items[index]?.fileName || path.basename(imagePath),
227
+ })),
228
+ });
183
229
  } catch (err) { await send(`Image failed: ${err.message}`); }
184
230
  }
185
231
 
@@ -193,6 +239,10 @@ async function handleDocument(envelope) {
193
239
  if (media.size && media.size > MAX_FILE_SIZE) {
194
240
  return send(`File too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
195
241
  }
242
+ const runContext = admitRunContext(envelope.caption || "", state.currentSession.dir, envelope.messageId, {
243
+ purpose: "foreground",
244
+ attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
245
+ });
196
246
  const savePath = await envelope.adapter.downloadMedia(media);
197
247
  const fileName = media.fileName || path.basename(savePath);
198
248
  const mime = media.mimeType || "";
@@ -202,7 +252,11 @@ async function handleDocument(envelope) {
202
252
  ? `Image file saved at ${savePath}\n\nView it, then: ${caption || "Describe this image. If code/UI/error — explain and fix."}`
203
253
  : `File received: ${fileName} (${mime})\nSaved at: ${savePath}\n\nRead this file and ${caption || "summarize its contents. If it's code, explain what it does. If it's a document, give key points."}`;
204
254
  await send(`File saved: ${fileName}`, { replyTo: envelope.messageId });
205
- await runClaude(prompt, state.currentSession.dir, envelope.messageId);
255
+ return runAgent(prompt, runContext.cwd, envelope.messageId, {
256
+ runContext,
257
+ imagePaths: isImage ? [savePath] : [],
258
+ attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
259
+ });
206
260
  } catch (err) { await send(`Failed: ${err.message}`); }
207
261
  }
208
262
 
@@ -339,6 +393,9 @@ async function handleText(envelope) {
339
393
  }
340
394
 
341
395
  const text = envelope.text || "";
396
+ const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
397
+ const attachments = [];
398
+ const imagePaths = [];
342
399
  const credPatterns = [
343
400
  /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
344
401
  /^[A-Za-z0-9_-]{20,}$/,
@@ -358,6 +415,9 @@ async function handleText(envelope) {
358
415
  const fileName = reply.document.file_name || reply.document.fileName || "unknown";
359
416
  const filePath = path.join(FILES_DIR, fileName);
360
417
  if (fs.existsSync(filePath)) {
418
+ const mimeType = reply.document.mime_type || reply.document.mimeType || null;
419
+ attachments.push({ type: mimeType?.startsWith("image/") ? "image" : "document", path: filePath, fileName, mimeType });
420
+ if (mimeType?.startsWith("image/")) imagePaths.push(filePath);
361
421
  ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${filePath}]`;
362
422
  } else if (reply.document.file_id) {
363
423
  // Attempt to fetch via adapter so this works on every channel.
@@ -368,6 +428,14 @@ async function handleText(envelope) {
368
428
  fileName,
369
429
  mimeType: reply.document.mime_type,
370
430
  });
431
+ const mimeType = reply.document.mime_type || reply.document.mimeType || null;
432
+ attachments.push({
433
+ type: mimeType?.startsWith("image/") ? "image" : "document",
434
+ path: localPath,
435
+ fileName,
436
+ mimeType,
437
+ });
438
+ if (mimeType?.startsWith("image/")) imagePaths.push(localPath);
371
439
  ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} at ${localPath}]`;
372
440
  } catch (e) {
373
441
  ctx += (ctx ? "\n" : "") + `[Attached file: ${fileName} — could not download]`;
@@ -383,7 +451,11 @@ async function handleText(envelope) {
383
451
  }
384
452
  }
385
453
 
386
- await runClaude(prompt, state.currentSession.dir, envelope.messageId);
454
+ return runAgent(prompt, runContext.cwd, envelope.messageId, {
455
+ runContext,
456
+ imagePaths,
457
+ attachments,
458
+ });
387
459
  }
388
460
 
389
- module.exports = { onMessage, onAction };
461
+ module.exports = { onMessage, onAction, deliverRouterError };
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const path = require("path");
5
+
6
+ function cloneSerializable(value, label = "run context value") {
7
+ if (value === undefined) return undefined;
8
+ try {
9
+ return structuredClone(value);
10
+ } catch (error) {
11
+ const wrapped = new TypeError(`${label} must be serializable`);
12
+ wrapped.code = "INVALID_RUN_CONTEXT";
13
+ wrapped.cause = error;
14
+ throw wrapped;
15
+ }
16
+ }
17
+
18
+ function deepFreeze(value, seen = new Set()) {
19
+ if (!value || typeof value !== "object" || Object.isFrozen(value) || seen.has(value)) return value;
20
+ seen.add(value);
21
+ for (const child of Object.values(value)) deepFreeze(child, seen);
22
+ return Object.freeze(value);
23
+ }
24
+
25
+ function projectSnapshot(state, cwd, requestedProject) {
26
+ const value = requestedProject !== undefined ? requestedProject : state.currentSession;
27
+ if (value && typeof value === "object") {
28
+ const dir = value.dir || value.path || cwd || null;
29
+ return {
30
+ name: value.name || (dir ? path.basename(dir) : null),
31
+ dir,
32
+ };
33
+ }
34
+ if (typeof value === "string" && value) {
35
+ return { name: value, dir: cwd || null };
36
+ }
37
+ const dir = cwd || null;
38
+ return { name: dir ? path.basename(dir) : null, dir };
39
+ }
40
+
41
+ function serializableOrigin(origin, fallback = {}) {
42
+ const source = origin || fallback || {};
43
+ return {
44
+ adapterId: source.adapterId || source.adapter?.id || null,
45
+ transport: source.transport || source.adapter?.type || null,
46
+ channelId: source.channelId != null ? String(source.channelId) : null,
47
+ userId: source.userId != null ? String(source.userId) : null,
48
+ canonicalUserId: source.canonicalUserId || null,
49
+ };
50
+ }
51
+
52
+ function captureRunContext({
53
+ state,
54
+ store = null,
55
+ prompt = "",
56
+ cwd = null,
57
+ replyToMsgId = null,
58
+ opts = {},
59
+ channelId = null,
60
+ canonicalUserId = null,
61
+ runId = null,
62
+ now = Date.now(),
63
+ } = {}) {
64
+ if (!state || typeof state !== "object") {
65
+ const error = new TypeError("state is required to capture a run context");
66
+ error.code = "INVALID_RUN_CONTEXT";
67
+ throw error;
68
+ }
69
+ if (typeof prompt !== "string") {
70
+ const error = new TypeError("prompt must be a string");
71
+ error.code = "INVALID_RUN_CONTEXT";
72
+ throw error;
73
+ }
74
+
75
+ const provider = String(opts.provider || state.settings?.backend || "").trim().toLowerCase();
76
+ if (provider === "cursor") {
77
+ const error = new Error("Cursor Agent support has been removed; choose Claude Code or OpenAI Codex");
78
+ error.code = "REMOVED_PROVIDER";
79
+ throw error;
80
+ }
81
+ if (!new Set(["claude", "codex"]).has(provider)) {
82
+ const error = new Error("Choose Claude Code or OpenAI Codex before starting a model turn");
83
+ error.code = "NO_AVAILABLE_PROVIDER";
84
+ throw error;
85
+ }
86
+ const project = projectSnapshot(state, cwd, opts.project);
87
+ const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
88
+ const admittedSessionId = opts.fresh
89
+ ? null
90
+ : (opts.resumeSessionId || activeSessionId || null);
91
+ const origin = serializableOrigin(opts.origin, store || {});
92
+ const resolvedChannelId = channelId != null
93
+ ? String(channelId)
94
+ : (origin.channelId || (state.channelId != null ? String(state.channelId) : null));
95
+ const providerSettings = cloneSerializable({
96
+ ...(state.providerSettings?.[provider] || state.settings || {}),
97
+ ...(opts.providerSettings || {}),
98
+ }, "provider settings");
99
+ delete providerSettings.backend;
100
+
101
+ const context = {
102
+ runId: runId || (typeof crypto.randomUUID === "function"
103
+ ? crypto.randomUUID()
104
+ : `run-${now}-${Math.random().toString(36).slice(2, 10)}`),
105
+ admittedAt: now,
106
+ canonicalUserId: canonicalUserId || state.userId || origin.canonicalUserId || null,
107
+ project,
108
+ provider,
109
+ providerSettings,
110
+ sessionId: admittedSessionId,
111
+ admittedSessionId,
112
+ previousSessionId: activeSessionId,
113
+ purpose: opts.purpose || (opts.label ? String(opts.label) : "foreground"),
114
+ channelId: resolvedChannelId,
115
+ origin,
116
+ cwd: cwd || project.dir || null,
117
+ replyToMsgId: replyToMsgId == null ? null : String(replyToMsgId),
118
+ userPrompt: prompt,
119
+ fresh: !!opts.fresh,
120
+ continueSession: !!opts.continueSession,
121
+ resumeSessionId: opts.resumeSessionId || null,
122
+ skipAutoCompact: !!opts.skipAutoCompact,
123
+ timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
124
+ maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
125
+ imagePaths: cloneSerializable(opts.imagePaths || opts.images || [], "image paths"),
126
+ attachments: cloneSerializable(opts.attachments || [], "attachments"),
127
+ lastInputWasVoice: !!state.lastInputWasVoice,
128
+ voiceStream: !!opts.voiceStream || (!!state.lastInputWasVoice && origin.transport === "voice"),
129
+ readOnly: opts.readOnly === true,
130
+ permissionMode: opts.permissionMode || null,
131
+ transcriptRole: opts.transcriptRole || null,
132
+ label: opts.label || null,
133
+ toolHookSettings: opts.toolHookSettings || null,
134
+ allowReadOnlyWithoutToolHook: opts.allowReadOnlyWithoutToolHook === true,
135
+ requiredDelivery: opts.requiredDelivery !== false,
136
+ attachSession: opts.attachSession !== false,
137
+ occurrenceId: opts.occurrenceId || null,
138
+ scheduledJobId: opts.scheduledJobId || null,
139
+ originRunId: opts.originRunId || null,
140
+ };
141
+
142
+ return deepFreeze(context);
143
+ }
144
+
145
+ function sameCapturedProject(state, runContext) {
146
+ const live = state?.currentSession;
147
+ const captured = runContext?.project;
148
+ if (!live || !captured) return !live && !captured;
149
+ return String(live.name || "") === String(captured.name || "")
150
+ && String(live.dir || live.path || "") === String(captured.dir || captured.path || "");
151
+ }
152
+
153
+ function bindRunInputs(runContext, inputs = {}) {
154
+ if (!runContext || typeof runContext !== "object") {
155
+ const error = new TypeError("captured run context is required");
156
+ error.code = "INVALID_RUN_CONTEXT";
157
+ throw error;
158
+ }
159
+ const next = {
160
+ ...runContext,
161
+ userPrompt: inputs.userPrompt !== undefined ? String(inputs.userPrompt) : runContext.userPrompt,
162
+ imagePaths: cloneSerializable(
163
+ inputs.imagePaths !== undefined ? inputs.imagePaths : runContext.imagePaths,
164
+ "image paths",
165
+ ),
166
+ attachments: cloneSerializable(
167
+ inputs.attachments !== undefined ? inputs.attachments : runContext.attachments,
168
+ "attachments",
169
+ ),
170
+ lastInputWasVoice: inputs.lastInputWasVoice !== undefined
171
+ ? !!inputs.lastInputWasVoice
172
+ : runContext.lastInputWasVoice,
173
+ voiceStream: inputs.voiceStream !== undefined ? !!inputs.voiceStream : runContext.voiceStream,
174
+ };
175
+ return deepFreeze(next);
176
+ }
177
+
178
+ module.exports = {
179
+ captureRunContext,
180
+ bindRunInputs,
181
+ cloneSerializable,
182
+ deepFreeze,
183
+ sameCapturedProject,
184
+ serializableOrigin,
185
+ };