@desplega.ai/agent-swarm 2.4.3 → 2.4.5

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 (270) hide show
  1. package/dist/{acp-adapter-1885dpr7.js → acp-adapter-2wdkktv6.js} +1 -2
  2. package/dist/{actions-pn76n32q.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-af84rx1d.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-6sv8mhzm.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-sc2tf825.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-37sp626b.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-rp0jtnwr.js → claude-adapter-m7q0a085.js} +2 -2
  11. package/dist/{claude-managed-adapter-h2y889ge.js → claude-managed-adapter-5ptb6tzs.js} +2 -2
  12. package/dist/{claude-managed-setup-rdt248k7.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-6eyncg0y.js → cli-018r4jq6.js} +34 -6
  15. package/dist/{cli-sds5zzs8.js → cli-1f3qzw6y.js} +78 -12
  16. package/dist/{cli-f0p0f6ja.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-dm8p2dpf.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-tkf4x6ha.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-v87dr6e3.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-1n9gdg56.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-p0qerd2r.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-aa3tb29r.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-1xsraagq.js → cli-94gwyg7m.js} +8 -8
  25. package/dist/{cli-0dxxwfkt.js → cli-9hstj09h.js} +17 -14
  26. package/dist/{cli-1012xb7j.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-pygdsgh8.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
  29. package/dist/{cli-bxrqvpg5.js → cli-be2161h1.js} +7 -7
  30. package/dist/{cli-npfe9t1s.js → cli-bnes4z6b.js} +2 -2
  31. package/dist/{cli-aypbg9r2.js → cli-cxxe61f6.js} +1 -1
  32. package/dist/{cli-vq9gqarq.js → cli-dk44dxec.js} +1 -1
  33. package/dist/{cli-836ehxmt.js → cli-eww120g8.js} +3 -2
  34. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  35. package/dist/{cli-mzkwkbdy.js → cli-f0g3pgvf.js} +1 -1
  36. package/dist/{cli-1ma335fz.js → cli-ftc1g5tj.js} +2 -2
  37. package/dist/{cli-4yef8ghv.js → cli-fv5hepyy.js} +3 -3
  38. package/dist/{cli-58vwn100.js → cli-g35m2xwm.js} +4 -4
  39. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  40. package/dist/{cli-gx0j2khs.js → cli-j99azg3g.js} +3 -3
  41. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  42. package/dist/{cli-qp2m4btv.js → cli-kpw04va0.js} +2 -2
  43. package/dist/{cli-8y99kfey.js → cli-ms85309d.js} +33 -15
  44. package/dist/{cli-kgnkyabk.js → cli-n72rg53p.js} +1555 -645
  45. package/dist/{cli-e4d78xsv.js → cli-p14m2cnp.js} +1 -1
  46. package/dist/{cli-1eszr343.js → cli-qfxc84gh.js} +1 -1
  47. package/dist/{cli-wk28hzqn.js → cli-qwhy2qma.js} +1 -1
  48. package/dist/{cli-73ezfhha.js → cli-st8aw1m6.js} +228 -11
  49. package/dist/{cli-zvbb4n0e.js → cli-tw4p90jb.js} +92 -192
  50. package/dist/{cli-038fga5k.js → cli-v0yrfmdh.js} +148 -183
  51. package/dist/{cli-6aen3ezb.js → cli-v8fjakwf.js} +1 -1
  52. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  53. package/dist/{cli-vcgscaan.js → cli-wt0jf9hm.js} +217 -80
  54. package/dist/{cli-s9rr4gr2.js → cli-xpa64rek.js} +1065 -305
  55. package/dist/{cli-h0mgxann.js → cli-z7d29rw9.js} +2 -2
  56. package/dist/cli.js +45 -20
  57. package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-tn6k5mkg.js} +1 -1
  58. package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-7vjt7jbw.js} +1 -1
  59. package/dist/{commands-hd574th6.js → commands-t7rz9146.js} +2 -2
  60. package/dist/{controller-pz5mcfg4.js → controller-m2jasma5.js} +2 -2
  61. package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-9ftpex99.js} +19 -19
  62. package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-mjv79e4z.js} +1 -1
  63. package/dist/{db-6nf1207q.js → db-6sfrn9g3.js} +1 -1
  64. package/dist/{db-h06dxp05.js → db-rn673r06.js} +3 -3
  65. package/dist/{dispatcher-zthpy4m1.js → dispatcher-c155xz0p.js} +17 -17
  66. package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-3mmszc1x.js} +1 -1
  67. package/dist/{external-channel-x01t7ke6.js → external-channel-35k6tkv8.js} +2 -2
  68. package/dist/{grok-adapter-h21jv594.js → grok-adapter-tfrq7tg1.js} +2 -3
  69. package/dist/handlers-7gw64dxw.js +39 -0
  70. package/dist/{heartbeat-zzfksckz.js → heartbeat-dyg4npkp.js} +20 -20
  71. package/dist/{hook-z4zhzj1f.js → hook-drspbwpe.js} +1 -1
  72. package/dist/{hook-3xfqhh2s.js → hook-x26b31bg.js} +1 -1
  73. package/dist/{http-q813tmya.js → http-pk972xyw.js} +571 -294
  74. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  75. package/dist/{index-x8xbyn1t.js → index-arytgj5q.js} +1 -1
  76. package/dist/{index-wbbs14c5.js → index-bgdyvcnv.js} +17 -17
  77. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  78. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  79. package/dist/{index-m0a4ydqj.js → index-gabbcdn2.js} +17 -17
  80. package/dist/{index-220tk9bw.js → index-jwxkkex2.js} +139 -76
  81. package/dist/index-tfb26zhy.js +41 -0
  82. package/dist/{keepalive-y5t1my7m.js → keepalive-7yrng7px.js} +3 -3
  83. package/dist/{lead-2xwtq66b.js → lead-7e02z839.js} +29 -30
  84. package/dist/{maintenance-ybfsaedv.js → maintenance-e6cemdqt.js} +1 -1
  85. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  86. package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  87. package/dist/{onboard-xn6fkkys.js → onboard-0a9y5yrc.js} +2 -2
  88. package/dist/{otel-impl-e6mn0z13.js → otel-impl-trjpkfb7.js} +1 -1
  89. package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-d2b5my0y.js} +3 -3
  90. package/dist/{rbac-roles-mb470p4e.js → rbac-roles-hey3yv9d.js} +2 -2
  91. package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-nxh5ahwk.js} +2 -2
  92. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  93. package/dist/{render-v2-g0306nh1.js → render-v2-rr3p2atg.js} +3 -3
  94. package/dist/{resume-v315hnq6.js → resume-2efkf1s1.js} +5 -5
  95. package/dist/{session-access-2zg285a1.js → session-access-s2mttxpw.js} +6 -4
  96. package/dist/{setup-a4mtf02k.js → setup-vypma5yt.js} +1 -1
  97. package/dist/{work-objects-9fr0fgtn.js → work-objects-rvhckdkp.js} +20 -20
  98. package/dist/{worker-qae22jeb.js → worker-5snytpbd.js} +29 -30
  99. package/dist/worker-token-xf11yyds.js +136 -0
  100. package/openapi.json +303 -13
  101. package/package.json +8 -8
  102. package/src/apps/action-runner.ts +4 -0
  103. package/src/apps/catalog.generated.json +209 -4
  104. package/src/apps/page-validator.ts +21 -3
  105. package/src/be/chunking.ts +42 -9
  106. package/src/be/db/tasks/draft-text.ts +15 -0
  107. package/src/be/db/tasks/read.ts +14 -1
  108. package/src/be/db.ts +5 -1
  109. package/src/be/memory/index-content.ts +36 -17
  110. package/src/be/memory/providers/openai-embedding.ts +115 -61
  111. package/src/be/memory/providers/sqlite-store.ts +69 -23
  112. package/src/be/memory/reranker.ts +31 -1
  113. package/src/be/memory/search-candidates.ts +47 -0
  114. package/src/be/memory/types.ts +14 -0
  115. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  116. package/src/be/scripts/typecheck.ts +2 -0
  117. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +116 -55
  118. package/src/be/seed-scripts/index.ts +2 -2
  119. package/src/be/swarm-config-guard.ts +40 -1
  120. package/src/be/task-attachment-store.ts +30 -0
  121. package/src/be/users.ts +169 -12
  122. package/src/cli.tsx +24 -0
  123. package/src/commands/worker-token.ts +164 -0
  124. package/src/extensions/catalog.generated.json +4 -4
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/extensions/contract.ts +7 -0
  127. package/src/fs/provider.ts +13 -1
  128. package/src/heartbeat/heartbeat.ts +31 -11
  129. package/src/http/apps.ts +43 -5
  130. package/src/http/codex-oauth-keep-warm.ts +27 -9
  131. package/src/http/config.ts +5 -3
  132. package/src/http/core.ts +2 -0
  133. package/src/http/fs.ts +54 -34
  134. package/src/http/mcp-body.ts +37 -0
  135. package/src/http/mcp-user.ts +3 -5
  136. package/src/http/mcp.ts +6 -7
  137. package/src/http/memory.ts +26 -15
  138. package/src/http/page-proxy.ts +61 -5
  139. package/src/http/pages-public.ts +27 -44
  140. package/src/http/pages.ts +4 -4
  141. package/src/http/script-runs.ts +2 -0
  142. package/src/http/session-access.ts +51 -2
  143. package/src/http/sessions.ts +249 -10
  144. package/src/http/tasks.ts +4 -32
  145. package/src/http/users.ts +12 -1
  146. package/src/integrations/chat-sdk/adapters.ts +7 -3
  147. package/src/integrations/chat-sdk/attachments.ts +90 -12
  148. package/src/integrations/chat-sdk/facts.ts +120 -0
  149. package/src/integrations/chat-sdk/inbound.ts +156 -8
  150. package/src/integrations/chat-sdk/limits.ts +24 -0
  151. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  152. package/src/integrations/chat-sdk/outbound.ts +234 -4
  153. package/src/integrations/chat-sdk/relay.ts +4 -1
  154. package/src/integrations/chat-sdk/router.ts +28 -2
  155. package/src/integrations/chat-sdk/steering.ts +9 -0
  156. package/src/integrations/chat-sdk/templates.ts +14 -0
  157. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  158. package/src/mcp-limits.ts +20 -0
  159. package/src/pages/browser-sdk.ts +8 -3
  160. package/src/pages/feedback-overlay.ts +1 -1
  161. package/src/providers/acp-adapter.ts +33 -13
  162. package/src/providers/acp-swarm-events.ts +1 -1
  163. package/src/providers/amp-adapter.ts +1 -1
  164. package/src/providers/codex-adapter.ts +106 -119
  165. package/src/providers/codex-agents-md.ts +35 -229
  166. package/src/providers/codex-oauth/storage.ts +1 -1
  167. package/src/providers/cursor-adapter.ts +1 -1
  168. package/src/rbac/legacy-policy.ts +3 -1
  169. package/src/rbac/permissions.ts +4 -0
  170. package/src/realtime/transport.ts +1 -2
  171. package/src/script-workflows/workflow-ctx.ts +1 -0
  172. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  173. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  174. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  175. package/src/server-user.ts +2 -0
  176. package/src/server.ts +2 -0
  177. package/src/slack/assistant.ts +2 -2
  178. package/src/slack/files.ts +2 -0
  179. package/src/slack/handlers.ts +4 -4
  180. package/src/slack/inbound-files.ts +39 -6
  181. package/src/slack/thread-buffer.ts +2 -1
  182. package/src/tasks/task-terminal-effects.ts +21 -17
  183. package/src/tests/acp-adapter.test.ts +10 -23
  184. package/src/tests/acp-fake-agent.ts +36 -0
  185. package/src/tests/acp-swarm-events.test.ts +1 -1
  186. package/src/tests/agent-session-tokens.test.ts +452 -0
  187. package/src/tests/apps-action-parent-task.test.ts +295 -0
  188. package/src/tests/apps-card-grid.test.ts +197 -0
  189. package/src/tests/apps-catalog-additions.test.ts +120 -0
  190. package/src/tests/apps-tree.test.ts +129 -0
  191. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  192. package/src/tests/chat-sdk-config.test.ts +11 -0
  193. package/src/tests/chat-sdk-facts.test.ts +23 -0
  194. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  195. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  196. package/src/tests/codex-adapter.test.ts +167 -242
  197. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  198. package/src/tests/config-api-only-keys.test.ts +9 -0
  199. package/src/tests/config-session-auth.test.ts +14 -0
  200. package/src/tests/cors-allowlist.test.ts +1 -1
  201. package/src/tests/docs-index-footprint.test.js +2 -2
  202. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  203. package/src/tests/grok-adapter.test.ts +3 -3
  204. package/src/tests/heartbeat-checklist.test.ts +33 -0
  205. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  206. package/src/tests/heartbeat-jev-gate.test.ts +265 -1
  207. package/src/tests/kv-page-proxy.test.ts +91 -5
  208. package/src/tests/launch-password-rejection.test.ts +2 -2
  209. package/src/tests/mcp-input-caps.test.ts +294 -0
  210. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  211. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  212. package/src/tests/memory.test.ts +14 -0
  213. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  214. package/src/tests/page-guest-session.test.ts +19 -8
  215. package/src/tests/page-proxy-authed.test.ts +1 -1
  216. package/src/tests/page-proxy.test.ts +17 -10
  217. package/src/tests/page-session.test.ts +102 -1
  218. package/src/tests/pages-authed-mode.test.ts +82 -12
  219. package/src/tests/pages-password-mode.test.ts +5 -5
  220. package/src/tests/rbac-engine.test.ts +1 -0
  221. package/src/tests/realtime-transport.test.ts +13 -7
  222. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  223. package/src/tests/script-connections.test.ts +2 -0
  224. package/src/tests/script-runs-http.test.ts +84 -0
  225. package/src/tests/scripts-http.test.ts +10 -1
  226. package/src/tests/session-route-posture.test.ts +2 -0
  227. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  228. package/src/tests/task-audio-transcription.test.ts +299 -0
  229. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  230. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  231. package/src/tests/transcription.test.ts +299 -0
  232. package/src/tests/worker-token-cli.test.ts +76 -0
  233. package/src/tests/workflow-executors.test.ts +4 -2
  234. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  235. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  236. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  237. package/src/tools/chat-access.ts +10 -0
  238. package/src/tools/chat-post.ts +31 -11
  239. package/src/tools/chat-reply.ts +32 -10
  240. package/src/tools/memory-search.ts +3 -1
  241. package/src/tools/system-one-decide.ts +17 -4
  242. package/src/types.ts +8 -1
  243. package/src/utils/config-env-export.ts +3 -1
  244. package/src/utils/page-session.ts +133 -20
  245. package/src/utils/request-auth-context.ts +2 -1
  246. package/src/utils/transcription.ts +246 -0
  247. package/src/workflows/definition.ts +7 -1
  248. package/src/workflows/engine.ts +18 -12
  249. package/src/workflows/executors/index.ts +1 -0
  250. package/src/workflows/executors/registry.ts +2 -0
  251. package/src/workflows/executors/system-one-decision.ts +84 -262
  252. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  253. package/src/workflows/executors/system-one-extraction.ts +830 -0
  254. package/src/workflows/executors/system-one-providers.ts +316 -114
  255. package/src/workflows/executors/system-one-transport.ts +264 -0
  256. package/templates/extensions/slack-reply-gate/README.md +24 -22
  257. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  258. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  259. package/templates/official/lead/HEARTBEAT.md +1 -1
  260. package/templates/skills/apps/SKILL.md +31 -7
  261. package/templates/skills/apps/content.md +31 -7
  262. package/templates/skills/chat-sdk/content.md +6 -2
  263. package/templates/skills/code-quality/content.md +2 -1
  264. package/templates/skills/heartbeat-runbook/content.md +8 -8
  265. package/templates/skills/pages/content.md +6 -4
  266. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  267. package/templates/skills/swarm-scripts/content.md +2 -2
  268. package/dist/cli-v1sjhn52.js +0 -7
  269. package/dist/handlers-2qjw434w.js +0 -39
  270. package/dist/index-d0g18bax.js +0 -41
