@desplega.ai/agent-swarm 2.4.4 → 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 (221) hide show
  1. package/dist/{acp-adapter-thq04adh.js → acp-adapter-2wdkktv6.js} +1 -1
  2. package/dist/{actions-j6ywh6x5.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-k8b5xmj8.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-rb7vc02s.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-ftta1zjf.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-srp6v6af.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-xns7h5h9.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-3qqrh2mz.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-9ks8hce7.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-k5ksyres.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-n5a5eqks.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-2j3j4kwn.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-cynvc9fj.js → cli-018r4jq6.js} +5 -5
  15. package/dist/{cli-9fhkkpj5.js → cli-1f3qzw6y.js} +1 -1
  16. package/dist/{cli-dqb4n629.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-gqvh8d8a.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-qkhc5aqy.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-1d70crsy.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-t7cyef5g.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-hj3debgw.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-55qmbp6z.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-pccfmtb5.js → cli-94gwyg7m.js} +7 -7
  25. package/dist/{cli-51y09c6p.js → cli-9hstj09h.js} +5 -5
  26. package/dist/{cli-zrwpctp4.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-esxerzg7.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-b8k7epj1.js → cli-be2161h1.js} +7 -7
  29. package/dist/{cli-xc27fwzg.js → cli-bnes4z6b.js} +2 -2
  30. package/dist/{cli-zrk6jxv8.js → cli-cxxe61f6.js} +1 -1
  31. package/dist/{cli-amyhm3gj.js → cli-dk44dxec.js} +1 -1
  32. package/dist/{cli-nc35pxb7.js → cli-eww120g8.js} +1 -0
  33. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  34. package/dist/{cli-82d3nkc3.js → cli-f0g3pgvf.js} +1 -1
  35. package/dist/{cli-1ephgwnq.js → cli-ftc1g5tj.js} +2 -2
  36. package/dist/{cli-2v045xyf.js → cli-fv5hepyy.js} +3 -3
  37. package/dist/{cli-bnz0r3kc.js → cli-g35m2xwm.js} +4 -4
  38. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  39. package/dist/{cli-w6nadkh4.js → cli-j99azg3g.js} +3 -3
  40. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  41. package/dist/{cli-4dg55wsk.js → cli-kpw04va0.js} +1 -1
  42. package/dist/{cli-3cgngyd8.js → cli-ms85309d.js} +33 -15
  43. package/dist/{cli-v3yr2gbg.js → cli-n72rg53p.js} +1471 -635
  44. package/dist/{cli-37yb7307.js → cli-p14m2cnp.js} +1 -1
  45. package/dist/{cli-2zqpkvtt.js → cli-qfxc84gh.js} +1 -1
  46. package/dist/{cli-44159k28.js → cli-qwhy2qma.js} +1 -1
  47. package/dist/{cli-2y91wnbh.js → cli-st8aw1m6.js} +221 -9
  48. package/dist/{cli-vq97g1jq.js → cli-tw4p90jb.js} +92 -192
  49. package/dist/{cli-te6b5wvg.js → cli-v0yrfmdh.js} +1 -1
  50. package/dist/{cli-xj0re5tm.js → cli-v8fjakwf.js} +1 -1
  51. package/dist/{cli-p20r8w42.js → cli-wt0jf9hm.js} +215 -80
  52. package/dist/{cli-q6r7t299.js → cli-xpa64rek.js} +33 -11
  53. package/dist/{cli-vdm20wej.js → cli-z7d29rw9.js} +2 -2
  54. package/dist/cli.js +19 -19
  55. package/dist/{codex-adapter-968xwfy7.js → codex-adapter-tn6k5mkg.js} +1 -1
  56. package/dist/{codex-session-runner-ht4z99fa.js → codex-session-runner-7vjt7jbw.js} +1 -1
  57. package/dist/{commands-pcymaeg9.js → commands-t7rz9146.js} +2 -2
  58. package/dist/{controller-fpjyk46r.js → controller-m2jasma5.js} +2 -2
  59. package/dist/{controllers-connect-mwzx1fh6.js → controllers-connect-9ftpex99.js} +19 -19
  60. package/dist/{cursor-adapter-cp4s2e2f.js → cursor-adapter-mjv79e4z.js} +1 -1
  61. package/dist/{db-xsjg9jqq.js → db-6sfrn9g3.js} +1 -1
  62. package/dist/{db-sgd12bt9.js → db-rn673r06.js} +3 -3
  63. package/dist/{dispatcher-x0jfw2c9.js → dispatcher-c155xz0p.js} +17 -17
  64. package/dist/{dsh-adapter-jrq7ma7h.js → dsh-adapter-3mmszc1x.js} +1 -1
  65. package/dist/{external-channel-8njj4h3b.js → external-channel-35k6tkv8.js} +2 -2
  66. package/dist/{grok-adapter-kkr97v2f.js → grok-adapter-tfrq7tg1.js} +2 -2
  67. package/dist/handlers-7gw64dxw.js +39 -0
  68. package/dist/{heartbeat-wd03zhn0.js → heartbeat-dyg4npkp.js} +20 -20
  69. package/dist/{hook-f0vghj42.js → hook-drspbwpe.js} +1 -1
  70. package/dist/{hook-xzj2ts2p.js → hook-x26b31bg.js} +1 -1
  71. package/dist/{http-4hwxv62p.js → http-pk972xyw.js} +355 -216
  72. package/dist/{index-ww2vznkp.js → index-arytgj5q.js} +1 -1
  73. package/dist/{index-hg0x2cdv.js → index-bgdyvcnv.js} +17 -17
  74. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  75. package/dist/{index-gdh8jrdm.js → index-gabbcdn2.js} +17 -17
  76. package/dist/{index-97q4xn03.js → index-jwxkkex2.js} +129 -75
  77. package/dist/index-tfb26zhy.js +41 -0
  78. package/dist/{keepalive-hrh7xnr9.js → keepalive-7yrng7px.js} +3 -3
  79. package/dist/{lead-ee8vazz2.js → lead-7e02z839.js} +29 -29
  80. package/dist/{maintenance-e8wnwnqx.js → maintenance-e6cemdqt.js} +1 -1
  81. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  82. package/dist/{oauth-refresh-sweep-zm0yj4sj.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  83. package/dist/{onboard-dtwcq603.js → onboard-0a9y5yrc.js} +2 -2
  84. package/dist/{otel-impl-w3fbhpn0.js → otel-impl-trjpkfb7.js} +1 -1
  85. package/dist/{pricing-refresh-j38dehfs.js → pricing-refresh-d2b5my0y.js} +3 -3
  86. package/dist/{rbac-roles-ejxx9b35.js → rbac-roles-hey3yv9d.js} +2 -2
  87. package/dist/{rbac-roles-ae8yttq4.js → rbac-roles-nxh5ahwk.js} +1 -1
  88. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  89. package/dist/{render-v2-n16n0phe.js → render-v2-rr3p2atg.js} +3 -3
  90. package/dist/{resume-z0fgjc38.js → resume-2efkf1s1.js} +5 -5
  91. package/dist/{session-access-cv3mq6mc.js → session-access-s2mttxpw.js} +4 -4
  92. package/dist/{setup-mafhj3yt.js → setup-vypma5yt.js} +1 -1
  93. package/dist/{work-objects-8b86mgjp.js → work-objects-rvhckdkp.js} +20 -20
  94. package/dist/{worker-16s75hm5.js → worker-5snytpbd.js} +29 -29
  95. package/openapi.json +9 -2
  96. package/package.json +7 -7
  97. package/src/apps/catalog.generated.json +206 -4
  98. package/src/apps/page-validator.ts +13 -1
  99. package/src/be/chunking.ts +42 -9
  100. package/src/be/db/tasks/draft-text.ts +15 -0
  101. package/src/be/db/tasks/read.ts +14 -1
  102. package/src/be/db.ts +5 -1
  103. package/src/be/memory/index-content.ts +36 -17
  104. package/src/be/memory/providers/openai-embedding.ts +115 -61
  105. package/src/be/memory/providers/sqlite-store.ts +69 -23
  106. package/src/be/memory/reranker.ts +31 -1
  107. package/src/be/memory/search-candidates.ts +47 -0
  108. package/src/be/memory/types.ts +14 -0
  109. package/src/be/scripts/typecheck.ts +2 -0
  110. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +106 -54
  111. package/src/be/seed-scripts/index.ts +2 -2
  112. package/src/be/swarm-config-guard.ts +24 -1
  113. package/src/be/task-attachment-store.ts +30 -0
  114. package/src/extensions/catalog.generated.json +4 -4
  115. package/src/extensions/contract-types.generated.ts +1 -1
  116. package/src/extensions/contract.ts +7 -0
  117. package/src/fs/provider.ts +13 -1
  118. package/src/heartbeat/heartbeat.ts +31 -11
  119. package/src/http/fs.ts +54 -34
  120. package/src/http/mcp-body.ts +37 -0
  121. package/src/http/mcp-user.ts +3 -5
  122. package/src/http/mcp.ts +6 -7
  123. package/src/http/memory.ts +26 -15
  124. package/src/http/page-proxy.ts +61 -5
  125. package/src/http/pages-public.ts +27 -44
  126. package/src/http/pages.ts +4 -4
  127. package/src/http/script-runs.ts +2 -0
  128. package/src/http/users.ts +12 -1
  129. package/src/integrations/chat-sdk/adapters.ts +7 -3
  130. package/src/integrations/chat-sdk/attachments.ts +90 -12
  131. package/src/integrations/chat-sdk/facts.ts +120 -0
  132. package/src/integrations/chat-sdk/inbound.ts +156 -8
  133. package/src/integrations/chat-sdk/limits.ts +24 -0
  134. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  135. package/src/integrations/chat-sdk/outbound.ts +234 -4
  136. package/src/integrations/chat-sdk/relay.ts +4 -1
  137. package/src/integrations/chat-sdk/router.ts +28 -2
  138. package/src/integrations/chat-sdk/steering.ts +9 -0
  139. package/src/integrations/chat-sdk/templates.ts +14 -0
  140. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  141. package/src/mcp-limits.ts +20 -0
  142. package/src/pages/browser-sdk.ts +8 -3
  143. package/src/pages/feedback-overlay.ts +1 -1
  144. package/src/providers/amp-adapter.ts +1 -1
  145. package/src/providers/codex-adapter.ts +106 -119
  146. package/src/providers/codex-agents-md.ts +35 -229
  147. package/src/providers/cursor-adapter.ts +1 -1
  148. package/src/realtime/transport.ts +1 -2
  149. package/src/script-workflows/workflow-ctx.ts +1 -0
  150. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  151. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  152. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  153. package/src/server-user.ts +2 -0
  154. package/src/server.ts +2 -0
  155. package/src/slack/assistant.ts +2 -2
  156. package/src/slack/files.ts +2 -0
  157. package/src/slack/handlers.ts +4 -4
  158. package/src/slack/inbound-files.ts +39 -6
  159. package/src/slack/thread-buffer.ts +2 -1
  160. package/src/tasks/task-terminal-effects.ts +21 -17
  161. package/src/tests/apps-card-grid.test.ts +197 -0
  162. package/src/tests/apps-catalog-additions.test.ts +120 -0
  163. package/src/tests/apps-tree.test.ts +129 -0
  164. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  165. package/src/tests/chat-sdk-config.test.ts +11 -0
  166. package/src/tests/chat-sdk-facts.test.ts +23 -0
  167. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  168. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  169. package/src/tests/codex-adapter.test.ts +167 -242
  170. package/src/tests/config-api-only-keys.test.ts +9 -0
  171. package/src/tests/cors-allowlist.test.ts +1 -1
  172. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  173. package/src/tests/heartbeat-checklist.test.ts +33 -0
  174. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  175. package/src/tests/heartbeat-jev-gate.test.ts +242 -1
  176. package/src/tests/kv-page-proxy.test.ts +91 -5
  177. package/src/tests/launch-password-rejection.test.ts +2 -2
  178. package/src/tests/mcp-input-caps.test.ts +294 -0
  179. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  180. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  181. package/src/tests/memory.test.ts +14 -0
  182. package/src/tests/page-guest-session.test.ts +19 -8
  183. package/src/tests/page-proxy-authed.test.ts +1 -1
  184. package/src/tests/page-proxy.test.ts +17 -10
  185. package/src/tests/page-session.test.ts +102 -1
  186. package/src/tests/pages-authed-mode.test.ts +82 -12
  187. package/src/tests/pages-password-mode.test.ts +5 -5
  188. package/src/tests/realtime-transport.test.ts +13 -7
  189. package/src/tests/script-runs-http.test.ts +84 -0
  190. package/src/tests/scripts-http.test.ts +10 -1
  191. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  192. package/src/tests/task-audio-transcription.test.ts +299 -0
  193. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  194. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  195. package/src/tests/transcription.test.ts +299 -0
  196. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  197. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  198. package/src/tools/chat-access.ts +10 -0
  199. package/src/tools/chat-post.ts +31 -11
  200. package/src/tools/chat-reply.ts +32 -10
  201. package/src/tools/memory-search.ts +3 -1
  202. package/src/tools/system-one-decide.ts +17 -4
  203. package/src/types.ts +6 -0
  204. package/src/utils/config-env-export.ts +3 -1
  205. package/src/utils/page-session.ts +133 -20
  206. package/src/utils/transcription.ts +246 -0
  207. package/src/workflows/executors/system-one-decision.ts +28 -7
  208. package/src/workflows/executors/system-one-providers.ts +39 -4
  209. package/templates/extensions/slack-reply-gate/README.md +24 -22
  210. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  211. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  212. package/templates/skills/apps/SKILL.md +28 -6
  213. package/templates/skills/apps/content.md +28 -6
  214. package/templates/skills/chat-sdk/content.md +6 -2
  215. package/templates/skills/code-quality/content.md +2 -1
  216. package/templates/skills/heartbeat-runbook/content.md +5 -5
  217. package/templates/skills/pages/content.md +6 -4
  218. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  219. package/templates/skills/swarm-scripts/content.md +2 -2
  220. package/dist/handlers-0pvee9nw.js +0 -39
  221. package/dist/index-thm0y6a8.js +0 -41
@@ -10,7 +10,7 @@ import {
10
10
  import { SYSTEM_ONE_PROVIDER_IDS } from "@/workflows/executors/system-one-providers";
11
11
 
12
12
  /**
13
- * One typed decision (Jev and the other SystemOne hosts) made in the API
13
+ * One typed decision (Jev, Clef and the other SystemOne hosts) made in the API
14
14
  * process, the same way a `system-one-decision` workflow node makes it. The
15
15
  * provider credential is read and sent here and never reaches the caller, so a
16
16
  * script can ask for a decision without a credential binding in its own realm.
@@ -22,14 +22,16 @@ export const registerSystemOneDecideTool = (server: McpServer) => {
22
22
  title: "Ask a SystemOne decision model",
23
23
  annotations: { destructiveHint: false, openWorldHint: true },
24
24
  description:
25
- "Send a state and typed questions (noul, choice, score) to a SystemOne decision host such as Jev, and return the validated answers. " +
25
+ "Send a state and typed questions (noul, choice, score) to a SystemOne decision host such as Jev or Clef, and return the validated answers. " +
26
26
  "Same request, provider list, credentials and validation as the `system-one-decision` workflow node, without its human review band. " +
27
27
  "The provider credential is a global secret that stays on the server; this tool never returns it.",
28
28
  inputSchema: z.object({
29
29
  provider: z
30
30
  .enum(SYSTEM_ONE_PROVIDER_IDS)
31
31
  .optional()
32
- .describe("Decision host (default typesafe)"),
32
+ .describe(
33
+ "Decision host (default: the SYSTEM_ONE_DEFAULT_PROVIDER global config, typesafe when unset)",
34
+ ),
33
35
  model: z.string().min(1).optional().describe("Provider model id (default: the provider's)"),
34
36
  state: z
35
37
  .union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())])
@@ -55,6 +57,12 @@ export const registerSystemOneDecideTool = (server: McpServer) => {
55
57
  answers: z.record(z.string(), z.unknown()).optional(),
56
58
  usage: z.record(z.string(), z.unknown()).optional(),
57
59
  requestId: z.string().optional(),
60
+ providerUnavailable: z
61
+ .boolean()
62
+ .optional()
63
+ .describe(
64
+ "On an error: true when this deployment refuses the provider (e.g. OpenRouter behind a gateway) and no request was sent, so a caller choosing automatically can try its next provider",
65
+ ),
58
66
  }),
59
67
  },
60
68
  async ({ provider, model, state, questions, timeoutMs, maxRetries }, requestInfo) => {
@@ -88,7 +96,12 @@ export const registerSystemOneDecideTool = (server: McpServer) => {
88
96
  return toolErr("The system-one-decision executor is not available");
89
97
  }
90
98
  const decided = await executor.decide(parsed.data);
91
- if (!decided.ok) return toolErr(decided.error);
99
+ if (!decided.ok) {
100
+ return toolErr(
101
+ decided.error,
102
+ decided.deploymentRefused ? { data: { providerUnavailable: true } } : {},
103
+ );
104
+ }
92
105
 
93
106
  const { output } = decided;
94
107
  return toolOk(`Decision from ${output.model}`, {
package/src/types.ts CHANGED
@@ -905,6 +905,12 @@ export const CreateTaskOptionsSchema = z.object({
905
905
  * contract on completion (which would block the control task — DES-523).
906
906
  */
907
907
  inheritParentOutputSchema: z.boolean().optional(),
908
+ /**
909
+ * When a `parentTaskId` is set and `requestedByUserId` is not, the child
910
+ * inherits the parent's requester by default. Set this to `false` when the
911
+ * child answers a different person (a chat message whose sender is unknown).
912
+ */
913
+ inheritParentRequester: z.boolean().optional(),
908
914
  /**
909
915
  * Skip parent routing requirements only for a child with its own explicit
910
916
  * Lead-only control-plane authorization.
@@ -8,7 +8,8 @@
8
8
  * binding instead.
9
9
  *
10
10
  * Keys below are read only by the API server (integration signing secrets, app
11
- * private keys, OAuth client secrets, the API's own agent-fs key). They can
11
+ * private keys, OAuth client secrets, the API's own agent-fs and
12
+ * speech-to-text keys). They can
12
13
  * never be exported: the config API rejects `exportToEnv: true` for them and
13
14
  * every reader treats a stored true as false.
14
15
  */
@@ -25,6 +26,7 @@ export const ENV_EXPORT_RESTRICTED_KEYS: ReadonlySet<string> = new Set([
25
26
  "AZURE_DEVOPS_WEBHOOK_SECRET",
26
27
  "AGENTMAIL_WEBHOOK_SECRET",
27
28
  "API_AGENT_FS_API_KEY",
29
+ "TRANSCRIPTION_API_KEY",
28
30
  ]);
29
31
 
30
32
  /**
@@ -246,23 +246,83 @@ export function parseCookieHeader(
246
246
  }
247
247
 
248
248
  /**
249
- * One-shot helper: pull `page_session` out of a request's `Cookie` header and
250
- * verify it. Returns the parsed payload on success, `null` on any failure
251
- * (no cookie, malformed, bad signature, expired, etc.).
252
- *
253
- * Shared by `/@swarm/api/*` (`src/http/page-proxy.ts`) and the authed `/p/:id`
254
- * branch (`src/http/pages-public.ts`) so both call sites converge on the same
255
- * verification semantics.
249
+ * The single shared cookie name used before sessions became per-page. Every
250
+ * launch overwrote it, so only the newest page in a browser had a session.
251
+ * Still read for back-compat, but only for the page its payload names.
252
+ */
253
+ export const LEGACY_PAGE_SESSION_COOKIE = "page_session";
254
+
255
+ /** Page ids are 32 hex chars today; anything outside the cookie-name-safe set is refused. */
256
+ const COOKIE_SAFE_PAGE_ID = /^[A-Za-z0-9_-]{1,64}$/;
257
+
258
+ export function isCookieSafePageId(pageId: string): boolean {
259
+ return COOKIE_SAFE_PAGE_ID.test(pageId);
260
+ }
261
+
262
+ /** Per-page cookie name, so sessions for different pages coexist in one browser. */
263
+ export function pageSessionCookieName(pageId: string): string {
264
+ return `${LEGACY_PAGE_SESSION_COOKIE}_${pageId}`;
265
+ }
266
+
267
+ export interface VerifiedPageSession {
268
+ /** The raw signed token, forwarded upstream by the page proxy. */
269
+ token: string;
270
+ payload: PageSessionPayload;
271
+ }
272
+
273
+ type CookieRequest = { headers: { cookie?: string | string[] | undefined } };
274
+
275
+ async function verifyNamedCookie(
276
+ req: CookieRequest,
277
+ name: string,
278
+ ): Promise<VerifiedPageSession | null> {
279
+ const token = parseCookieHeader(req.headers.cookie, name);
280
+ const payload = await verifyPageSession(token);
281
+ return payload && token ? { token, payload } : null;
282
+ }
283
+
284
+ /**
285
+ * Find and verify the session for `pageId`: `page_session_<pageId>` first,
286
+ * then the legacy `page_session`. A cookie whose signed payload names a
287
+ * different page is ignored, so the result always satisfies
288
+ * `payload.pageId === pageId`. Returns `null` on any failure (no cookie,
289
+ * malformed, bad signature, expired, wrong page).
290
+ */
291
+ export async function extractPageSession(
292
+ req: CookieRequest,
293
+ pageId: string,
294
+ ): Promise<VerifiedPageSession | null> {
295
+ if (!isCookieSafePageId(pageId)) return null;
296
+ for (const name of [pageSessionCookieName(pageId), LEGACY_PAGE_SESSION_COOKIE]) {
297
+ const session = await verifyNamedCookie(req, name);
298
+ if (session?.payload.pageId === pageId) return session;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Verify the legacy `page_session` cookie for whatever page it names. Only for
305
+ * callers that send no page id (clients predating per-page cookies).
306
+ */
307
+ export async function extractLegacyPageSession(
308
+ req: CookieRequest,
309
+ ): Promise<VerifiedPageSession | null> {
310
+ return verifyNamedCookie(req, LEGACY_PAGE_SESSION_COOKIE);
311
+ }
312
+
313
+ /**
314
+ * Payload-only form of `extractPageSession`. Shared by the `/p/:id` handler
315
+ * (`src/http/pages-public.ts`) and the realtime upgrade
316
+ * (`src/realtime/transport.ts`) so they converge on the same semantics.
256
317
  *
257
318
  * Accepts an object with a `headers.cookie` field — duck-typed to keep this
258
319
  * helper test-friendly without dragging `node:http` types into the utility.
259
320
  */
260
- export async function extractAndVerifyCookie(req: {
261
- headers: { cookie?: string | string[] | undefined };
262
- }): Promise<PageSessionPayload | null> {
263
- const token = parseCookieHeader(req.headers.cookie, "page_session");
264
- if (!token) return null;
265
- return verifyPageSession(token);
321
+ export async function extractAndVerifyCookie(
322
+ req: CookieRequest,
323
+ pageId: string,
324
+ ): Promise<PageSessionPayload | null> {
325
+ return (await extractPageSession(req, pageId))?.payload ?? null;
266
326
  }
267
327
 
268
328
  // ───────────────────────────────────────────────────────────────────────────
@@ -284,6 +344,10 @@ const PAGE_SESSION_TTL_SECONDS = 3600;
284
344
  * - `dev=true` → `SameSite=Lax` without `Secure` (works on http://localhost).
285
345
  * - `dev=false` → `SameSite=None; Secure` (cross-site iframe embedding in prod).
286
346
  *
347
+ * The cookie is named `page_session_<pageId>`, so launching page B leaves
348
+ * page A's session intact. Responses should go through `pageSessionSetCookies`,
349
+ * which also bounds how many of these a browser keeps.
350
+ *
287
351
  * The caller is responsible for setting `Set-Cookie` on the response — this
288
352
  * helper only builds the string. TTL is 1 hour; renewed on every issuance.
289
353
  */
@@ -299,13 +363,12 @@ export async function issuePageSessionCookie(
299
363
  ...(opts.name ? { name: opts.name } : {}),
300
364
  ...(opts.op ? { op: true as const } : {}),
301
365
  });
302
- const attrs = [
303
- `page_session=${token}`,
304
- "HttpOnly",
305
- "Path=/",
306
- `Max-Age=${PAGE_SESSION_TTL_SECONDS}`,
307
- ];
308
- if (opts.dev) {
366
+ return buildCookie(pageSessionCookieName(pageId), token, PAGE_SESSION_TTL_SECONDS, opts.dev);
367
+ }
368
+
369
+ function buildCookie(name: string, value: string, maxAge: number, dev: boolean): string {
370
+ const attrs = [`${name}=${value}`, "HttpOnly", "Path=/", `Max-Age=${maxAge}`];
371
+ if (dev) {
309
372
  attrs.push("SameSite=Lax");
310
373
  } else {
311
374
  attrs.push("SameSite=None");
@@ -313,3 +376,53 @@ export async function issuePageSessionCookie(
313
376
  }
314
377
  return attrs.join("; ");
315
378
  }
379
+
380
+ /**
381
+ * Most per-page session cookies one browser keeps, counting the one being
382
+ * issued. Each is ~220 bytes on the wire, and `Path=/` sends all of them on
383
+ * every request to this host, so an unbounded jar grows past the server's
384
+ * 16 KB header limit (431 on every route, `/health` included) after ~75
385
+ * launches within the hour. 20 keeps the jar near 4.5 KB.
386
+ */
387
+ export const MAX_PAGE_SESSION_COOKIES = 20;
388
+
389
+ const PAGE_SESSION_COOKIE_RE = new RegExp(
390
+ `(?:^|;\\s*)(${LEGACY_PAGE_SESSION_COOKIE}_[A-Za-z0-9_-]{1,64})=([^;]*)`,
391
+ "g",
392
+ );
393
+
394
+ /**
395
+ * `Set-Cookie` values for a launch of `pageId`: the new session, plus an
396
+ * expiring cookie for every other per-page session in the request's jar that
397
+ * is invalid or expired, or falls outside the `MAX_PAGE_SESSION_COOKIES - 1`
398
+ * newest. The browser then holds at most `MAX_PAGE_SESSION_COOKIES` of them.
399
+ * An evicted page's open tab loses its session, the same as after expiry.
400
+ */
401
+ export async function pageSessionSetCookies(
402
+ req: CookieRequest,
403
+ pageId: string,
404
+ opts: { dev: boolean; uid?: string; name?: string; op?: true },
405
+ ): Promise<string[]> {
406
+ const issued = await issuePageSessionCookie(pageId, opts);
407
+ const header = Array.isArray(req.headers.cookie)
408
+ ? req.headers.cookie.join("; ")
409
+ : (req.headers.cookie ?? "");
410
+ const keep: { name: string; exp: number; order: number }[] = [];
411
+ const evict = new Set<string>();
412
+ const seen = new Set([pageSessionCookieName(pageId)]);
413
+ for (const [, name, token] of header.matchAll(PAGE_SESSION_COOKIE_RE)) {
414
+ if (!name || seen.has(name)) continue;
415
+ seen.add(name);
416
+ const payload = await verifyPageSession(token);
417
+ if (payload && name === pageSessionCookieName(payload.pageId)) {
418
+ keep.push({ name, exp: payload.exp, order: keep.length });
419
+ } else {
420
+ evict.add(name);
421
+ }
422
+ }
423
+ // `exp` has one-second granularity. Browsers list older cookies first, so a
424
+ // later position breaks ties toward the more recent launch.
425
+ keep.sort((a, b) => b.exp - a.exp || b.order - a.order);
426
+ for (const { name } of keep.slice(MAX_PAGE_SESSION_COOKIES - 1)) evict.add(name);
427
+ return [issued, ...[...evict].map((name) => buildCookie(name, "", 0, opts.dev))];
428
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Speech-to-text for inbound audio. `TRANSCRIPTION_PROVIDER` picks the request
3
+ * shape:
4
+ *
5
+ * - `openai`: `POST <base>/audio/transcriptions`, `Authorization: Bearer`, form
6
+ * field `model`. OpenAI Whisper and self-hosted whisper servers speak this
7
+ * shape.
8
+ * - `groq`: the `openai` shape against Groq's OpenAI-compatible endpoint.
9
+ * - `elevenlabs`: `POST <base>/speech-to-text`, `xi-api-key` header, form
10
+ * field `model_id`.
11
+ *
12
+ * The key: an explicit `TRANSCRIPTION_API_KEY` wins, with `TRANSCRIPTION_PROVIDER`
13
+ * (default `openai`). Without it, transcription reuses a provider key the
14
+ * swarm already has: the pinned provider's key when `TRANSCRIPTION_PROVIDER` is
15
+ * set, else the first of `ELEVENLABS_API_KEY`, `OPENAI_API_KEY`, `GROQ_API_KEY`.
16
+ * Off when none is set.
17
+ *
18
+ * `TRANSCRIPTION_MODEL` overrides the provider's default model.
19
+ * `TRANSCRIPTION_BASE_URL` overrides its base URL for an explicit key only: an
20
+ * auto-detected key always goes to its provider's own endpoint. Deepgram and AssemblyAI use other request shapes and are not
21
+ * supported. Read at call time, so a config reload applies without a restart.
22
+ */
23
+ import { createValueScrubber, scrubSecrets } from "./secret-scrubber";
24
+
25
+ const TRANSCRIPTION_TIMEOUT_MS = 60_000;
26
+
27
+ /** The config key that switches transcription on explicitly. */
28
+ export const TRANSCRIPTION_API_KEY_ENV = "TRANSCRIPTION_API_KEY";
29
+
30
+ type ProviderSpec = {
31
+ /** The provider's own key, reused when `TRANSCRIPTION_API_KEY` is unset. */
32
+ keyEnv: string;
33
+ defaultBaseUrl: string;
34
+ defaultModel: string;
35
+ path: string;
36
+ modelField: string;
37
+ authHeaders: (apiKey: string) => Record<string, string>;
38
+ };
39
+
40
+ const openAIShape = {
41
+ path: "/audio/transcriptions",
42
+ modelField: "model",
43
+ authHeaders: (apiKey: string) => ({ authorization: `Bearer ${apiKey}` }),
44
+ };
45
+
46
+ const PROVIDERS = {
47
+ openai: {
48
+ keyEnv: "OPENAI_API_KEY",
49
+ defaultBaseUrl: "https://api.openai.com/v1",
50
+ defaultModel: "whisper-1",
51
+ ...openAIShape,
52
+ },
53
+ groq: {
54
+ keyEnv: "GROQ_API_KEY",
55
+ defaultBaseUrl: "https://api.groq.com/openai/v1",
56
+ defaultModel: "whisper-large-v3-turbo",
57
+ ...openAIShape,
58
+ },
59
+ elevenlabs: {
60
+ keyEnv: "ELEVENLABS_API_KEY",
61
+ defaultBaseUrl: "https://api.elevenlabs.io/v1",
62
+ defaultModel: "scribe_v2",
63
+ path: "/speech-to-text",
64
+ modelField: "model_id",
65
+ authHeaders: (apiKey) => ({ "xi-api-key": apiKey }),
66
+ },
67
+ } satisfies Record<string, ProviderSpec>;
68
+
69
+ export type TranscriptionProvider = keyof typeof PROVIDERS;
70
+
71
+ /** Order in which an existing provider key is picked when nothing is pinned. */
72
+ const AUTO_DETECT_ORDER: TranscriptionProvider[] = ["elevenlabs", "openai", "groq"];
73
+
74
+ /** Every env key the provider and key resolution reads. */
75
+ export const TRANSCRIPTION_ENV_KEYS = [
76
+ TRANSCRIPTION_API_KEY_ENV,
77
+ "TRANSCRIPTION_PROVIDER",
78
+ "TRANSCRIPTION_BASE_URL",
79
+ "TRANSCRIPTION_MODEL",
80
+ ...AUTO_DETECT_ORDER.map((name) => PROVIDERS[name].keyEnv),
81
+ ];
82
+
83
+ export class TranscriptionError extends Error {
84
+ constructor(message: string) {
85
+ super(message);
86
+ this.name = "TranscriptionError";
87
+ }
88
+ }
89
+
90
+ /** The provider and key a transcription call uses. */
91
+ export type TranscriptionConfig = {
92
+ provider: string;
93
+ apiKey: string;
94
+ /** The env key the API key came from. */
95
+ keyEnv: string;
96
+ /** `explicit`: `TRANSCRIPTION_API_KEY`. `auto`: a provider key the swarm already had. */
97
+ source: "explicit" | "auto";
98
+ };
99
+
100
+ function envValue(key: string): string | undefined {
101
+ return process.env[key]?.trim() || undefined;
102
+ }
103
+
104
+ /** Resolve provider and key, or null when no key is set. Unsupported provider names pass through. */
105
+ export function resolveTranscriptionConfig(): TranscriptionConfig | null {
106
+ const pinned = envValue("TRANSCRIPTION_PROVIDER")?.toLowerCase();
107
+ const explicitKey = envValue(TRANSCRIPTION_API_KEY_ENV);
108
+ if (explicitKey) {
109
+ return {
110
+ provider: pinned ?? "openai",
111
+ apiKey: explicitKey,
112
+ keyEnv: TRANSCRIPTION_API_KEY_ENV,
113
+ source: "explicit",
114
+ };
115
+ }
116
+ // A pinned provider only uses its own key, never another provider's.
117
+ const candidates = pinned
118
+ ? AUTO_DETECT_ORDER.filter((name) => name === pinned)
119
+ : AUTO_DETECT_ORDER;
120
+ for (const name of candidates) {
121
+ const { keyEnv } = PROVIDERS[name];
122
+ const apiKey = envValue(keyEnv);
123
+ if (apiKey) return { provider: name, apiKey, keyEnv, source: "auto" };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ export function isTranscriptionConfigured(): boolean {
129
+ return resolveTranscriptionConfig() !== null;
130
+ }
131
+
132
+ function providerSpec(name: string): ProviderSpec {
133
+ if (!Object.hasOwn(PROVIDERS, name)) {
134
+ throw new TranscriptionError(
135
+ `TRANSCRIPTION_PROVIDER "${name}" is not supported (use ${Object.keys(PROVIDERS).join(", ")})`,
136
+ );
137
+ }
138
+ return PROVIDERS[name as TranscriptionProvider];
139
+ }
140
+
141
+ /** OpenAI puts the reason in `error.message`; ElevenLabs in `detail.message` or `detail`. */
142
+ function errorDetail(payload: Record<string, unknown> | null, status: number): string {
143
+ const error = payload?.error as { message?: unknown } | undefined;
144
+ if (typeof error?.message === "string") return error.message;
145
+ const detail = payload?.detail as { message?: unknown } | string | undefined;
146
+ if (typeof detail === "string") return detail;
147
+ if (typeof detail?.message === "string") return detail.message;
148
+ return `HTTP ${status}`;
149
+ }
150
+
151
+ /** Transcribe one audio file. Throws `TranscriptionError` when unconfigured or when the call fails. */
152
+ export async function transcribeAudio(input: {
153
+ data: Blob;
154
+ filename: string;
155
+ mimeType?: string;
156
+ }): Promise<string> {
157
+ const config = resolveTranscriptionConfig();
158
+ if (!config) throw new TranscriptionError("no transcription provider key is set");
159
+ const { apiKey } = config;
160
+ const provider = providerSpec(config.provider);
161
+ // An auto-detected key only goes to its own provider: a base URL set in
162
+ // config must not receive a key that was stored for another purpose.
163
+ const baseUrlOverride =
164
+ config.source === "explicit" ? process.env.TRANSCRIPTION_BASE_URL?.trim() : undefined;
165
+ const baseUrl = (baseUrlOverride || provider.defaultBaseUrl).replace(/\/+$/, "");
166
+ // scrubSecrets skips known values under 12 chars, and a self-hosted key can
167
+ // be shorter, so the submitted key is scrubbed by value from every error.
168
+ const keyScrubber = createValueScrubber([{ value: apiKey, name: config.keyEnv }]);
169
+ const scrub = (text: string) => scrubSecrets(keyScrubber.text(text));
170
+ const form = new FormData();
171
+ const type = input.mimeType ?? input.data.type;
172
+ form.append("file", type ? new Blob([input.data], { type }) : input.data, input.filename);
173
+ form.append(
174
+ provider.modelField,
175
+ process.env.TRANSCRIPTION_MODEL?.trim() || provider.defaultModel,
176
+ );
177
+
178
+ let response: Response;
179
+ try {
180
+ response = await fetch(`${baseUrl}${provider.path}`, {
181
+ method: "POST",
182
+ headers: provider.authHeaders(apiKey),
183
+ body: form,
184
+ signal: AbortSignal.timeout(TRANSCRIPTION_TIMEOUT_MS),
185
+ });
186
+ } catch (error) {
187
+ if (error instanceof Error && error.name === "TimeoutError") {
188
+ throw new TranscriptionError(`no answer within ${TRANSCRIPTION_TIMEOUT_MS / 1000} s`);
189
+ }
190
+ throw new TranscriptionError(scrub(error instanceof Error ? error.message : String(error)));
191
+ }
192
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
193
+ if (!response.ok || typeof payload?.text !== "string") {
194
+ throw new TranscriptionError(scrub(errorDetail(payload, response.status)));
195
+ }
196
+ return payload.text.trim();
197
+ }
198
+
199
+ /** The task-text note for an audio file when no provider is configured. */
200
+ export const NOT_TRANSCRIBED_NOTE = `(not transcribed: no transcription provider configured, set ${TRANSCRIPTION_API_KEY_ENV} or one of ${AUTO_DETECT_ORDER.map((name) => PROVIDERS[name].keyEnv).join(", ")})`;
201
+
202
+ const AUDIO_EXTENSIONS: Record<string, string> = {
203
+ "audio/ogg": "ogg",
204
+ "audio/opus": "ogg",
205
+ "audio/mpeg": "mp3",
206
+ "audio/mp4": "m4a",
207
+ "audio/x-m4a": "m4a",
208
+ "audio/wav": "wav",
209
+ "audio/x-wav": "wav",
210
+ "audio/webm": "webm",
211
+ };
212
+
213
+ /** One audio file to transcribe. `key` names its note in the returned map. */
214
+ export type AudioFile = { key: string; name: string; data: Blob; mimeType?: string };
215
+
216
+ /**
217
+ * Transcribe audio files into task-text notes, one per file: `[Transcript of
218
+ * <name>]` and the text, `(transcription failed: <reason>)`, or (when no
219
+ * provider is configured) `NOT_TRANSCRIBED_NOTE`. Never throws, so a failed
220
+ * call never blocks the task it belongs to. `logPrefix` tags the warning.
221
+ */
222
+ export async function transcribeAudioNotes(
223
+ files: AudioFile[],
224
+ logPrefix: string,
225
+ ): Promise<Map<string, string>> {
226
+ const notes = new Map<string, string>();
227
+ if (files.length === 0) return notes;
228
+ if (!isTranscriptionConfigured()) {
229
+ for (const file of files) notes.set(file.key, NOT_TRANSCRIBED_NOTE);
230
+ return notes;
231
+ }
232
+ for (const file of files) {
233
+ // Voice notes can carry no file name; the endpoint reads the format from the extension.
234
+ const extension = AUDIO_EXTENSIONS[file.mimeType ?? ""] ?? "ogg";
235
+ const filename = /\.\w+$/.test(file.name) ? file.name : `${file.name}.${extension}`;
236
+ try {
237
+ const text = await transcribeAudio({ data: file.data, filename, mimeType: file.mimeType });
238
+ notes.set(file.key, `[Transcript of ${file.name}]\n${text || "(no speech recognized)"}`);
239
+ } catch (error) {
240
+ const reason = scrubSecrets(error instanceof Error ? error.message : String(error));
241
+ console.warn(`${logPrefix} could not transcribe file ${file.key}: ${reason}`);
242
+ notes.set(file.key, `(transcription failed: ${reason})`);
243
+ }
244
+ }
245
+ return notes;
246
+ }
@@ -18,8 +18,8 @@ import {
18
18
  } from "./human-in-the-loop";
19
19
  import { SYSTEM_ONE_EXTRACTION_NODE_TYPE } from "./system-one-extraction";
20
20
  import {
21
+ resolveSystemOneDefaultProvider,
21
22
  resolveSystemOneTarget,
22
- SYSTEM_ONE_DEFAULT_PROVIDER,
23
23
  SYSTEM_ONE_PROVIDER_IDS,
24
24
  SYSTEM_ONE_PROVIDERS,
25
25
  type SystemOneProvider,
@@ -164,7 +164,11 @@ export type SystemOneHumanReview = z.infer<typeof HumanReviewSchema>;
164
164
 
165
165
  export const SystemOneDecisionConfigSchema = z
166
166
  .strictObject({
167
- provider: z.enum(SYSTEM_ONE_PROVIDER_IDS).default(SYSTEM_ONE_DEFAULT_PROVIDER),
167
+ /**
168
+ * Decision host. Unset means the `SYSTEM_ONE_DEFAULT_PROVIDER` global config
169
+ * value, read at run time (`typesafe` when that is unset too).
170
+ */
171
+ provider: z.enum(SYSTEM_ONE_PROVIDER_IDS).optional(),
168
172
  /** Provider-specific model id. Unset means the provider's default. */
169
173
  model: z.string().min(1).optional(),
170
174
  state: SystemOneStateSchema,
@@ -235,7 +239,9 @@ export const SystemOneDecisionConfigSchema = z
235
239
  }
236
240
  }
237
241
  // Provider limits. A {{token}} model is skipped at save and checked again at run,
238
- // where the interpolated config is parsed by this same schema.
242
+ // where the interpolated config is parsed by this same schema. With no provider
243
+ // the default is read at run time, so `decide` checks the limits there.
244
+ if (config.provider === undefined) return;
239
245
  const modelProblem = systemOneModelProblem(
240
246
  config.provider,
241
247
  config.model ?? (SYSTEM_ONE_PROVIDERS[config.provider] as SystemOneProvider).defaultModel,
@@ -847,6 +853,11 @@ export class SystemOneDecisionExecutor extends BaseExecutor<
847
853
  return { db: this.deps.db, env: this.options.env ?? process.env };
848
854
  }
849
855
 
856
+ /** The provider a config without `provider` uses, read now so a settings change applies at once. */
857
+ private defaultProvider(): Promise<SystemOneProviderId> {
858
+ return resolveSystemOneDefaultProvider(this.providerContext());
859
+ }
860
+
850
861
  private resolveTarget(id: SystemOneProviderId, model?: string) {
851
862
  return resolveSystemOneTarget(id, this.providerContext(), {
852
863
  apiKey: this.options.getApiKey,
@@ -863,9 +874,10 @@ export class SystemOneDecisionExecutor extends BaseExecutor<
863
874
  nodes: readonly ExecutorReadinessNode[],
864
875
  ): Promise<ExecutorReadinessProblem[]> {
865
876
  const nodeIdsByProvider = new Map<SystemOneProviderId, string[]>();
877
+ const defaultId = await this.defaultProvider();
866
878
  for (const node of nodes) {
867
879
  // An unknown provider is a config error the schema reports; it names no credential.
868
- const id = systemOneProviderOf(node.config);
880
+ const id = systemOneProviderOf(node.config, defaultId);
869
881
  if (id) nodeIdsByProvider.set(id, [...(nodeIdsByProvider.get(id) ?? []), node.id]);
870
882
  }
871
883
  const problems: ExecutorReadinessProblem[] = [];
@@ -903,16 +915,25 @@ export class SystemOneDecisionExecutor extends BaseExecutor<
903
915
  */
904
916
  async decide(
905
917
  config: SystemOneDecisionConfig,
906
- ): Promise<{ ok: true; output: SystemOneDecisionOutput } | { ok: false; error: string }> {
907
- const providerId = config.provider;
918
+ ): Promise<
919
+ | { ok: true; output: SystemOneDecisionOutput }
920
+ | { ok: false; error: string; deploymentRefused?: true }
921
+ > {
922
+ const providerId = config.provider ?? (await this.defaultProvider());
908
923
  const provider: SystemOneProvider = SYSTEM_ONE_PROVIDERS[providerId];
909
924
 
910
925
  // A provider with no default sends no `model` when the node sets none.
911
926
  const model = config.model ?? provider.defaultModel;
912
927
  const modelProblem = systemOneModelProblem(providerId, model);
913
928
  if (modelProblem) return { ok: false, error: modelProblem };
929
+ // Checked at save only for a named provider; a defaulted one is checked here.
930
+ const limitProblem = systemOneQuestionLimitProblem(
931
+ providerId,
932
+ Object.keys(config.questions).length,
933
+ );
934
+ if (limitProblem) return { ok: false, error: limitProblem };
914
935
  const target = await this.resolveTarget(providerId, model);
915
- if (!target.ok) return { ok: false, error: target.error };
936
+ if (!target.ok) return target;
916
937
  const { apiKey, endpoint, keyName } = target;
917
938
 
918
939
  const wire = provider.wire ?? SYSTEM_ONE_WIRE;