@@ -0,0 +1,830 @@
1
+ import { z } from "zod";
2
+ import type { ExecutorMeta } from "../../types";
3
+ import {
4
+ BaseExecutor,
5
+ type ExecutorDependencies,
6
+ type ExecutorReadinessNode,
7
+ type ExecutorReadinessProblem,
8
+ type ExecutorResult,
9
+ } from "./base";
10
+ import {
11
+ resolveSystemOneTarget,
12
+ SYSTEM_ONE_EXTRACT_DEFAULT_PROVIDER,
13
+ SYSTEM_ONE_EXTRACT_PROVIDER_IDS,
14
+ SYSTEM_ONE_HOSTS,
15
+ type SystemOneExtractProviderId,
16
+ type SystemOneHost,
17
+ systemOneExtractProviderOf,
18
+ systemOneModelProblem,
19
+ } from "./system-one-providers";
20
+ import {
21
+ SYSTEM_ONE_DEFAULT_MAX_RETRIES,
22
+ SYSTEM_ONE_DEFAULT_TIMEOUT_MS,
23
+ SYSTEM_ONE_MAX_RETRIES_LIMIT,
24
+ SYSTEM_ONE_MAX_TIMEOUT_MS,
25
+ SYSTEM_ONE_MIN_TIMEOUT_MS,
26
+ type SystemOneTransportOptions,
27
+ sendSystemOneRequest,
28
+ } from "./system-one-transport";
29
+ import { isRecord, SystemOneWireError, VALIDATION_PREFIX } from "./system-one-wire";
30
+
31
+ /**
32
+ * `system-one-extraction`: read typed values out of a text (and up to four images)
33
+ * with a System One extraction model, Cloudflare's Clairic by default. Each field
34
+ * declares its type; the answer for it is typed the same way, with the model's own
35
+ * uncertainty (`p_null`, `confidence`, quantiles) next to the value, so a later
36
+ * node branches on `<node>.answers.<field>.value` or on how sure the model was.
37
+ *
38
+ * The host, credential, endpoint, transport, and messages are the ones
39
+ * `system-one-decision` uses (`SYSTEM_ONE_HOSTS`); only the request and answers differ.
40
+ */
41
+
42
+ // ─── Constants ──────────────────────────────────────────────
43
+
44
+ export const SYSTEM_ONE_EXTRACTION_NODE_TYPE = "system-one-extraction";
45
+
46
+ export const EXTRACTION_FIELD_TYPES = [
47
+ "text",
48
+ "number",
49
+ "date",
50
+ "time",
51
+ "datetime",
52
+ "duration",
53
+ "list",
54
+ "estimate",
55
+ "forecast",
56
+ ] as const;
57
+
58
+ const DURATION_UNITS = ["seconds", "minutes", "hours", "days", "weeks", "months", "years"] as const;
59
+ const LIST_ITEMS = ["text", "number", "date"] as const;
60
+ const FORECAST_INTERVALS = [
61
+ "step",
62
+ "minute",
63
+ "hour",
64
+ "day",
65
+ "week",
66
+ "month",
67
+ "quarter",
68
+ "year",
69
+ ] as const;
70
+
71
+ /** Field ids double as output paths (`answers.<id>.value`), so no dots. Clairic allows 64 characters. */
72
+ const FIELD_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
73
+ const FIELD_ID_MESSAGE =
74
+ "field ids must start with a letter or underscore, use only letters, digits, _ or -, and be at most 64 characters";
75
+ const RESERVED_FIELD_IDS = new Set([...Object.getOwnPropertyNames(Object.prototype), "prototype"]);
76
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
77
+
78
+ const MAX_IMAGES = 4;
79
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
80
+ const MAX_IMAGES_TOTAL_BYTES = 8 * 1024 * 1024;
81
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
82
+ const DATA_URL_RE = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=\s]+)$/i;
83
+
84
+ // ─── Config schema ──────────────────────────────────────────
85
+
86
+ const Instructions = z.string().trim().min(1, "instructions must not be blank");
87
+ const NullThreshold = z
88
+ .number()
89
+ .gt(0, "null_threshold must be above 0")
90
+ .max(1, "null_threshold must be at most 1");
91
+ const Decimals = z.number().int().min(0).max(6);
92
+ const Unit = z.string().trim().min(1, "unit must not be blank");
93
+ const Scale = z.enum(["linear", "log"]);
94
+ const Bound = z.union([
95
+ z.number(),
96
+ z.string().regex(ISO_DATE_RE, "must be a number or an ISO date (YYYY-MM-DD)"),
97
+ ]);
98
+
99
+ const plain = { instructions: Instructions.optional(), null_threshold: NullThreshold.optional() };
100
+
101
+ const TextField = z.strictObject({ type: z.literal("text"), ...plain });
102
+ const NumberField = z.strictObject({
103
+ type: z.literal("number"),
104
+ ...plain,
105
+ unit: Unit.optional(),
106
+ integer: z.boolean().optional(),
107
+ decimals: Decimals.optional(),
108
+ });
109
+ const DateField = z.strictObject({ type: z.literal("date"), ...plain });
110
+ const TimeField = z.strictObject({ type: z.literal("time"), ...plain });
111
+ const DatetimeField = z.strictObject({ type: z.literal("datetime"), ...plain });
112
+ const DurationField = z.strictObject({
113
+ type: z.literal("duration"),
114
+ ...plain,
115
+ unit: z.enum(DURATION_UNITS).optional(),
116
+ integer: z.boolean().optional(),
117
+ decimals: Decimals.optional(),
118
+ });
119
+ const ListField = z.strictObject({
120
+ type: z.literal("list"),
121
+ instructions: Instructions.optional(),
122
+ items: z.enum(LIST_ITEMS),
123
+ decimals: Decimals.optional(),
124
+ });
125
+ const EstimateField = z.strictObject({
126
+ type: z.literal("estimate"),
127
+ instructions: Instructions.optional(),
128
+ unit: Unit.optional(),
129
+ min: Bound,
130
+ max: Bound,
131
+ step: z.union([z.number().positive(), z.enum(["month", "year"])]).optional(),
132
+ scale: Scale.optional(),
133
+ });
134
+ const ForecastField = z.strictObject({
135
+ type: z.literal("forecast"),
136
+ instructions: Instructions.optional(),
137
+ unit: Unit.optional(),
138
+ min: z.number(),
139
+ max: z.number(),
140
+ horizon: z.number().int().min(1).max(64).optional(),
141
+ interval: z.enum(FORECAST_INTERVALS).optional(),
142
+ scale: Scale.optional(),
143
+ });
144
+
145
+ export const SystemOneExtractionFieldSchema = z
146
+ .discriminatedUnion("type", [
147
+ TextField,
148
+ NumberField,
149
+ DateField,
150
+ TimeField,
151
+ DatetimeField,
152
+ DurationField,
153
+ ListField,
154
+ EstimateField,
155
+ ForecastField,
156
+ ])
157
+ .superRefine((field, ctx) => {
158
+ if ("integer" in field && field.integer && field.decimals !== undefined && field.decimals > 0) {
159
+ ctx.addIssue({
160
+ code: "custom",
161
+ path: ["decimals"],
162
+ message: "an integer field cannot also set decimals above 0",
163
+ });
164
+ }
165
+ if (field.type === "list" && field.decimals !== undefined && field.items !== "number") {
166
+ ctx.addIssue({
167
+ code: "custom",
168
+ path: ["decimals"],
169
+ message: 'decimals applies only to a list of items "number"',
170
+ });
171
+ }
172
+ if (field.type === "estimate" || field.type === "forecast") {
173
+ const { min, max } = field;
174
+ if (typeof min !== typeof max) {
175
+ ctx.addIssue({
176
+ code: "custom",
177
+ path: ["max"],
178
+ message: "min and max must both be numbers or both be ISO dates",
179
+ });
180
+ } else if (min >= max) {
181
+ ctx.addIssue({ code: "custom", path: ["max"], message: "max must be greater than min" });
182
+ }
183
+ if (field.scale === "log" && typeof min === "number" && min <= 0) {
184
+ ctx.addIssue({
185
+ code: "custom",
186
+ path: ["min"],
187
+ message: 'a "log" scale needs min above 0',
188
+ });
189
+ }
190
+ if (
191
+ field.type === "estimate" &&
192
+ typeof field.step === "string" &&
193
+ typeof field.min === "number"
194
+ ) {
195
+ ctx.addIssue({
196
+ code: "custom",
197
+ path: ["step"],
198
+ message: `step "${field.step}" applies only to a date estimate (ISO date min and max)`,
199
+ });
200
+ }
201
+ }
202
+ });
203
+
204
+ export type SystemOneExtractionField = z.infer<typeof SystemOneExtractionFieldSchema>;
205
+
206
+ /** An embedded image: a `data:` URL or `{ content_type, base64 }`. Remote URLs are not accepted by Clairic. */
207
+ const ImageSchema = z.union([
208
+ z.string().regex(DATA_URL_RE, "an image must be a base64 data: URL of a PNG, JPEG or WebP"),
209
+ z.strictObject({ content_type: z.enum(IMAGE_TYPES), base64: z.string().min(1) }),
210
+ ]);
211
+
212
+ type ExtractionImage = z.infer<typeof ImageSchema>;
213
+
214
+ /** Decoded size of one image, from its base64 length. */
215
+ function imageBytes(image: ExtractionImage): number {
216
+ const base64 = (
217
+ typeof image === "string" ? (DATA_URL_RE.exec(image)?.[2] ?? "") : image.base64
218
+ ).replace(/\s+/g, "");
219
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
220
+ return Math.floor((base64.length * 3) / 4) - padding;
221
+ }
222
+
223
+ export const SystemOneExtractionConfigSchema = z
224
+ .strictObject({
225
+ provider: z.enum(SYSTEM_ONE_EXTRACT_PROVIDER_IDS).default(SYSTEM_ONE_EXTRACT_DEFAULT_PROVIDER),
226
+ /** Provider-specific model id. Unset means the provider's default. */
227
+ model: z.string().min(1).optional(),
228
+ /** The text to read. A structured value is sent as JSON text. May be empty when images are sent. */
229
+ state: z
230
+ .union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())])
231
+ .default(""),
232
+ images: z.array(ImageSchema).max(MAX_IMAGES).optional(),
233
+ /** Anchors dates written without a year. */
234
+ referenceDate: z
235
+ .string()
236
+ .regex(ISO_DATE_RE, "referenceDate must be an ISO date (YYYY-MM-DD)")
237
+ .optional(),
238
+ timeoutMs: z
239
+ .number()
240
+ .int()
241
+ .min(SYSTEM_ONE_MIN_TIMEOUT_MS)
242
+ .max(SYSTEM_ONE_MAX_TIMEOUT_MS)
243
+ .default(SYSTEM_ONE_DEFAULT_TIMEOUT_MS),
244
+ maxRetries: z
245
+ .number()
246
+ .int()
247
+ .min(0)
248
+ .max(SYSTEM_ONE_MAX_RETRIES_LIMIT)
249
+ .default(SYSTEM_ONE_DEFAULT_MAX_RETRIES),
250
+ fields: z.record(
251
+ z.string().regex(FIELD_ID_RE, FIELD_ID_MESSAGE),
252
+ SystemOneExtractionFieldSchema,
253
+ ),
254
+ })
255
+ .superRefine((config, ctx) => {
256
+ const fieldIds = Object.keys(config.fields);
257
+ if (fieldIds.length === 0) {
258
+ ctx.addIssue({
259
+ code: "custom",
260
+ path: ["fields"],
261
+ message: "system-one-extraction needs at least one field",
262
+ });
263
+ }
264
+ for (const id of fieldIds) {
265
+ if (RESERVED_FIELD_IDS.has(id)) {
266
+ ctx.addIssue({
267
+ code: "custom",
268
+ path: ["fields", id],
269
+ message: `"${id}" is reserved and cannot be a field id`,
270
+ });
271
+ }
272
+ }
273
+ const stateText = typeof config.state === "string" ? config.state : "nonempty";
274
+ if (stateText.trim() === "" && !config.images?.length) {
275
+ ctx.addIssue({
276
+ code: "custom",
277
+ path: ["state"],
278
+ message: "state must not be empty unless images are sent",
279
+ });
280
+ }
281
+ const images = config.images ?? [];
282
+ let total = 0;
283
+ images.forEach((image, index) => {
284
+ const bytes = imageBytes(image);
285
+ total += bytes;
286
+ if (bytes > MAX_IMAGE_BYTES) {
287
+ ctx.addIssue({
288
+ code: "custom",
289
+ path: ["images", index],
290
+ message: "an image must be at most 4 MiB decoded",
291
+ });
292
+ }
293
+ });
294
+ if (total > MAX_IMAGES_TOTAL_BYTES) {
295
+ ctx.addIssue({
296
+ code: "custom",
297
+ path: ["images"],
298
+ message: "images must total at most 8 MiB decoded",
299
+ });
300
+ }
301
+ const host: SystemOneHost = SYSTEM_ONE_HOSTS[config.provider];
302
+ const service = host.extract;
303
+ const modelProblem = systemOneModelProblem(
304
+ config.provider,
305
+ config.model ?? service?.defaultModel,
306
+ "extract",
307
+ );
308
+ if (modelProblem) ctx.addIssue({ code: "custom", path: ["model"], message: modelProblem });
309
+ const maxFields = service?.limits?.maxFields;
310
+ if (maxFields !== undefined && fieldIds.length > maxFields) {
311
+ ctx.addIssue({
312
+ code: "custom",
313
+ path: ["fields"],
314
+ message: `SystemOne provider "${config.provider}" takes at most ${maxFields} fields per node; this node has ${fieldIds.length}.`,
315
+ });
316
+ }
317
+ const maxImages = service?.limits?.maxImages;
318
+ if (maxImages !== undefined && images.length > maxImages) {
319
+ ctx.addIssue({
320
+ code: "custom",
321
+ path: ["images"],
322
+ message: `SystemOne provider "${config.provider}" takes at most ${maxImages} images per node.`,
323
+ });
324
+ }
325
+ });
326
+
327
+ export type SystemOneExtractionConfig = z.infer<typeof SystemOneExtractionConfigSchema>;
328
+
329
+ // ─── Output schema ──────────────────────────────────────────
330
+
331
+ const UnitInterval = z.number().min(0).max(1);
332
+ const Span = z.tuple([z.number().int().min(0), z.number().int().min(0)]);
333
+ /** A number, or an ISO date for a date estimate. */
334
+ const Point = z.union([z.number(), z.string()]);
335
+
336
+ /**
337
+ * One field's answer. `value` is null when the input does not determine it; `guess`
338
+ * is what the model would have said anyway, and `p_null` how likely "no value" is.
339
+ * Estimates add `median`, `p10`, `p90`; forecasts carry one entry per step in each.
340
+ */
341
+ export const SystemOneExtractionAnswerSchema = z.strictObject({
342
+ type: z.enum(EXTRACTION_FIELD_TYPES),
343
+ value: z.unknown(),
344
+ guess: z.unknown().optional(),
345
+ p_null: UnitInterval.optional(),
346
+ confidence: UnitInterval.optional(),
347
+ /** `[start, end)` character offsets of the answer in `state`. */
348
+ span: Span.optional(),
349
+ /** One span per list item. */
350
+ spans: z.array(Span).optional(),
351
+ unit: z.string().optional(),
352
+ mean: z.union([Point, z.array(Point)]).optional(),
353
+ median: z.union([Point, z.array(Point)]).optional(),
354
+ p10: z.union([Point, z.array(Point)]).optional(),
355
+ p90: z.union([Point, z.array(Point)]).optional(),
356
+ p_below: UnitInterval.optional(),
357
+ p_above: UnitInterval.optional(),
358
+ });
359
+
360
+ export const SystemOneExtractionOutputSchema = z.object({
361
+ model: z.string().min(1),
362
+ answers: z.record(z.string(), SystemOneExtractionAnswerSchema),
363
+ usage: z.object({
364
+ input_tokens: z.number().int().min(0),
365
+ output_tokens: z.number().int().min(0),
366
+ }),
367
+ /** The host reports whether the state was cut to fit its context. */
368
+ truncated: z.boolean().optional(),
369
+ requestId: z.string().optional(),
370
+ });
371
+
372
+ export type SystemOneExtractionAnswer = z.infer<typeof SystemOneExtractionAnswerSchema>;
373
+ export type SystemOneExtractionOutput = z.infer<typeof SystemOneExtractionOutputSchema>;
374
+
375
+ // ─── Engine-facing rules (authoring + runtime) ──────────────
376
+
377
+ function hasToken(value: unknown): boolean {
378
+ if (typeof value === "string") return value.includes("{{") && value.includes("}}");
379
+ if (Array.isArray(value)) return value.some(hasToken);
380
+ if (isRecord(value)) return Object.values(value).some(hasToken);
381
+ return false;
382
+ }
383
+
384
+ /**
385
+ * Save-time checks for a field spec that carries a {{token}}. The generic config
386
+ * check skips any issue whose value holds a token, which would let a whole spec
387
+ * (and its field id) through because its `instructions` is templated. Only a
388
+ * string `instructions` may hold a token, so the field id, the spec object and
389
+ * every other option must be literals, and the rest is checked as is.
390
+ */
391
+ function templatedFieldViolations(nodeId: string, fields: Record<string, unknown>): string[] {
392
+ const violations: string[] = [];
393
+ for (const [id, spec] of Object.entries(fields)) {
394
+ if (!hasToken(spec)) continue;
395
+ const at = `Node "${nodeId}" (system-one-extraction) config.fields.${id}`;
396
+ if (!FIELD_ID_RE.test(id)) {
397
+ violations.push(`${at}: ${FIELD_ID_MESSAGE}`);
398
+ } else if (RESERVED_FIELD_IDS.has(id)) {
399
+ violations.push(`${at}: "${id}" is reserved and cannot be a field id`);
400
+ }
401
+ if (!isRecord(spec)) {
402
+ violations.push(
403
+ `${at} must be an object: field types and options are static, only instructions may use {{tokens}}`,
404
+ );
405
+ continue;
406
+ }
407
+ const tokened = Object.keys(spec).filter(
408
+ (key) => key !== "instructions" && hasToken(spec[key]),
409
+ );
410
+ for (const key of tokened) {
411
+ violations.push(`${at}.${key} must be a literal: only instructions may use {{tokens}}`);
412
+ }
413
+ if (tokened.length > 0) continue;
414
+ if (typeof spec.instructions !== "string") {
415
+ violations.push(`${at}.instructions must be a string to use {{tokens}}`);
416
+ continue;
417
+ }
418
+ const parsed = SystemOneExtractionFieldSchema.safeParse({ ...spec, instructions: "x" });
419
+ if (parsed.success) continue;
420
+ for (const issue of parsed.error.issues) {
421
+ const path = issue.path.length > 0 ? `${at}.${issue.path.map(String).join(".")}` : at;
422
+ violations.push(`${path}: ${issue.message}`);
423
+ }
424
+ }
425
+ return violations;
426
+ }
427
+
428
+ /** A 3-byte PNG data URL that stands in for a templated image. */
429
+ const STAND_IN_IMAGE = "data:image/png;base64,AAAA";
430
+
431
+ /**
432
+ * The config with every {{token}} replaced by a value the schema accepts:
433
+ * templated known options are dropped (each is optional or has a default),
434
+ * templated `state` and `instructions` become text, and templated images become
435
+ * one small image. Unknown keys stay, so the strict schema still refuses them.
436
+ */
437
+ function standInConfig(config: Record<string, unknown>): Record<string, unknown> {
438
+ const known = new Set(Object.keys(SystemOneExtractionConfigSchema.shape));
439
+ const out: Record<string, unknown> = {};
440
+ for (const [key, value] of Object.entries(config)) {
441
+ if (!hasToken(value) || key === "fields" || !known.has(key)) {
442
+ out[key] = value;
443
+ } else if (key === "state") {
444
+ out.state = typeof value === "string" ? "x" : value;
445
+ } else if (key === "images" && Array.isArray(value)) {
446
+ out.images = value.map((image) => (hasToken(image) ? STAND_IN_IMAGE : image));
447
+ }
448
+ }
449
+ if (isRecord(config.fields)) {
450
+ out.fields = Object.fromEntries(
451
+ Object.entries(config.fields).map(([id, spec]) => [
452
+ id,
453
+ isRecord(spec) && hasToken(spec) ? { ...spec, instructions: "x" } : spec,
454
+ ]),
455
+ );
456
+ }
457
+ return out;
458
+ }
459
+
460
+ /**
461
+ * Whole-config rules hidden by a {{token}}. The generic config check skips an
462
+ * issue whose value holds a token, so a limit on `fields`, `images` or the
463
+ * config itself (field count, image count, unknown keys) disappears once any
464
+ * part of it is templated. Parse the config with type-valid stand-ins and
465
+ * report exactly those issues.
466
+ */
467
+ function templatedConfigViolations(nodeId: string, config: Record<string, unknown>): string[] {
468
+ if (!hasToken(config)) return [];
469
+ const parsed = SystemOneExtractionConfigSchema.safeParse(standInConfig(config));
470
+ if (parsed.success) return [];
471
+ const violations: string[] = [];
472
+ for (const issue of parsed.error.issues) {
473
+ let value: unknown = config;
474
+ for (const segment of issue.path) {
475
+ value = isRecord(value) || Array.isArray(value) ? (value as never)[segment] : undefined;
476
+ }
477
+ // A token-free value is reported by the generic check; a token itself resolves at run time.
478
+ if (!hasToken(value) || typeof value === "string") continue;
479
+ const path = ["config", ...issue.path.map(String)].join(".");
480
+ violations.push(`Node "${nodeId}" (system-one-extraction) ${path}: ${issue.message}`);
481
+ }
482
+ return violations;
483
+ }
484
+
485
+ /** Static-shape rules a per-field schema cannot express: field ids and types, and the provider, are static. */
486
+ export function systemOneExtractionStaticShapeViolations(node: {
487
+ id: string;
488
+ type: string;
489
+ config: Record<string, unknown>;
490
+ }): string[] {
491
+ if (node.type !== SYSTEM_ONE_EXTRACTION_NODE_TYPE) return [];
492
+ const violations: string[] = [];
493
+ const fields = node.config.fields;
494
+ if (
495
+ fields !== undefined &&
496
+ (typeof fields !== "object" || fields === null || Array.isArray(fields))
497
+ ) {
498
+ violations.push(
499
+ `Node "${node.id}" (system-one-extraction) config.fields must be an object: field ids and types are static, only state, images, and instructions may use {{tokens}}`,
500
+ );
501
+ } else if (fields !== undefined) {
502
+ violations.push(...templatedFieldViolations(node.id, fields as Record<string, unknown>));
503
+ }
504
+ const provider = node.config.provider;
505
+ if (typeof provider === "string" && provider.includes("{{")) {
506
+ violations.push(
507
+ `Node "${node.id}" (system-one-extraction) config.provider must be a literal (${SYSTEM_ONE_EXTRACT_PROVIDER_IDS.join(", ")}), not a {{token}}: the provider decides which credential is checked before the run starts`,
508
+ );
509
+ }
510
+ if (violations.length === 0) {
511
+ violations.push(...templatedConfigViolations(node.id, node.config));
512
+ }
513
+ return violations;
514
+ }
515
+
516
+ // ─── Answer validation ──────────────────────────────────────
517
+
518
+ class ExtractionContractError extends Error {}
519
+
520
+ function fail(message: string): never {
521
+ throw new ExtractionContractError(message);
522
+ }
523
+
524
+ function unitOrUndefined(value: unknown, what: string): number | undefined {
525
+ if (value === undefined) return undefined;
526
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
527
+ fail(`${what} must be a finite number between 0 and 1`);
528
+ }
529
+ return value;
530
+ }
531
+
532
+ function isFiniteNumber(value: unknown): value is number {
533
+ return typeof value === "number" && Number.isFinite(value);
534
+ }
535
+
536
+ function isSpan(value: unknown): value is [number, number] {
537
+ return (
538
+ Array.isArray(value) &&
539
+ value.length === 2 &&
540
+ value.every((n) => Number.isInteger(n) && n >= 0) &&
541
+ value[0] <= value[1]
542
+ );
543
+ }
544
+
545
+ /** A number, or a string for a date estimate: the shape a point of an estimate takes. */
546
+ function isPoint(value: unknown, dates: boolean): boolean {
547
+ return dates ? typeof value === "string" : isFiniteNumber(value);
548
+ }
549
+
550
+ /** Check a scalar value against its field. Null means "not in the input" and is always allowed. */
551
+ function checkScalar(at: string, field: SystemOneExtractionField, value: unknown): void {
552
+ if (value === null) return;
553
+ switch (field.type) {
554
+ case "text":
555
+ case "date":
556
+ case "time":
557
+ case "datetime":
558
+ if (typeof value !== "string") fail(`${at}.value must be a string or null`);
559
+ return;
560
+ case "number":
561
+ case "duration":
562
+ if (!isFiniteNumber(value)) fail(`${at}.value must be a finite number or null`);
563
+ if (field.integer && !Number.isInteger(value)) fail(`${at}.value must be an integer`);
564
+ return;
565
+ default:
566
+ return;
567
+ }
568
+ }
569
+
570
+ /** Check one raw answer against its declared field. Extra provider fields are dropped. */
571
+ function validateAnswer(
572
+ id: string,
573
+ field: SystemOneExtractionField,
574
+ raw: unknown,
575
+ ): SystemOneExtractionAnswer {
576
+ const at = `answer "${id}"`;
577
+ if (!isRecord(raw)) fail(`${at} must be an object`);
578
+ if (raw.type !== field.type) {
579
+ fail(`${at} has type ${JSON.stringify(raw.type)} but the field declares "${field.type}"`);
580
+ }
581
+ if (!Object.hasOwn(raw, "value")) fail(`${at} is missing its value`);
582
+ const answer: SystemOneExtractionAnswer = { type: field.type, value: raw.value };
583
+
584
+ switch (field.type) {
585
+ case "list": {
586
+ if (!Array.isArray(raw.value)) fail(`${at}.value must be a list`);
587
+ const ok = raw.value.every((item) =>
588
+ field.items === "number" ? isFiniteNumber(item) : typeof item === "string",
589
+ );
590
+ if (!ok)
591
+ fail(`${at}.value items must all be ${field.items === "number" ? "numbers" : "strings"}`);
592
+ if (raw.spans !== undefined) {
593
+ if (!Array.isArray(raw.spans) || !raw.spans.every(isSpan)) {
594
+ fail(`${at}.spans must be a list of [start, end] offsets`);
595
+ }
596
+ answer.spans = raw.spans as [number, number][];
597
+ }
598
+ break;
599
+ }
600
+ case "estimate": {
601
+ const dates = typeof field.min === "string";
602
+ if (raw.value !== null && !isPoint(raw.value, dates)) {
603
+ fail(`${at}.value must be ${dates ? "an ISO date" : "a finite number"} or null`);
604
+ }
605
+ for (const key of ["median", "p10", "p90"] as const) {
606
+ if (!isPoint(raw[key], dates))
607
+ fail(`${at}.${key} must be ${dates ? "an ISO date" : "a finite number"}`);
608
+ answer[key] = raw[key] as number | string;
609
+ }
610
+ if (raw.mean !== undefined && isPoint(raw.mean, dates))
611
+ answer.mean = raw.mean as number | string;
612
+ break;
613
+ }
614
+ case "forecast": {
615
+ const steps = (value: unknown) => Array.isArray(value) && value.every(isFiniteNumber);
616
+ if (!steps(raw.value)) fail(`${at}.value must be a list of numbers, one per step`);
617
+ const length = (raw.value as number[]).length;
618
+ if (field.horizon !== undefined && length !== field.horizon) {
619
+ fail(`${at}.value must have ${field.horizon} step(s), got ${length}`);
620
+ }
621
+ for (const key of ["median", "p10", "p90"] as const) {
622
+ if (!steps(raw[key]) || (raw[key] as number[]).length !== length) {
623
+ fail(`${at}.${key} must be a list of ${length} number(s)`);
624
+ }
625
+ answer[key] = raw[key] as number[];
626
+ }
627
+ break;
628
+ }
629
+ default:
630
+ checkScalar(at, field, raw.value);
631
+ if (raw.span !== undefined) {
632
+ if (!isSpan(raw.span)) fail(`${at}.span must be [start, end] offsets`);
633
+ answer.span = raw.span;
634
+ }
635
+ if (raw.guess !== undefined && raw.guess !== null) {
636
+ checkScalar(`${at}.guess`, field, raw.guess);
637
+ answer.guess = raw.guess;
638
+ }
639
+ }
640
+
641
+ const pNull = unitOrUndefined(raw.p_null, `${at}.p_null`);
642
+ if (pNull !== undefined) answer.p_null = pNull;
643
+ const confidence = unitOrUndefined(raw.confidence, `${at}.confidence`);
644
+ if (confidence !== undefined) answer.confidence = confidence;
645
+ if (field.type === "estimate") {
646
+ const below = unitOrUndefined(raw.p_below, `${at}.p_below`);
647
+ const above = unitOrUndefined(raw.p_above, `${at}.p_above`);
648
+ if (below !== undefined) answer.p_below = below;
649
+ if (above !== undefined) answer.p_above = above;
650
+ }
651
+ if (typeof raw.unit === "string" && raw.unit !== "") answer.unit = raw.unit;
652
+ return answer;
653
+ }
654
+
655
+ /**
656
+ * Turn a parsed provider body into the node output, or throw. Success requires
657
+ * exactly one valid answer per declared field. Nothing is synthesized or normalized.
658
+ */
659
+ export function validateSystemOneExtraction(
660
+ fields: Record<string, SystemOneExtractionField>,
661
+ body: unknown,
662
+ requestId?: string,
663
+ ): SystemOneExtractionOutput {
664
+ if (!isRecord(body)) fail("response body must be a JSON object");
665
+ if (typeof body.model !== "string" || body.model === "") {
666
+ fail("response is missing the model that produced the answers");
667
+ }
668
+ if (!isRecord(body.answers)) fail("response is missing the answers object");
669
+ const usage = z
670
+ .object({ input_tokens: z.number().int().min(0), output_tokens: z.number().int().min(0) })
671
+ .safeParse(body.usage);
672
+ if (!usage.success) {
673
+ fail("response usage must report non-negative integer input_tokens and output_tokens");
674
+ }
675
+
676
+ const declared = Object.keys(fields);
677
+ if (declared.length === 0) fail("a system-one-extraction call needs at least one field");
678
+ const unexpected = Object.keys(body.answers).filter((id) => !Object.hasOwn(fields, id));
679
+ if (unexpected.length > 0) {
680
+ fail(`response contains ${unexpected.length} answer(s) for undeclared fields`);
681
+ }
682
+
683
+ const answers: Record<string, SystemOneExtractionAnswer> = {};
684
+ for (const id of declared) {
685
+ if (!Object.hasOwn(body.answers, id)) fail(`response is missing the answer for field "${id}"`);
686
+ const field = fields[id];
687
+ if (!field) fail(`field "${id}" is not defined`);
688
+ answers[id] = validateAnswer(id, field, body.answers[id]);
689
+ }
690
+
691
+ return {
692
+ model: body.model,
693
+ answers,
694
+ usage: usage.data,
695
+ ...(typeof body.truncated === "boolean" ? { truncated: body.truncated } : {}),
696
+ ...(requestId ? { requestId } : {}),
697
+ };
698
+ }
699
+
700
+ // ─── Executor ───────────────────────────────────────────────
701
+
702
+ export interface SystemOneExtractionExecutorOptions extends SystemOneTransportOptions {
703
+ /** Credential lookup. Defaults to the provider's own source (see `SYSTEM_ONE_HOSTS`). */
704
+ getApiKey?: (provider: SystemOneExtractProviderId) => Promise<string | null | undefined>;
705
+ /** Lookup of the config value the endpoint is built from (Cloudflare's account id). */
706
+ getServerUrl?: (provider: SystemOneExtractProviderId) => Promise<string | null | undefined>;
707
+ env?: NodeJS.ProcessEnv;
708
+ }
709
+
710
+ export class SystemOneExtractionExecutor extends BaseExecutor<
711
+ typeof SystemOneExtractionConfigSchema,
712
+ typeof SystemOneExtractionOutputSchema
713
+ > {
714
+ readonly type = SYSTEM_ONE_EXTRACTION_NODE_TYPE;
715
+ readonly mode = "instant" as const;
716
+ readonly configSchema = SystemOneExtractionConfigSchema;
717
+ readonly outputSchema = SystemOneExtractionOutputSchema;
718
+
719
+ constructor(
720
+ deps: ExecutorDependencies,
721
+ private readonly options: SystemOneExtractionExecutorOptions = {},
722
+ ) {
723
+ super(deps);
724
+ }
725
+
726
+ private providerContext() {
727
+ return { db: this.deps.db, env: this.options.env ?? process.env };
728
+ }
729
+
730
+ private resolveTarget(id: SystemOneExtractProviderId, model?: string) {
731
+ const { getApiKey, getServerUrl } = this.options;
732
+ return resolveSystemOneTarget(id, this.providerContext(), {
733
+ apiKey: getApiKey ? () => getApiKey(id) : undefined,
734
+ serverUrl: getServerUrl ? () => getServerUrl(id) : undefined,
735
+ model,
736
+ capability: "extract",
737
+ });
738
+ }
739
+
740
+ /** One problem per provider the given nodes use whose credential or endpoint is not usable. */
741
+ override async checkReadiness(
742
+ nodes: readonly ExecutorReadinessNode[],
743
+ ): Promise<ExecutorReadinessProblem[]> {
744
+ const nodeIdsByProvider = new Map<SystemOneExtractProviderId, string[]>();
745
+ for (const node of nodes) {
746
+ // An unknown provider is a config error the schema reports; it names no credential.
747
+ const id = systemOneExtractProviderOf(node.config);
748
+ if (id) nodeIdsByProvider.set(id, [...(nodeIdsByProvider.get(id) ?? []), node.id]);
749
+ }
750
+ const problems: ExecutorReadinessProblem[] = [];
751
+ for (const [id, nodeIds] of nodeIdsByProvider) {
752
+ const target = await this.resolveTarget(id);
753
+ if (!target.ok) problems.push({ nodeIds, message: target.error });
754
+ }
755
+ return problems;
756
+ }
757
+
758
+ protected async execute(
759
+ config: SystemOneExtractionConfig,
760
+ _context: Readonly<Record<string, unknown>>,
761
+ _meta: ExecutorMeta,
762
+ ): Promise<ExecutorResult<SystemOneExtractionOutput>> {
763
+ const extracted = await this.extract(config);
764
+ return extracted.ok
765
+ ? { status: "success", output: extracted.output }
766
+ : { status: "failed", error: extracted.error };
767
+ }
768
+
769
+ /** One extraction: resolve the target, send, validate. The credential stays in this process. */
770
+ async extract(
771
+ config: SystemOneExtractionConfig,
772
+ ): Promise<{ ok: true; output: SystemOneExtractionOutput } | { ok: false; error: string }> {
773
+ const providerId = config.provider;
774
+ const service = (SYSTEM_ONE_HOSTS[providerId] as SystemOneHost).extract;
775
+ if (!service) {
776
+ return { ok: false, error: `SystemOne provider "${providerId}" does not serve extraction` };
777
+ }
778
+
779
+ const model = config.model ?? service.defaultModel;
780
+ const modelProblem = systemOneModelProblem(providerId, model, "extract");
781
+ if (modelProblem) return { ok: false, error: modelProblem };
782
+ const target = await this.resolveTarget(providerId, model);
783
+ if (!target.ok) return { ok: false, error: target.error };
784
+ const { apiKey, endpoint, keyName } = target;
785
+
786
+ const body = JSON.stringify(service.wire.buildRequest(config, model));
787
+ const scrub = (message: string) => message.split(apiKey).join("[REDACTED]");
788
+ const sent = await sendSystemOneRequest(
789
+ {
790
+ providerId,
791
+ capability: "extract",
792
+ endpoint,
793
+ apiKey,
794
+ keyName,
795
+ body,
796
+ timeoutMs: config.timeoutMs,
797
+ maxRetries: config.maxRetries,
798
+ env: this.providerContext().env,
799
+ },
800
+ this.options,
801
+ );
802
+ if (!sent.ok) return { ok: false, error: scrub(sent.error) };
803
+
804
+ let parsed: unknown;
805
+ try {
806
+ parsed = JSON.parse(sent.text);
807
+ } catch {
808
+ return { ok: false, error: "SystemOne response was not valid JSON" };
809
+ }
810
+ try {
811
+ return {
812
+ ok: true,
813
+ output: validateSystemOneExtraction(
814
+ config.fields,
815
+ service.wire.toExtraction(parsed),
816
+ sent.requestId,
817
+ ),
818
+ };
819
+ } catch (err) {
820
+ const withId = (message: string) =>
821
+ sent.requestId ? `${message} [request ${sent.requestId}]` : message;
822
+ if (err instanceof SystemOneWireError)
823
+ return { ok: false, error: scrub(withId(err.message)) };
824
+ if (err instanceof ExtractionContractError) {
825
+ return { ok: false, error: scrub(withId(`${VALIDATION_PREFIX}${err.message}`)) };
826
+ }
827
+ throw err;
828
+ }
829
+ }
830
+ }