@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
@@ -13,11 +13,15 @@ export const argsSchema = z.object({
13
13
  .min(0)
14
14
  .max(1)
15
15
  .optional()
16
- .describe("Quiet when Jev's p(needs action) is below this (default 0.5)"),
16
+ .describe(
17
+ "Quiet when the model's p(needs action) is below this (default: the provider's measured threshold, 0.5 for Jev, 0.2 for Clef)",
18
+ ),
17
19
  provider: z
18
- .enum(["auto", "typesafe", "openrouter"])
20
+ .enum(["auto", "typesafe", "openrouter", "cloudflare"])
19
21
  .optional()
20
- .describe("Jev host; auto prefers TypeSafe (default auto)"),
22
+ .describe(
23
+ "Decision host: typesafe or openrouter (Jev), cloudflare (Clef). auto tries them in that order (default auto)",
24
+ ),
21
25
  timeZone: z
22
26
  .string()
23
27
  .optional()
@@ -28,25 +32,56 @@ export const argsSchema = z.object({
28
32
  .min(1_000)
29
33
  .max(45_000)
30
34
  .optional()
31
- .describe("Abandon the Jev call after this many ms (default 25000); the gate deadline is 60 s"),
35
+ .describe(
36
+ "Abandon the decision call after this many ms (default 25000); the gate deadline is 60 s",
37
+ ),
32
38
  });
33
39
 
34
40
  const KV_NAMESPACE = "heartbeat-jev-gate";
35
41
  const KV_KEY = "setup";
36
- // Measured per decision in the 2026-10-15 heartbeat replay (229 states).
37
- const COST_PER_DECISION = "$0.00004";
42
+ // Jev's cost, measured per decision in the 2026-10-15 heartbeat replay (229 states).
43
+ const JEV_COST_PER_DECISION = "$0.00004";
44
+ // Clef replayed on the same 229 states and labels (2026-10-20): 0.2 is the highest
45
+ // threshold that misses none of the 9 ticks where the Lead acted (57 quiet ticks;
46
+ // Jev at 0.5 has 100). Above 0.21 Clef starts missing real asks.
47
+ const CLEF_THRESHOLD = 0.2;
38
48
 
39
- // Jev is called through `ctx.swarm.systemOne_decide`: the API server reads the
40
- // key and sends the request, so no key ever enters this script's realm. The
49
+ // The model is called through `ctx.swarm.systemOne_decide`: the API server reads
50
+ // the key and sends the request, so no key ever enters this script's realm. The
41
51
  // server also refuses OpenRouter when the effective OPENROUTER_BASE_URL (stored
42
- // config or deployment env) points at a gateway.
52
+ // config or deployment env) points at a gateway; `auto` then tries the next
53
+ // usable provider, since this script cannot read the deployment env.
54
+ //
55
+ // `credentials` lists what must be set: every group needs one of its keys.
56
+ // `threshold` is the default the provider's own replay measured (see `CLEF_THRESHOLD`).
43
57
  const PROVIDERS = {
44
- typesafe: { keyName: "TYPESAFE_API_KEY", model: "jev-latest" },
45
- openrouter: { keyName: "OPENROUTER_API_KEY", model: "~typesafe/jev-latest" },
58
+ typesafe: {
59
+ model: "jev-latest",
60
+ family: "jev",
61
+ threshold: 0.5,
62
+ credentials: [["TYPESAFE_API_KEY"]],
63
+ setup: "`TYPESAFE_API_KEY` (Jev on TypeSafe)",
64
+ },
65
+ openrouter: {
66
+ model: "~typesafe/jev-latest",
67
+ family: "jev",
68
+ threshold: 0.5,
69
+ credentials: [["OPENROUTER_API_KEY"]],
70
+ setup: "`OPENROUTER_API_KEY` (Jev on OpenRouter)",
71
+ },
72
+ cloudflare: {
73
+ model: "clef",
74
+ family: "clef",
75
+ threshold: CLEF_THRESHOLD,
76
+ credentials: [["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_KEY"], ["CLOUDFLARE_ACCOUNT_ID"]],
77
+ setup:
78
+ "`CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`) as a secret plus the `CLOUDFLARE_ACCOUNT_ID` config value (Clef on Cloudflare Workers AI)",
79
+ },
46
80
  } as const;
47
81
  type ProviderId = keyof typeof PROVIDERS;
82
+ const AUTO_ORDER: ProviderId[] = ["typesafe", "openrouter", "cloudflare"];
48
83
 
49
- // Worded exactly as in the replay that set the 0.5 threshold; changing it voids that result.
84
+ // Worded exactly as in the replays that set the thresholds; changing it voids those results.
50
85
  const QUESTIONS = {
51
86
  act: {
52
87
  type: "noul",
@@ -229,21 +264,25 @@ async function buildState(ctx: any, leadId: string, nowMs: number, timeZone: str
229
264
  };
230
265
  }
231
266
 
232
- function setupPrompt(keys: string[]) {
267
+ function setupPrompt(candidates: ProviderId[]) {
268
+ const options = candidates.map((id) => `- ${PROVIDERS[id].setup}`);
233
269
  return {
234
- title: "Heartbeat Jev gate: add a Jev credential or decline",
270
+ title: "Heartbeat gate: add a decision model credential or decline",
235
271
  detached: true,
236
272
  questions: [
237
273
  {
238
274
  id: "enable",
239
275
  type: "approval",
240
- label: "Enable the Jev heartbeat gate?",
276
+ label: "Enable the heartbeat checklist gate?",
241
277
  description: [
242
- "Before each recurring heartbeat checklist, the `heartbeat-jev-gate` script sends a short summary of swarm state (about 1,500 characters: new failures, due tracked items, task and agent counts) to Jev, a small decision model by TypeSafe, and asks whether the Lead needs to act. With `HEARTBEAT_CHECKLIST_GATE_MODE=enforce`, quiet ticks skip the Lead's checklist task.",
278
+ "Before each recurring heartbeat checklist, the `heartbeat-jev-gate` script sends a short summary of swarm state (about 1,500 characters: new failures, due tracked items, task and agent counts) to a small SystemOne decision model (Jev by TypeSafe, or Clef on Cloudflare Workers AI) and asks whether the Lead needs to act. With `HEARTBEAT_CHECKLIST_GATE_MODE=enforce`, quiet ticks skip the Lead's checklist task.",
243
279
  "",
244
- `Cost: about ${COST_PER_DECISION} per decision, one decision per checklist tick.`,
280
+ `Cost: one decision per checklist tick, about ${JEV_COST_PER_DECISION} each with Jev.`,
245
281
  "",
246
- `No Jev credential is configured. To enable, approve and add one of ${keys.map((k) => `\`${k}\``).join(" or ")} as a global secret on the Secrets page (Settings > Secrets). Do not paste the key here. The next tick picks it up.`,
282
+ "No credential for the gate is configured. To enable, approve and add one of these as a global value on the Secrets page (Settings > Secrets):",
283
+ ...options,
284
+ "",
285
+ "Do not paste a key here. The next tick picks it up.",
247
286
  "",
248
287
  "Reject to decline. The gate then stays inactive, with no further prompts, until a key is added.",
249
288
  ].join("\n"),
@@ -253,9 +292,10 @@ function setupPrompt(keys: string[]) {
253
292
  }
254
293
 
255
294
  /**
256
- * Heartbeat checklist gate backed by Jev: asks Jev whether a recurring checklist
257
- * tick needs the Lead and returns { quiet, reason, summary }. With no Jev
258
- * credential it asks a human once, then stays a no-op until a key is added.
295
+ * Heartbeat checklist gate backed by a SystemOne decision model (Jev or Clef):
296
+ * asks whether a recurring checklist tick needs the Lead and returns
297
+ * { quiet, reason, summary }. With no credential it asks a human once, then
298
+ * stays a no-op until a key is added.
259
299
  */
260
300
  export default async function heartbeatJevGate(args: any, ctx: any) {
261
301
  const parsed = argsSchema.safeParse(args ?? {});
@@ -276,25 +316,21 @@ export default async function heartbeatJevGate(args: any, ctx: any) {
276
316
  };
277
317
 
278
318
  const cfgThreshold = Number.parseFloat(cfg("HEARTBEAT_JEV_GATE_THRESHOLD"));
279
- const threshold =
280
- a.threshold ?? (cfgThreshold >= 0 && cfgThreshold <= 1 ? cfgThreshold : 0.5);
281
319
  const providerPref = a.provider ?? (cfg("HEARTBEAT_JEV_GATE_PROVIDER").toLowerCase() || "auto");
282
320
  const timeZone = a.timeZone ?? (cfg("HEARTBEAT_JEV_GATE_TIMEZONE") || "UTC");
283
321
 
284
- const usable: Record<ProviderId, boolean> = {
285
- typesafe: cfg(PROVIDERS.typesafe.keyName) !== "",
286
- openrouter: cfg(PROVIDERS.openrouter.keyName) !== "",
287
- };
322
+ const usable = (id: ProviderId) =>
323
+ PROVIDERS[id].credentials.every((group) => group.some((key) => cfg(key) !== ""));
288
324
  const candidates: ProviderId[] =
289
- providerPref === "typesafe"
290
- ? ["typesafe"]
291
- : providerPref === "openrouter"
292
- ? ["openrouter"]
293
- : ["typesafe", "openrouter"];
294
- const provider = candidates.find((id) => usable[id]);
325
+ Object.hasOwn(PROVIDERS, providerPref) ? [providerPref as ProviderId] : AUTO_ORDER;
326
+ const usableCandidates = candidates.filter(usable);
295
327
 
296
- if (!provider) {
297
- const keys = candidates.map((id) => PROVIDERS[id].keyName);
328
+ if (usableCandidates.length === 0) {
329
+ const keys = candidates.map((id) =>
330
+ PROVIDERS[id].credentials
331
+ .map((group) => (group.length > 1 ? `(${group.join(" or ")})` : group[0]))
332
+ .join(" + "),
333
+ );
298
334
  const inactive = (state: string, extra: Record<string, unknown> = {}) => ({
299
335
  quiet: false,
300
336
  reason: `jev-gate inactive: ${state}`.slice(0, 300),
@@ -305,7 +341,7 @@ export default async function heartbeatJevGate(args: any, ctx: any) {
305
341
  let setup: SetupState | null = typeof stored === "string" ? JSON.parse(stored) : (stored ?? null);
306
342
 
307
343
  if (!setup) {
308
- const prompt = setupPrompt(keys);
344
+ const prompt = setupPrompt(candidates);
309
345
  if (dryRun) {
310
346
  const state = await buildState(ctx, leadId, Date.now(), timeZone);
311
347
  return inactive("no credential, would ask a human", { prompt, state });
@@ -349,35 +385,51 @@ export default async function heartbeatJevGate(args: any, ctx: any) {
349
385
  return inactive(why, { setup });
350
386
  }
351
387
 
352
- const target = PROVIDERS[provider];
353
388
  const state = await buildState(ctx, leadId, Date.now(), timeZone);
354
389
 
355
- // The server enforces the time budget. No retries, as in the replay.
356
- const res: any = await ctx.swarm.systemOne_decide({
357
- provider,
358
- model: target.model,
359
- state,
360
- questions: QUESTIONS,
361
- timeoutMs: a.jevTimeoutMs ?? 25_000,
362
- maxRetries: 0,
363
- });
364
- const body: any = res?.data ?? res;
365
- if (res?.success === false || body?.success === false) {
366
- throw new Error(`jev ${provider}: ${String(body?.message ?? body?.error ?? `HTTP ${res?.status}`).slice(0, 250)}`);
390
+ // The server enforces the time budget. No retries, as in the replay. A provider
391
+ // this deployment refuses (OpenRouter behind a gateway) sends nothing, so the
392
+ // next usable one is tried; only an explicit choice or the last one throws.
393
+ let provider = usableCandidates[0] as ProviderId;
394
+ let body: any;
395
+ const skipped: string[] = [];
396
+ for (const [i, id] of usableCandidates.entries()) {
397
+ provider = id;
398
+ const res: any = await ctx.swarm.systemOne_decide({
399
+ provider: id,
400
+ model: PROVIDERS[id].model,
401
+ state,
402
+ questions: QUESTIONS,
403
+ timeoutMs: a.jevTimeoutMs ?? 25_000,
404
+ maxRetries: 0,
405
+ });
406
+ body = res?.data ?? res;
407
+ if (res?.success !== false && body?.success !== false) break;
408
+ const why = String(body?.message ?? body?.error ?? `HTTP ${res?.status}`).slice(0, 250);
409
+ if (body?.providerUnavailable === true && i < usableCandidates.length - 1) {
410
+ skipped.push(`${id}: ${why}`);
411
+ continue;
412
+ }
413
+ throw new Error(`${PROVIDERS[id].family} ${id}: ${why}`);
367
414
  }
415
+ const target = PROVIDERS[provider];
416
+ // A configured threshold applies to every provider; unset, each uses its measured one.
417
+ const threshold =
418
+ a.threshold ?? (cfgThreshold >= 0 && cfgThreshold <= 1 ? cfgThreshold : target.threshold);
368
419
  const p = body?.answers?.act?.noul;
369
420
  if (typeof p !== "number" || !Number.isFinite(p)) {
370
- throw new Error(`jev ${provider}: response has no numeric answers.act.noul`);
421
+ throw new Error(`${target.family} ${provider}: response has no numeric answers.act.noul`);
371
422
  }
372
423
 
373
424
  const pText = p.toFixed(3);
374
425
  const quiet = p < threshold;
375
426
  const verdict: Record<string, unknown> = {
376
427
  quiet,
377
- reason: `jev p(needs action)=${pText} ${quiet ? "<" : ">="} ${threshold} via ${provider}`,
428
+ reason: `${target.family} p(needs action)=${pText} ${quiet ? "<" : ">="} ${threshold} via ${provider}`,
378
429
  };
379
430
  if (!quiet) {
380
- const lines = [`Jev p(needs action) = ${pText} (threshold ${threshold}, ${provider}).`];
431
+ const model = target.family === "jev" ? "Jev" : "Clef";
432
+ const lines = [`${model} p(needs action) = ${pText} (threshold ${threshold}, ${provider}).`];
381
433
  const failures = state.new_failures_since_previous_heartbeat;
382
434
  const tracked = state.tracked_items_mentioning_today_or_past_delete_date;
383
435
  if (Array.isArray(failures)) {
@@ -387,9 +439,9 @@ export default async function heartbeatJevGate(args: any, ctx: any) {
387
439
  lines.push("", "Tracked items due today or past their DELETE date:", ...tracked.map((t) => `- ${t.replace(/^[-*] /, "")}`));
388
440
  }
389
441
  if (!Array.isArray(failures) && !Array.isArray(tracked)) {
390
- lines.push("", "No new failures or due tracked items; Jev flagged the overall state.");
442
+ lines.push("", `No new failures or due tracked items; ${model} flagged the overall state.`);
391
443
  }
392
444
  verdict.summary = lines.join("\n").slice(0, 4000);
393
445
  }
394
- return dryRun ? { ...verdict, dryRun: true, provider, p, threshold, state } : verdict;
446
+ return dryRun ? { ...verdict, dryRun: true, provider, skipped, p, threshold, state } : verdict;
395
447
  }
@@ -116,9 +116,9 @@ export const SEED_SCRIPTS: SeedScript[] = [
116
116
  {
117
117
  name: "heartbeat-jev-gate",
118
118
  description:
119
- "Heartbeat checklist gate backed by Jev (TypeSafe or OpenRouter decisions API): scores a compact swarm state and returns {quiet, reason, summary} for HEARTBEAT_CHECKLIST_GATE_SCRIPT.",
119
+ "Heartbeat checklist gate backed by a SystemOne decision model (Jev on TypeSafe or OpenRouter, Clef on Cloudflare Workers AI): scores a compact swarm state and returns {quiet, reason, summary} for HEARTBEAT_CHECKLIST_GATE_SCRIPT.",
120
120
  intent:
121
- "Skip quiet Lead heartbeat checklist ticks with a cheap Jev decision; with no Jev credential it asks a human once, then stays a no-op until a key is added.",
121
+ "Skip quiet Lead heartbeat checklist ticks with a cheap Jev or Clef decision; with no credential it asks a human once, then stays a no-op until a key is added.",
122
122
  source: asText(heartbeatJevGateSrc),
123
123
  },
124
124
  {
@@ -68,7 +68,11 @@ const RESERVED_KEYS = new Set([
68
68
  // the agent-fs bootstrap admin key would let a compromised worker administer the
69
69
  // shared org. The API materializes these into its own process.env at boot via
70
70
  // getInjectableGlobalConfigs, so no HTTP consumer legitimately needs them.
71
- const API_ONLY_CONFIG_KEYS = new Set(["API_AGENT_FS_API_KEY", "SLACK_SIGNING_SECRET"]);
71
+ const API_ONLY_CONFIG_KEYS = new Set([
72
+ "API_AGENT_FS_API_KEY",
73
+ "SLACK_SIGNING_SECRET",
74
+ "TRANSCRIPTION_API_KEY",
75
+ ]);
72
76
 
73
77
  export function isApiOnlyConfigKey(key: string): boolean {
74
78
  return API_ONLY_CONFIG_KEYS.has(key);
@@ -160,6 +164,20 @@ export const APPS_JSON_COLUMN_MAX_BYTES_BOUNDS = { min: 1_024, max: 16_777_216 }
160
164
  */
161
165
  export const CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS_BOUNDS = { min: 1, max: 216 } as const;
162
166
 
167
+ /**
168
+ * Decision hosts `SYSTEM_ONE_DEFAULT_PROVIDER` may name: `SYSTEM_ONE_PROVIDER_IDS`
169
+ * in `src/workflows/executors/system-one-providers.ts`, listed here because that
170
+ * module's credential readers would pull the LLM stack into every `db.ts` import.
171
+ * A test keeps the two lists equal.
172
+ */
173
+ export const SYSTEM_ONE_DEFAULT_PROVIDER_OPTIONS = [
174
+ "typesafe",
175
+ "openrouter",
176
+ "laya",
177
+ "openai",
178
+ "cloudflare",
179
+ ] as const;
180
+
163
181
  /** Build `{ KEY: validator }` entries accepting only boolean literals. */
164
182
  function booleanValidators(keys: string[]): Record<string, ConfigValidator> {
165
183
  const message = (key: string) =>
@@ -399,6 +417,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
399
417
  },
400
418
  ...enumValidator("CLAUDE_TRANSPORT", ["cli", "sdk"]),
401
419
  ...enumValidator("SCRIPT_EXECUTOR", ["native", "quickjs"]),
420
+ ...enumValidator("TRANSCRIPTION_PROVIDER", ["openai", "elevenlabs", "groq"]),
402
421
  SCRIPT_QUICKJS_POOL_SIZE: validateQuickJSPoolSize,
403
422
  ...booleanValidators(["SCRIPT_EGRESS_ALLOW_PRIVATE"]),
404
423
  SCRIPT_EGRESS_ALLOWLIST: (value) =>
@@ -407,6 +426,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
407
426
  // model's weekly window (Fable/Opus/Sonnet). fallback: legacy random pick.
408
427
  ...enumValidator("MODEL_WINDOW_EXHAUSTED_POLICY", ["fail", "fallback"]),
409
428
  ...enumValidator("HEARTBEAT_CHECKLIST_GATE_MODE", ["shadow", "enforce"]),
429
+ ...enumValidator("SYSTEM_ONE_DEFAULT_PROVIDER", [...SYSTEM_ONE_DEFAULT_PROVIDER_OPTIONS]),
410
430
  // Codex credits-exhausted cooldown (ms). Permissive on range here (positive
411
431
  // integer) — the worker clamps to [5m, 7d] via resolveCodexCreditsExhaustedCooldownMs.
412
432
  CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS: (value) => {
@@ -582,6 +602,9 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
582
602
  // default in thread-buffer.ts; above 10 minutes a
583
603
  // reply waits longer than any operator expects.
584
604
  ...boundedIntegerValidators(["CHAT_SDK_BUFFER_MS"], 1, 600_000),
605
+ // Minutes a Chat SDK thread keeps its owner after the owner's task ends.
606
+ // 0 keeps it only while the task is open; a day is the longest useful window.
607
+ ...boundedIntegerValidators(["CHAT_SDK_OWNER_WINDOW_MIN"], 0, 1440),
585
608
  // Below ~100 tokens the preamble can't fit a useful summary; above 20000
586
609
  // (~80k chars) it risks the SIGTERM-143 context-saturation failure mode
587
610
  // the cap exists to prevent (see context-preamble.ts).
@@ -68,3 +68,33 @@ export async function recordTaskAttachmentUpload(
68
68
  throw error;
69
69
  }
70
70
  }
71
+
72
+ // Resolve against the row's ACTUAL stored key + its own org/drive, rather than
73
+ // reconstructing `tasks/<taskId>/<name>`. Attachments written to agent-fs via the
74
+ // CLI live at arbitrary paths (`misc/…`, `smoke/…`); reconstruction mis-resolves
75
+ // them, which previously caused downloads to 404 and deletes to orphan the real
76
+ // file. New uploads have no stored key yet at scope-build time and keep the
77
+ // forward-looking layout.
78
+ export function scopeFromAttachment(attachment: TaskAttachment): FileScope {
79
+ const storedKey = attachment.providerKey ?? attachment.path ?? "";
80
+ return {
81
+ taskId: attachment.taskId,
82
+ name: attachment.name,
83
+ key: storedKey.trim() ? storedKey : undefined,
84
+ orgId: attachment.orgId,
85
+ driveId: attachment.driveId,
86
+ };
87
+ }
88
+
89
+ // Which file-storage provider (if any) actually holds this attachment's bytes.
90
+ // Only agent-fs objects and local-fs uploads are provider-backed; `shared-fs`
91
+ // worker-volume pointers, `url`, and `page` attachments have no provider object,
92
+ // so they must never be fetched or deleted through a provider (doing so would
93
+ // mis-resolve and, on delete, orphan the real target). Note local-fs uploads are
94
+ // recorded with kind "shared-fs" but an explicit local-fs providerId, so key off
95
+ // both signals rather than kind alone.
96
+ export function backingProviderId(attachment: TaskAttachment): string | null {
97
+ if (attachment.kind === "agent-fs") return attachment.providerId ?? "agent-fs";
98
+ if (attachment.providerId === "local-fs") return "local-fs";
99
+ return null;
100
+ }
@@ -106,17 +106,17 @@
106
106
  "manifest": {
107
107
  "$schema": "../manifest.schema.json",
108
108
  "name": "slack-reply-gate",
109
- "description": "Asks Jev (TypeSafe or OpenRouter) whether Lead should answer a buffered Slack thread follow-up, and drops the wake-up when it should not",
110
- "version": "1.2.0",
109
+ "description": "Asks a SystemOne decision model (Jev or Clef) whether Lead should answer a buffered Slack thread follow-up, and drops the wake-up when it should not",
110
+ "version": "1.3.0",
111
111
  "runtime": "api",
112
112
  "assets": {
113
113
  "hooks": "hooks.ts"
114
114
  }
115
115
  },
116
116
  "files": {
117
- "hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * slack-reply-gate\n *\n * Every human message in a Slack thread the swarm takes part in is buffered and\n * flushed into a Lead task (\"[Thread follow-up — N message(s) buffered]\"), even\n * \"lol\", \":joy:\" or two people bantering. This hook asks TypeSafe Jev whether\n * Lead should answer, and drops the wake-up when the answer is no.\n *\n * Hook: `pre.task.create`, origin \"slack\", description carrying the\n * thread-buffer header. `pre.slack.route` cannot be used: the buffered thread\n * path returns before that hook is dispatched, and its result can only\n * re-target a message, never drop it.\n *\n * Rules:\n * - A message that @mentions Lead passes without a Jev call.\n * - Otherwise one Jev `choice` question classifies the message into a reason;\n * reply=false only for reaction_only | banter_between_others |\n * not_addressed_to_lead, and only when the summed probability of those\n * three labels is >= minConfidence (a banter/reaction split is still a skip).\n * - Fails OPEN on every error, timeout or missing key.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, block the task when reply=false, and mark the\n * triggering Slack message with :mute: (config.muteReaction)\n *\n * Muting: no swarm tool or ctx method adds a Slack reaction, so the hook calls\n * the Slack Web API (reactions.remove / reactions.add) with the bot token. It\n * first removes the bot's own acceptance reactions (:eyes:, :heavy_plus_sign:,\n * :zap:, :speech_balloon:, or their SLACK_REACTION_* overrides), the same set\n * the engine clears when it finalizes a task, then adds :mute:. With\n * muteReaction \"\" it only removes them: a true no-op on the message. It runs after\n * the block decision returns, so a slow or failing Slack call never delays or\n * changes the drop; its errors go to the same KV namespace.\n *\n * Every decision lands in KV namespace `ext:slack-reply-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n timeoutMs: z.number().int().positive().default(3000),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n minConfidence: z.number().min(0).max(1).default(0.55),\n contextMessages: z.number().int().min(0).max(20).default(6),\n leadAliases: z.array(z.string()).default([\"lead\"]),\n botSlackUserIds: z.array(z.string()).default([]),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n /**\n * Reaction for a dropped message in enforce mode. \"\" only removes the bot's\n * acceptance reaction(s) and adds nothing.\n */\n muteReaction: z.string().default(\"mute\"),\n slackTimeoutMs: z.number().int().positive().default(5000),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:slack-reply-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst HEADER_LINE_RE = /(?:^|\\n)\\[Thread follow-up — \\d+ message\\(s\\) buffered\\]\\n/g;\nconst CTX_OPEN = \"<thread_context>\";\nconst CTX_CLOSE = \"</thread_context>\";\n// The dispatcher cancels a handler after 5 s; leave room for the KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const REASONS = {\n direct_ask:\n \"Asks Lead (or the swarm, the bot, 'you' meaning the AI) a question or for something, explicitly or clearly implied.\",\n followup_to_lead:\n \"Answers, confirms, corrects or continues something Lead or another swarm agent ([Agent]) just said or asked, so Lead needs it to proceed.\",\n instruction_to_lead:\n \"Gives a task, instruction, request or decision for the AI to act on, even without naming it (for example 'can you create X', 'ship it', 'do it').\",\n reaction_only:\n \"Only a reaction: emoji, laughter (lol, haha, jaja), 'boom!', 'nice', 'thanks', '+1', with no request and nothing new for Lead.\",\n banter_between_others:\n \"Chat between the humans, or between other people and other bots, that is not aimed at Lead.\",\n not_addressed_to_lead:\n \"Addressed to a specific other person or another assistant, or an FYI that needs no answer from Lead.\",\n other: \"None of the above, or unclear.\",\n} as const;\n\nexport type Reason = keyof typeof REASONS;\nconst SKIP_REASONS: ReadonlySet<string> = new Set([\n \"reaction_only\",\n \"banter_between_others\",\n \"not_addressed_to_lead\",\n]);\n\nconst cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._:-]/g, \"_\").slice(0, 120);\n\nconst escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/**\n * Locate the engine's follow-up header (src/slack/thread-buffer.ts): at the start of the\n * description, or right after the closing `</thread_context>`. Thread context quotes old\n * messages verbatim, so a header (or a `</thread_context>`) quoted inside it is skipped.\n */\nconst findHeader = (description: string): { index: number; end: number } | null => {\n for (const m of description.matchAll(HEADER_LINE_RE)) {\n const index = m.index! + (m[0].startsWith(\"\\n\") ? 1 : 0);\n const before = description.slice(0, index);\n const open = before.indexOf(CTX_OPEN);\n if (open < 0 || before.trimEnd().endsWith(CTX_CLOSE)) {\n return { index, end: m.index! + m[0].length };\n }\n }\n return null;\n};\n\n/**\n * Split a buffered follow-up description into thread context and new messages, or null\n * when the description is not a buffered follow-up. Only `messages` is new text.\n */\nexport const parseFollowUp = (\n description: string,\n): { context: string[]; messages: string[] } | null => {\n const header = findHeader(description);\n if (!header) return null;\n const before = description.slice(0, header.index).trimEnd();\n const open = before.indexOf(CTX_OPEN);\n const context =\n open >= 0\n ? before\n .slice(open + CTX_OPEN.length, before.length - CTX_CLOSE.length)\n .split(/\\n(?=<@[A-Z0-9]+[|>]|\\[Agent\\]:)/)\n .map((m) => m.trim())\n .filter(Boolean)\n : [];\n const messages = description\n .slice(header.end)\n .split(\"\\n---\\n\")\n .map((m) => m.trim())\n .filter(Boolean);\n return { context, messages };\n};\n\n/** True when the new text addresses Lead with an @mention. */\nexport const mentionsLead = (text: string, aliases: readonly string[]): boolean => {\n // The buffer rewrites a mention of the swarm's own bot to `<@U…> (that's you)`.\n if (/<@[A-Z0-9]+>\\s*\\(that's you\\)/.test(text)) return true;\n const names = aliases\n .map((a) => a.trim())\n .filter(Boolean)\n .map(escapeRe);\n if (names.length === 0) return false;\n const alt = names.join(\"|\");\n return (\n new RegExp(`(^|[^\\\\w@])@(${alt})\\\\b`, \"i\").test(text) ||\n new RegExp(`<@[A-Z0-9]+\\\\|(${alt})>`, \"i\").test(text)\n );\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\nconst SLACK_API = \"https://slack.com/api\";\n// The engine's acceptance reactions (src/slack/reaction-shortcode.ts), by config key.\nconst ACCEPTANCE_REACTIONS: ReadonlyArray<[string, string]> = [\n [\"SLACK_REACTION_ACCEPTED\", \"eyes\"],\n [\"SLACK_REACTION_BUFFERED\", \"heavy_plus_sign\"],\n [\"SLACK_REACTION_NOW\", \"zap\"],\n [\"SLACK_REACTION_STEERED\", \"speech_balloon\"],\n];\nconst SHORTCODE_RE = /^[a-z0-9_+'-]+(::skin-tone-[2-6])?$/;\nconst shortcode = (raw: string): string | null => {\n const s = raw.trim().replace(/^:/, \"\").replace(/:$/, \"\").toLowerCase();\n return s && SHORTCODE_RE.test(s) ? s : null;\n};\n\n/** The acceptance reactions the engine may have put on the message, deduplicated. */\nexport const acceptanceReactions = (): string[] => [\n ...new Set(ACCEPTANCE_REACTIONS.map(([key, fallback]) => shortcode(envOf(key)) ?? fallback)),\n];\n\nconst slackCall = async (\n method: string,\n token: string,\n body: Record<string, string>,\n timeoutMs: number,\n): Promise<{ ok: boolean; error?: string }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetch(`${SLACK_API}/${method}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n if (!res.ok) return { ok: false, error: `http_${res.status}` };\n const data = (await res.json()) as { ok?: boolean; error?: string };\n return { ok: data?.ok === true, error: data?.error };\n } finally {\n clearTimeout(timer);\n }\n};\n\nexport type MuteResult = { removed: string[]; added: boolean; errors: string[] };\n\n/**\n * Replace the bot's acceptance reaction(s) on one message with `name`, or only\n * remove them when `name` is \"\". `reactions.remove` only removes the calling\n * bot's own reaction, so a human's :eyes: stays. Never throws: every failure\n * lands in `errors`.\n */\nexport const muteMessage = async (\n token: string,\n channel: string,\n timestamp: string,\n name: string,\n timeoutMs: number,\n): Promise<MuteResult> => {\n const result: MuteResult = { removed: [], added: false, errors: [] };\n const call = (method: string, reaction: string) =>\n slackCall(method, token, { channel, timestamp, name: reaction }, timeoutMs).catch(\n (err): { ok: boolean; error?: string } => ({ ok: false, error: String(err).slice(0, 120) }),\n );\n const removals = acceptanceReactions().filter((r) => r !== name);\n const removed = await Promise.all(removals.map((r) => call(\"reactions.remove\", r)));\n removed.forEach((res, i) => {\n const reaction = removals[i]!;\n if (res.ok) result.removed.push(reaction);\n else if (res.error !== \"no_reaction\" && res.error !== \"invalid_name\")\n result.errors.push(`remove ${reaction}: ${res.error ?? \"unknown\"}`);\n });\n if (!name) return result;\n const added = await call(\"reactions.add\", name);\n if (added.ok || added.error === \"already_reacted\") result.added = true;\n else result.errors.push(`add ${name}: ${added.error ?? \"unknown\"}`);\n return result;\n};\n\n// In-flight mutes, so tests can await the work the handler does not wait for.\nconst pendingMutes = new Set<Promise<void>>();\n/** Test hook: wait for every in-flight mute. */\nexport const settleMutes = async (): Promise<void> => {\n await Promise.allSettled([...pendingMutes]);\n};\n\nexport type JevVerdict = {\n reason: string;\n confidence: number | null;\n probabilities: Record<string, number> | null;\n latencyMs: number;\n};\n\n/**\n * One Jev call: classify the newest message into a reason. Throws on any failure.\n * TypeSafe and OpenRouter share the request body and the `answers` response shape.\n */\nexport const askJev = async (\n input: {\n messages: string[];\n context: string[];\n sender: string;\n leadNames: readonly string[];\n },\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<JevVerdict> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${transport.key}`,\n },\n body: JSON.stringify({\n state: {\n role: \"Decide whether Lead, the coordinating AI agent of a team's agent swarm, should answer a new Slack message in a thread it takes part in. Lead is woken by every message in the thread, including ones not meant for it. Answering noise wastes everyone's time; missing a real ask is worse.\",\n lead_names: input.leadNames,\n recent_thread_messages: input.context,\n new_message: input.messages.join(\"\\n---\\n\"),\n new_message_sender: input.sender,\n },\n model: transport.model,\n questions: {\n reason: {\n type: \"choice\",\n instructions:\n \"Which label best describes new_message from Lead's point of view? Use recent_thread_messages to tell who it is talking to. Lines starting with [Agent] are swarm bots.\",\n criteria: REASONS,\n },\n },\n }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as {\n answers?: { reason?: { choice?: unknown; confidence?: unknown; probabilities?: unknown } };\n } | null;\n const answer = body?.answers?.reason;\n const reason = typeof answer?.choice === \"string\" ? answer.choice : \"\";\n if (!(reason in REASONS)) throw new Error(`unexpected choice: ${String(reason).slice(0, 60)}`);\n const confidence = typeof answer?.confidence === \"number\" ? answer.confidence : null;\n const probabilities =\n answer?.probabilities && typeof answer.probabilities === \"object\"\n ? (answer.probabilities as Record<string, number>)\n : null;\n return { reason, confidence, probabilities, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\n/** Probability that the message needs no reply: the summed mass of the skip labels. */\nexport const skipProbability = (verdict: JevVerdict): number | null => {\n if (verdict.probabilities) {\n let sum = 0;\n for (const [label, p] of Object.entries(verdict.probabilities)) {\n if (SKIP_REASONS.has(label) && typeof p === \"number\") sum += p;\n }\n return sum;\n }\n return verdict.confidence;\n};\n\n/** Map a Jev verdict to the typed decision. An unsure skip verdict replies. */\nexport const decide = (\n verdict: JevVerdict,\n minConfidence: number,\n): { reply: boolean; reason: Reason; skipP: number | null; lowConfidence: boolean } => {\n const reason = verdict.reason as Reason;\n const skipP = skipProbability(verdict);\n if (!SKIP_REASONS.has(reason)) return { reply: true, reason, skipP, lowConfidence: false };\n const lowConfidence = skipP !== null && skipP < minConfidence;\n return { reply: lowConfidence, reason, skipP, lowConfidence };\n};\n\nconst record = async (\n swarm: Swarm,\n entry: Record<string, unknown>,\n counters: string[],\n prefix: \"d\" | \"m\" = \"d\",\n) => {\n const at = new Date().toISOString();\n const thread = safeKey(`${entry.channelId ?? \"\"}_${entry.threadTs ?? \"\"}`);\n const value = { at, ...entry };\n await Promise.allSettled([\n swarm.kv_set({ namespace: NS, key: `${prefix}:${Date.now()}:${thread}`, value }),\n // `last` stays the latest decision; mute outcomes only get their own row.\n ...(prefix === \"d\" ? [swarm.kv_set({ namespace: NS, key: \"last\", value })] : []),\n ...counters.map((c) => swarm.kv_incr({ namespace: NS, key: safeKey(c) })),\n ]);\n};\n\n/** Mute the triggering message and log the outcome. Never throws. */\nconst muteTrigger = async (\n swarm: Swarm,\n cfg: Cfg,\n base: Record<string, unknown>,\n): Promise<void> => {\n try {\n const channel = String(base.channelId ?? \"\");\n const ts = String(base.triggerTs ?? \"\");\n if (!channel || !ts) throw new Error(\"no slackChannelId or slackTriggerMessageTs\");\n const token = secret(\"SLACK_BOT_TOKEN\");\n if (!usable(token)) throw new Error(\"no SLACK_BOT_TOKEN\");\n const result = await muteMessage(token, channel, ts, cfg.muteReaction, cfg.slackTimeoutMs);\n // \"\" adds nothing: success is clearing the acceptance reaction(s).\n const done = cfg.muteReaction ? \"muted\" : \"unreacted\";\n const ok = (result.added || !cfg.muteReaction) && result.errors.length === 0;\n const action = ok ? done : \"mute-error\";\n await record(swarm, { ...base, action, ...result }, [action], \"m\");\n } catch (err) {\n await record(\n swarm,\n { ...base, action: \"mute-error\", errors: [String(err).slice(0, 300)] },\n [\"mute-error\"],\n \"m\",\n ).catch(() => {});\n }\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.task.create\", async (event, ctx) => {\n if (event.origin !== \"slack\") return;\n // Only buffered follow-ups. A direct @mention task never carries the header outside\n // its thread context.\n const followUp = parseFollowUp(event.description);\n if (!followUp) return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\") return;\n\n const opts = event.options;\n const base = {\n mode: cfg.mode,\n channelId: opts.slackChannelId,\n threadTs: opts.slackThreadTs,\n triggerTs: opts.slackTriggerMessageTs,\n senderSlackId: opts.slackUserId,\n };\n\n try {\n // A draft follow-up carries downloaded files: always let Lead see them.\n if (opts.status === \"draft\") {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"has_files\" }, [\n \"total\",\n \"files\",\n ]);\n return;\n }\n\n // The mention check reads only the new messages, never the thread context.\n const { context, messages } = followUp;\n const preview = messages.join(\" | \").slice(0, 300);\n\n if (messages.some((m) => mentionsLead(m, cfg.leadAliases))) {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"mention\", preview }, [\n \"total\",\n \"mention\",\n ]);\n return;\n }\n\n const transport = resolveTransport(cfg);\n\n const sender =\n opts.slackUserId && cfg.botSlackUserIds.includes(opts.slackUserId)\n ? \"bot\"\n : opts.requestedByUserId\n ? \"human (known teammate)\"\n : \"unknown (not a known teammate; may be a bot or an external person)\";\n\n const verdict = await askJev(\n {\n messages,\n context: context.slice(-cfg.contextMessages).map((m) => m.slice(0, 500)),\n sender,\n leadNames: cfg.leadAliases,\n },\n transport,\n cfg,\n ctx.signal,\n );\n const decision = decide(verdict, cfg.minConfidence);\n\n const skip = !decision.reply;\n const action = skip ? (cfg.mode === \"enforce\" ? \"skipped\" : \"shadow-skip\") : \"pass\";\n await record(\n ctx.swarm,\n {\n ...base,\n action,\n reply: decision.reply,\n reason: decision.reason,\n confidence: verdict.confidence,\n skipP: decision.skipP,\n lowConfidence: decision.lowConfidence,\n latencyMs: verdict.latencyMs,\n provider: transport.provider,\n sender,\n preview,\n },\n [\"total\", skip ? action : \"reply\", `reason:${decision.reason}`],\n );\n\n if (skip && cfg.mode === \"enforce\") {\n // Not awaited: the block must return inside the dispatcher's 5 s budget\n // whatever Slack does, and a failed reaction never changes the drop.\n // An empty (or invalid) muteReaction still clears the acceptance reaction(s).\n const muteReaction = shortcode(cfg.muteReaction) ?? \"\";\n const pending = muteTrigger(ctx.swarm, { ...cfg, muteReaction }, base);\n pendingMutes.add(pending);\n void pending.finally(() => pendingMutes.delete(pending));\n return block(\n `slack-reply-gate: no reply needed (${decision.reason}${decision.skipP !== null ? `, p(skip) ${decision.skipP.toFixed(2)}` : \"\"})`,\n );\n }\n return;\n } catch (err) {\n // Fail open, always.\n try {\n await record(\n ctx.swarm,\n { ...base, action: \"fail-open\", error: String(err).slice(0, 300) },\n [\"total\", \"error\"],\n );\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"slack-reply-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
117
+ "hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * slack-reply-gate\n *\n * Every human message in a Slack thread the swarm takes part in is buffered and\n * flushed into a Lead task (\"[Thread follow-up — N message(s) buffered]\"), even\n * \"lol\", \":joy:\" or two people bantering. This hook asks a SystemOne decision\n * model (Jev or Clef) whether Lead should answer, and drops the wake-up when the\n * answer is no.\n *\n * Hook: `pre.task.create`, origin \"slack\", description carrying the\n * thread-buffer header. `pre.slack.route` cannot be used: the buffered thread\n * path returns before that hook is dispatched, and its result can only\n * re-target a message, never drop it.\n *\n * Rules:\n * - A message that @mentions Lead passes without a decision call.\n * - Otherwise one `choice` question classifies the message into a reason;\n * reply=false only for reaction_only | banter_between_others |\n * not_addressed_to_lead, and only when the summed probability of those\n * three labels is >= minConfidence (a banter/reaction split is still a skip).\n * - Fails OPEN on every error, timeout or missing key.\n *\n * Provider (config.provider): the decision is made server-side by the\n * `system-one-decide` tool (`ctx.swarm.systemOne_decide`), the same path as a\n * `system-one-decision` workflow node, so every SystemOne host works and no key\n * enters this hook. `auto` picks the first host with a configured credential:\n * typesafe (Jev, TYPESAFE_API_KEY), openrouter (Jev, OPENROUTER_API_KEY), then\n * cloudflare (Clef, CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY, plus\n * CLOUDFLARE_ACCOUNT_ID). laya and openai are explicit choices only.\n *\n * modes (config.mode):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, block the task when reply=false, and mark the\n * triggering Slack message with :mute: (config.muteReaction)\n *\n * Muting: no swarm tool or ctx method adds a Slack reaction, so the hook calls\n * the Slack Web API (reactions.remove / reactions.add) with the bot token. It\n * first removes the bot's own acceptance reactions (:eyes:, :heavy_plus_sign:,\n * :zap:, :speech_balloon:, or their SLACK_REACTION_* overrides), the same set\n * the engine clears when it finalizes a task, then adds :mute:. With\n * muteReaction \"\" it only removes them: a true no-op on the message. It runs after\n * the block decision returns, so a slow or failing Slack call never delays or\n * changes the drop; its errors go to the same KV namespace.\n *\n * Every decision lands in KV namespace `ext:slack-reply-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n timeoutMs: z.number().int().positive().default(3000),\n provider: z\n .enum([\"auto\", \"typesafe\", \"openrouter\", \"cloudflare\", \"laya\", \"openai\"])\n .default(\"auto\"),\n /** Jev model on TypeSafe. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on OpenRouter. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Clef model on Cloudflare Workers AI: clef or clef-flash. */\n cloudflareModel: z.string().default(\"clef\"),\n minConfidence: z.number().min(0).max(1).default(0.55),\n contextMessages: z.number().int().min(0).max(20).default(6),\n leadAliases: z.array(z.string()).default([\"lead\"]),\n botSlackUserIds: z.array(z.string()).default([]),\n /**\n * Reaction for a dropped message in enforce mode. \"\" only removes the bot's\n * acceptance reaction(s) and adds nothing.\n */\n muteReaction: z.string().default(\"mute\"),\n slackTimeoutMs: z.number().int().positive().default(5000),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:slack-reply-gate\";\nconst HEADER_LINE_RE = /(?:^|\\n)\\[Thread follow-up — \\d+ message\\(s\\) buffered\\]\\n/g;\nconst CTX_OPEN = \"<thread_context>\";\nconst CTX_CLOSE = \"</thread_context>\";\n// The dispatcher cancels a handler after 5 s; leave room for the KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n// The smallest time budget system-one-decide accepts.\nconst MIN_DECIDE_TIMEOUT_MS = 1000;\n\nexport const REASONS = {\n direct_ask:\n \"Asks Lead (or the swarm, the bot, 'you' meaning the AI) a question or for something, explicitly or clearly implied.\",\n followup_to_lead:\n \"Answers, confirms, corrects or continues something Lead or another swarm agent ([Agent]) just said or asked, so Lead needs it to proceed.\",\n instruction_to_lead:\n \"Gives a task, instruction, request or decision for the AI to act on, even without naming it (for example 'can you create X', 'ship it', 'do it').\",\n reaction_only:\n \"Only a reaction: emoji, laughter (lol, haha, jaja), 'boom!', 'nice', 'thanks', '+1', with no request and nothing new for Lead.\",\n banter_between_others:\n \"Chat between the humans, or between other people and other bots, that is not aimed at Lead.\",\n not_addressed_to_lead:\n \"Addressed to a specific other person or another assistant, or an FYI that needs no answer from Lead.\",\n other: \"None of the above, or unclear.\",\n} as const;\n\nexport type Reason = keyof typeof REASONS;\nconst SKIP_REASONS: ReadonlySet<string> = new Set([\n \"reaction_only\",\n \"banter_between_others\",\n \"not_addressed_to_lead\",\n]);\n\nconst cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._:-]/g, \"_\").slice(0, 120);\n\nconst escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/**\n * Locate the engine's follow-up header (src/slack/thread-buffer.ts): at the start of the\n * description, or right after the closing `</thread_context>`. Thread context quotes old\n * messages verbatim, so a header (or a `</thread_context>`) quoted inside it is skipped.\n */\nconst findHeader = (description: string): { index: number; end: number } | null => {\n for (const m of description.matchAll(HEADER_LINE_RE)) {\n const index = m.index! + (m[0].startsWith(\"\\n\") ? 1 : 0);\n const before = description.slice(0, index);\n const open = before.indexOf(CTX_OPEN);\n if (open < 0 || before.trimEnd().endsWith(CTX_CLOSE)) {\n return { index, end: m.index! + m[0].length };\n }\n }\n return null;\n};\n\n/**\n * Split a buffered follow-up description into thread context and new messages, or null\n * when the description is not a buffered follow-up. Only `messages` is new text.\n */\nexport const parseFollowUp = (\n description: string,\n): { context: string[]; messages: string[] } | null => {\n const header = findHeader(description);\n if (!header) return null;\n const before = description.slice(0, header.index).trimEnd();\n const open = before.indexOf(CTX_OPEN);\n const context =\n open >= 0\n ? before\n .slice(open + CTX_OPEN.length, before.length - CTX_CLOSE.length)\n .split(/\\n(?=<@[A-Z0-9]+[|>]|\\[Agent\\]:)/)\n .map((m) => m.trim())\n .filter(Boolean)\n : [];\n const messages = description\n .slice(header.end)\n .split(\"\\n---\\n\")\n .map((m) => m.trim())\n .filter(Boolean);\n return { context, messages };\n};\n\n/** True when the new text addresses Lead with an @mention. */\nexport const mentionsLead = (text: string, aliases: readonly string[]): boolean => {\n // The buffer rewrites a mention of the swarm's own bot to `<@U…> (that's you)`.\n if (/<@[A-Z0-9]+>\\s*\\(that's you\\)/.test(text)) return true;\n const names = aliases\n .map((a) => a.trim())\n .filter(Boolean)\n .map(escapeRe);\n if (names.length === 0) return false;\n const alt = names.join(\"|\");\n return (\n new RegExp(`(^|[^\\\\w@])@(${alt})\\\\b`, \"i\").test(text) ||\n new RegExp(`<@[A-Z0-9]+\\\\|(${alt})>`, \"i\").test(text)\n );\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret from the API process env. The API process loads global\n * swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = usable(envOf(name)) ? envOf(name) : \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type ProviderId = Exclude<Cfg[\"provider\"], \"auto\">;\n\n/** Where the decision is made. No model means the provider's own default. */\nexport type DecisionTarget = { provider: ProviderId; model?: string };\n\n/** What `auto` needs per provider: every group must have one usable value. */\nconst AUTO_CREDENTIALS: ReadonlyArray<[ProviderId, string[][]]> = [\n [\"typesafe\", [[\"TYPESAFE_API_KEY\"]]],\n [\"openrouter\", [[\"OPENROUTER_API_KEY\"]]],\n [\"cloudflare\", [[\"CLOUDFLARE_API_TOKEN\", \"CLOUDFLARE_API_KEY\"], [\"CLOUDFLARE_ACCOUNT_ID\"]]],\n];\n\nconst modelFor = (provider: ProviderId, cfg: Cfg): string | undefined =>\n provider === \"typesafe\"\n ? cfg.model\n : provider === \"openrouter\"\n ? cfg.openrouterModel\n : provider === \"cloudflare\"\n ? cfg.cloudflareModel\n : undefined;\n\n/**\n * The decision providers to try, in order. An explicit one is used as is: the\n * server checks its credential. `auto` lists every provider in `AUTO_CREDENTIALS`\n * whose credential is set and throws when none is; the handler then fails open.\n */\nexport const resolveProviders = (cfg: Cfg): DecisionTarget[] => {\n if (cfg.provider !== \"auto\") {\n return [{ provider: cfg.provider, model: modelFor(cfg.provider, cfg) }];\n }\n const targets = AUTO_CREDENTIALS.filter(([, groups]) =>\n groups.every((keys) => keys.some((key) => usable(secret(key)))),\n ).map(([provider]) => ({ provider, model: modelFor(provider, cfg) }));\n if (targets.length > 0) return targets;\n throw new Error(\n \"no TYPESAFE_API_KEY, OPENROUTER_API_KEY, or CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID\",\n );\n};\n\n/** The first provider `resolveProviders` would try. */\nexport const resolveProvider = (cfg: Cfg): DecisionTarget => resolveProviders(cfg)[0]!;\n\n/** The server refused the provider for a deployment setting and sent nothing. */\nexport class ProviderUnavailableError extends Error {}\n\nconst SLACK_API = \"https://slack.com/api\";\n// The engine's acceptance reactions (src/slack/reaction-shortcode.ts), by config key.\nconst ACCEPTANCE_REACTIONS: ReadonlyArray<[string, string]> = [\n [\"SLACK_REACTION_ACCEPTED\", \"eyes\"],\n [\"SLACK_REACTION_BUFFERED\", \"heavy_plus_sign\"],\n [\"SLACK_REACTION_NOW\", \"zap\"],\n [\"SLACK_REACTION_STEERED\", \"speech_balloon\"],\n];\nconst SHORTCODE_RE = /^[a-z0-9_+'-]+(::skin-tone-[2-6])?$/;\nconst shortcode = (raw: string): string | null => {\n const s = raw.trim().replace(/^:/, \"\").replace(/:$/, \"\").toLowerCase();\n return s && SHORTCODE_RE.test(s) ? s : null;\n};\n\n/** The acceptance reactions the engine may have put on the message, deduplicated. */\nexport const acceptanceReactions = (): string[] => [\n ...new Set(ACCEPTANCE_REACTIONS.map(([key, fallback]) => shortcode(envOf(key)) ?? fallback)),\n];\n\nconst slackCall = async (\n method: string,\n token: string,\n body: Record<string, string>,\n timeoutMs: number,\n): Promise<{ ok: boolean; error?: string }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetch(`${SLACK_API}/${method}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n if (!res.ok) return { ok: false, error: `http_${res.status}` };\n const data = (await res.json()) as { ok?: boolean; error?: string };\n return { ok: data?.ok === true, error: data?.error };\n } finally {\n clearTimeout(timer);\n }\n};\n\nexport type MuteResult = { removed: string[]; added: boolean; errors: string[] };\n\n/**\n * Replace the bot's acceptance reaction(s) on one message with `name`, or only\n * remove them when `name` is \"\". `reactions.remove` only removes the calling\n * bot's own reaction, so a human's :eyes: stays. Never throws: every failure\n * lands in `errors`.\n */\nexport const muteMessage = async (\n token: string,\n channel: string,\n timestamp: string,\n name: string,\n timeoutMs: number,\n): Promise<MuteResult> => {\n const result: MuteResult = { removed: [], added: false, errors: [] };\n const call = (method: string, reaction: string) =>\n slackCall(method, token, { channel, timestamp, name: reaction }, timeoutMs).catch(\n (err): { ok: boolean; error?: string } => ({ ok: false, error: String(err).slice(0, 120) }),\n );\n const removals = acceptanceReactions().filter((r) => r !== name);\n const removed = await Promise.all(removals.map((r) => call(\"reactions.remove\", r)));\n removed.forEach((res, i) => {\n const reaction = removals[i]!;\n if (res.ok) result.removed.push(reaction);\n else if (res.error !== \"no_reaction\" && res.error !== \"invalid_name\")\n result.errors.push(`remove ${reaction}: ${res.error ?? \"unknown\"}`);\n });\n if (!name) return result;\n const added = await call(\"reactions.add\", name);\n if (added.ok || added.error === \"already_reacted\") result.added = true;\n else result.errors.push(`add ${name}: ${added.error ?? \"unknown\"}`);\n return result;\n};\n\n// In-flight mutes, so tests can await the work the handler does not wait for.\nconst pendingMutes = new Set<Promise<void>>();\n/** Test hook: wait for every in-flight mute. */\nexport const settleMutes = async (): Promise<void> => {\n await Promise.allSettled([...pendingMutes]);\n};\n\nexport type Verdict = {\n reason: string;\n confidence: number | null;\n probabilities: Record<string, number> | null;\n latencyMs: number;\n};\n\n/**\n * One decision: classify the newest message into a reason. Throws on any failure.\n * The server makes the call (`system-one-decide`), so every provider returns the\n * same `answers` shape. The time budget is enforced here and on the server.\n */\nexport const askModel = async (\n swarm: Swarm,\n input: {\n messages: string[];\n context: string[];\n sender: string;\n leadNames: readonly string[];\n },\n target: DecisionTarget,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<Verdict> => {\n const budgetMs = Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS);\n let timer: ReturnType<typeof setTimeout> | undefined;\n let onOuterAbort: (() => void) | undefined;\n const stop = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => reject(new Error(`timed out after ${budgetMs}ms`)), budgetMs);\n onOuterAbort = () => reject(new Error(\"aborted\"));\n outer?.addEventListener(\"abort\", onOuterAbort);\n });\n const t0 = Date.now();\n try {\n const call = swarm.systemOne_decide({\n provider: target.provider,\n ...(target.model ? { model: target.model } : {}),\n state: {\n role: \"Decide whether Lead, the coordinating AI agent of a team's agent swarm, should answer a new Slack message in a thread it takes part in. Lead is woken by every message in the thread, including ones not meant for it. Answering noise wastes everyone's time; missing a real ask is worse.\",\n lead_names: [...input.leadNames],\n recent_thread_messages: input.context,\n new_message: input.messages.join(\"\\n---\\n\"),\n new_message_sender: input.sender,\n },\n questions: {\n reason: {\n type: \"choice\",\n instructions:\n \"Which label best describes new_message from Lead's point of view? Use recent_thread_messages to tell who it is talking to. Lines starting with [Agent] are swarm bots.\",\n criteria: REASONS,\n },\n },\n timeoutMs: Math.max(budgetMs, MIN_DECIDE_TIMEOUT_MS),\n maxRetries: 0,\n });\n // After a timeout nobody awaits the call; its late failure must not go unhandled.\n call.catch(() => {});\n const res = (await Promise.race([call, stop])) as {\n success?: boolean;\n status?: number;\n data?: unknown;\n } | null;\n const body = (res?.data ?? res) as {\n success?: boolean;\n message?: unknown;\n error?: unknown;\n providerUnavailable?: unknown;\n answers?: { reason?: { choice?: unknown; confidence?: unknown; probabilities?: unknown } };\n } | null;\n if (res?.success === false || body?.success === false) {\n const why = `${target.provider}: ${String(body?.message ?? body?.error ?? `HTTP ${res?.status}`).slice(0, 200)}`;\n throw body?.providerUnavailable === true\n ? new ProviderUnavailableError(why)\n : new Error(why);\n }\n const answer = body?.answers?.reason;\n const reason = typeof answer?.choice === \"string\" ? answer.choice : \"\";\n if (!(reason in REASONS)) throw new Error(`unexpected choice: ${String(reason).slice(0, 60)}`);\n const confidence = typeof answer?.confidence === \"number\" ? answer.confidence : null;\n const probabilities =\n answer?.probabilities && typeof answer.probabilities === \"object\"\n ? (answer.probabilities as Record<string, number>)\n : null;\n return { reason, confidence, probabilities, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n if (onOuterAbort) outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\n/**\n * `askModel` on each target in turn, moving on only when the server refused the\n * provider for a deployment setting (no request was sent). Any other failure, or\n * a refusal of the last target, throws.\n */\nexport const askFirstAvailable = async (\n swarm: Swarm,\n input: Parameters<typeof askModel>[1],\n targets: readonly DecisionTarget[],\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ verdict: Verdict; target: DecisionTarget }> => {\n for (const [i, target] of targets.entries()) {\n try {\n return { verdict: await askModel(swarm, input, target, cfg, outer), target };\n } catch (err) {\n if (!(err instanceof ProviderUnavailableError) || i === targets.length - 1) throw err;\n }\n }\n throw new Error(\"no decision provider\");\n};\n\n/** Probability that the message needs no reply: the summed mass of the skip labels. */\nexport const skipProbability = (verdict: Verdict): number | null => {\n if (verdict.probabilities) {\n let sum = 0;\n for (const [label, p] of Object.entries(verdict.probabilities)) {\n if (SKIP_REASONS.has(label) && typeof p === \"number\") sum += p;\n }\n return sum;\n }\n return verdict.confidence;\n};\n\n/** Map a verdict to the typed decision. An unsure skip verdict replies. */\nexport const decide = (\n verdict: Verdict,\n minConfidence: number,\n): { reply: boolean; reason: Reason; skipP: number | null; lowConfidence: boolean } => {\n const reason = verdict.reason as Reason;\n const skipP = skipProbability(verdict);\n if (!SKIP_REASONS.has(reason)) return { reply: true, reason, skipP, lowConfidence: false };\n const lowConfidence = skipP !== null && skipP < minConfidence;\n return { reply: lowConfidence, reason, skipP, lowConfidence };\n};\n\nconst record = async (\n swarm: Swarm,\n entry: Record<string, unknown>,\n counters: string[],\n prefix: \"d\" | \"m\" = \"d\",\n) => {\n const at = new Date().toISOString();\n const thread = safeKey(`${entry.channelId ?? \"\"}_${entry.threadTs ?? \"\"}`);\n const value = { at, ...entry };\n await Promise.allSettled([\n swarm.kv_set({ namespace: NS, key: `${prefix}:${Date.now()}:${thread}`, value }),\n // `last` stays the latest decision; mute outcomes only get their own row.\n ...(prefix === \"d\" ? [swarm.kv_set({ namespace: NS, key: \"last\", value })] : []),\n ...counters.map((c) => swarm.kv_incr({ namespace: NS, key: safeKey(c) })),\n ]);\n};\n\n/** Mute the triggering message and log the outcome. Never throws. */\nconst muteTrigger = async (\n swarm: Swarm,\n cfg: Cfg,\n base: Record<string, unknown>,\n): Promise<void> => {\n try {\n const channel = String(base.channelId ?? \"\");\n const ts = String(base.triggerTs ?? \"\");\n if (!channel || !ts) throw new Error(\"no slackChannelId or slackTriggerMessageTs\");\n const token = secret(\"SLACK_BOT_TOKEN\");\n if (!usable(token)) throw new Error(\"no SLACK_BOT_TOKEN\");\n const result = await muteMessage(token, channel, ts, cfg.muteReaction, cfg.slackTimeoutMs);\n // \"\" adds nothing: success is clearing the acceptance reaction(s).\n const done = cfg.muteReaction ? \"muted\" : \"unreacted\";\n const ok = (result.added || !cfg.muteReaction) && result.errors.length === 0;\n const action = ok ? done : \"mute-error\";\n await record(swarm, { ...base, action, ...result }, [action], \"m\");\n } catch (err) {\n await record(\n swarm,\n { ...base, action: \"mute-error\", errors: [String(err).slice(0, 300)] },\n [\"mute-error\"],\n \"m\",\n ).catch(() => {});\n }\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.task.create\", async (event, ctx) => {\n if (event.origin !== \"slack\") return;\n // Only buffered follow-ups. A direct @mention task never carries the header outside\n // its thread context.\n const followUp = parseFollowUp(event.description);\n if (!followUp) return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\") return;\n\n const opts = event.options;\n const base = {\n mode: cfg.mode,\n channelId: opts.slackChannelId,\n threadTs: opts.slackThreadTs,\n triggerTs: opts.slackTriggerMessageTs,\n senderSlackId: opts.slackUserId,\n };\n\n try {\n // A draft follow-up carries downloaded files: always let Lead see them.\n if (opts.status === \"draft\") {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"has_files\" }, [\n \"total\",\n \"files\",\n ]);\n return;\n }\n\n // The mention check reads only the new messages, never the thread context.\n const { context, messages } = followUp;\n const preview = messages.join(\" | \").slice(0, 300);\n\n if (messages.some((m) => mentionsLead(m, cfg.leadAliases))) {\n await record(ctx.swarm, { ...base, action: \"pass\", reason: \"mention\", preview }, [\n \"total\",\n \"mention\",\n ]);\n return;\n }\n\n const targets = resolveProviders(cfg);\n\n const sender =\n opts.slackUserId && cfg.botSlackUserIds.includes(opts.slackUserId)\n ? \"bot\"\n : opts.requestedByUserId\n ? \"human (known teammate)\"\n : \"unknown (not a known teammate; may be a bot or an external person)\";\n\n const { verdict, target } = await askFirstAvailable(\n ctx.swarm,\n {\n messages,\n context: context.slice(-cfg.contextMessages).map((m) => m.slice(0, 500)),\n sender,\n leadNames: cfg.leadAliases,\n },\n targets,\n cfg,\n ctx.signal,\n );\n const decision = decide(verdict, cfg.minConfidence);\n\n const skip = !decision.reply;\n const action = skip ? (cfg.mode === \"enforce\" ? \"skipped\" : \"shadow-skip\") : \"pass\";\n await record(\n ctx.swarm,\n {\n ...base,\n action,\n reply: decision.reply,\n reason: decision.reason,\n confidence: verdict.confidence,\n skipP: decision.skipP,\n lowConfidence: decision.lowConfidence,\n latencyMs: verdict.latencyMs,\n provider: target.provider,\n sender,\n preview,\n },\n [\"total\", skip ? action : \"reply\", `reason:${decision.reason}`],\n );\n\n if (skip && cfg.mode === \"enforce\") {\n // Not awaited: the block must return inside the dispatcher's 5 s budget\n // whatever Slack does, and a failed reaction never changes the drop.\n // An empty (or invalid) muteReaction still clears the acceptance reaction(s).\n const muteReaction = shortcode(cfg.muteReaction) ?? \"\";\n const pending = muteTrigger(ctx.swarm, { ...cfg, muteReaction }, base);\n pendingMutes.add(pending);\n void pending.finally(() => pendingMutes.delete(pending));\n return block(\n `slack-reply-gate: no reply needed (${decision.reason}${decision.skipP !== null ? `, p(skip) ${decision.skipP.toFixed(2)}` : \"\"})`,\n );\n }\n return;\n } catch (err) {\n // Fail open, always.\n try {\n await record(\n ctx.swarm,\n { ...base, action: \"fail-open\", error: String(err).slice(0, 300) },\n [\"total\", \"error\"],\n );\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"slack-reply-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
118
118
  },
119
- "readme": "# slack-reply-gate\n\nLead is woken by every human message in a Slack thread the swarm takes part in, including \"lol\", an emoji, or two people chatting with each other. This extension asks Jev, [TypeSafe](https://typesafe.ai)'s decision model, whether Lead should answer a new thread message, and drops the wake-up when it should not.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\" }\n```\n\nStart in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |\n| `enforce` | Asks Jev, records the verdict, and blocks the Lead task when the answer is no. The message gets :mute: instead: no Lead task, no reply, no outcome card. |\n\n## What it gates\n\nHook: `pre.task.create`, for tasks with origin `slack` whose description carries the thread buffer's header `[Thread follow-up — N message(s) buffered]`. Those are the non-mention thread replies that `src/slack/thread-buffer.ts` batches into a Lead task. A direct @mention of the bot creates a task without that header and is never gated.\n\nThe header counts only at the start of the description or right after the closing `</thread_context>`. Thread context quotes old messages verbatim, and an older Lead reply can quote the header itself, so a header inside the context is ignored. Only the text after the real header is treated as new.\n\n## Decision\n\n1. A follow-up with downloaded files (status `draft`) passes without a Jev call.\n2. **@mention pass-through.** A new message that @mentions Lead passes without a Jev call. A mention is the bot mention the engine rewrites to `<@U…> (that's you)`, a resolved `<@U…|lead>`, or a literal `@lead` (any name in `leadAliases`). Only the new messages are scanned, never the thread context, so an old mention earlier in the thread does not pass later banter.\n3. Otherwise one Jev call (one `choice` question, see [Jev transport](#jev-transport)) with the new message(s), the sender kind, and the last `contextMessages` thread messages. Labels: `direct_ask`, `followup_to_lead`, `instruction_to_lead`, `reaction_only`, `banter_between_others`, `not_addressed_to_lead`, `other`.\n4. The answer is no only when the top label is `reaction_only`, `banter_between_others`, or `not_addressed_to_lead`, and the summed probability of those three labels is at least `minConfidence`. A split between two skip labels is still a skip. An unsure verdict replies.\n\nSeveral buffered messages are judged together. One mention in any of them passes the batch.\n\n## Jev transport\n\nJev is reachable two ways. Both take the same request and return the same answer, so the decision is identical whichever one runs.\n\n| `provider` | Endpoint | Key | Model |\n|---|---|---|---|\n| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |\n| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |\n| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |\n\nUnder `auto`, `TYPESAFE_API_KEY` wins when both keys are set. With neither key, every message fails open and the KV row names both secrets. Each decision row records the `provider` that answered.\n\n## Fail open\n\nEvery error lets the task through: a missing key, a TypeSafe HTTP error, a network error, a timeout, a malformed answer, or an unknown label. The handler never throws, so an outage cannot auto-disable the extension. Each failure is recorded as `fail-open` in KV and logged as a warning.\n\n## :mute: on drop (enforce)\n\nNo swarm tool or extension `ctx` method adds a Slack reaction, so the hook calls the Slack Web API with the bot token. It needs the `reactions:write` scope, which the Slack app already uses for its acceptance reactions.\n\n1. `reactions.remove` for each acceptance reaction the engine may have put on the triggering message: `eyes`, `heavy_plus_sign`, `zap`, `speech_balloon`, or their `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, and `SLACK_REACTION_STEERED` overrides. Slack only removes the bot's own reaction, so a human's :eyes: stays.\n2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `\"\"`.\n\nThe block returns first and the Slack calls run after it. A slow or failing Slack call never delays the drop or turns it back into a task. Set `muteReaction` to `\"\"` for a true no-op: step 1 still clears the bot's acceptance reaction(s), and nothing is added.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `timeoutMs` | `3000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `minConfidence` | `0.55` | Minimum summed skip probability to skip |\n| `contextMessages` | `6` | Thread messages sent to Jev, 0 to 20 |\n| `leadAliases` | `[\"lead\"]` | A literal `@alias` in a new message counts as a mention |\n| `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for Jev |\n| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |\n| `muteReaction` | `mute` | Reaction for a dropped message; `\"\"` only removes the acceptance reaction(s) |\n| `slackTimeoutMs` | `5000` | Timeout per Slack call |\n\n## Required secrets\n\nAll are read from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.\n\n- `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every message fails open.\n- `SLACK_BOT_TOKEN`: the Slack app's bot token. A swarm with Slack connected already has it. Without it, `enforce` still drops the message but cannot touch its reactions, and logs a `mute-error`.\n\nNo key is written to config, KV, logs, or a description.\n\n## KV namespace `ext:slack-reply-gate`\n\n- `d:<epochMs>:<channel>_<threadTs>`: one row per decision, with `action` (`pass`, `shadow-skip`, `skipped`, `fail-open`), `provider`, `reason`, `confidence`, `skipP`, `latencyMs`, `sender`, and a 300-character `preview`.\n- `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `\"\"`, `mute-error`), `removed`, `added`, and `errors`.\n- `last`: the latest decision. Mute rows never overwrite it.\n- Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.\n\n## Known limits\n\n- When Lead already has an active task in the thread, the buffer steers that task instead of creating one, so `pre.task.create` does not fire and the gate does not run.\n- A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.\n- A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.\n"
119
+ "readme": "# slack-reply-gate\n\nLead is woken by every human message in a Slack thread the swarm takes part in, including \"lol\", an emoji, or two people chatting with each other. This extension asks a SystemOne decision model, Jev ([TypeSafe](https://typesafe.ai)) or Clef (Cloudflare Workers AI), whether Lead should answer a new thread message, and drops the wake-up when it should not.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\" }\n```\n\nStart in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks the model and records the verdict. Never blocks. |\n| `enforce` | Asks the model, records the verdict, and blocks the Lead task when the answer is no. The message gets :mute: instead: no Lead task, no reply, no outcome card. |\n\n## What it gates\n\nHook: `pre.task.create`, for tasks with origin `slack` whose description carries the thread buffer's header `[Thread follow-up — N message(s) buffered]`. Those are the non-mention thread replies that `src/slack/thread-buffer.ts` batches into a Lead task. A direct @mention of the bot creates a task without that header and is never gated.\n\nThe header counts only at the start of the description or right after the closing `</thread_context>`. Thread context quotes old messages verbatim, and an older Lead reply can quote the header itself, so a header inside the context is ignored. Only the text after the real header is treated as new.\n\n## Decision\n\n1. A follow-up with downloaded files (status `draft`) passes without a decision call.\n2. **@mention pass-through.** A new message that @mentions Lead passes without a decision call. A mention is the bot mention the engine rewrites to `<@U…> (that's you)`, a resolved `<@U…|lead>`, or a literal `@lead` (any name in `leadAliases`). Only the new messages are scanned, never the thread context, so an old mention earlier in the thread does not pass later banter.\n3. Otherwise one decision call (one `choice` question, see [Provider](#provider)) with the new message(s), the sender kind, and the last `contextMessages` thread messages. Labels: `direct_ask`, `followup_to_lead`, `instruction_to_lead`, `reaction_only`, `banter_between_others`, `not_addressed_to_lead`, `other`.\n4. The answer is no only when the top label is `reaction_only`, `banter_between_others`, or `not_addressed_to_lead`, and the summed probability of those three labels is at least `minConfidence`. A split between two skip labels is still a skip. An unsure verdict replies.\n\nSeveral buffered messages are judged together. One mention in any of them passes the batch.\n\n## Provider\n\nThe decision is made on the API server by the `system-one-decide` tool (`ctx.swarm.systemOne_decide`), the same path a `system-one-decision` workflow node takes. The server reads the provider's credential, sends the request, and validates the answer, so every SystemOne host works and no key enters the hook.\n\n| `provider` | Model | Credential |\n|---|---|---|\n| `typesafe` | Jev, `model` (default `jev-latest`) | `TYPESAFE_API_KEY` |\n| `openrouter` | Jev, `openrouterModel` (default `typesafe/jev-1.13`) | `OPENROUTER_API_KEY` |\n| `cloudflare` | Clef, `cloudflareModel` (default `clef`, or `clef-flash`) | `CLOUDFLARE_API_TOKEN` (or `CLOUDFLARE_API_KEY`) and `CLOUDFLARE_ACCOUNT_ID` |\n| `laya`, `openai` | The host's default model | See the workflows guide |\n| `auto` (default) | The first of typesafe, openrouter, cloudflare whose credential is set. A provider the server refuses for a deployment setting (openrouter when `OPENROUTER_BASE_URL` points at a gateway) is skipped for the next one | |\n\nWith no credential for `auto`, every message fails open and the KV row names the secrets. Each decision row records the `provider` that answered. `minConfidence` was tuned on Jev; check Clef's verdicts in `shadow` before you enforce on it.\n\n## Fail open\n\nEvery error lets the task through: a missing key, a provider error relayed by the server, a network error, a timeout, a malformed answer, or an unknown label. The handler never throws, so an outage cannot auto-disable the extension. Each failure is recorded as `fail-open` in KV and logged as a warning.\n\n## :mute: on drop (enforce)\n\nNo swarm tool or extension `ctx` method adds a Slack reaction, so the hook calls the Slack Web API with the bot token. It needs the `reactions:write` scope, which the Slack app already uses for its acceptance reactions.\n\n1. `reactions.remove` for each acceptance reaction the engine may have put on the triggering message: `eyes`, `heavy_plus_sign`, `zap`, `speech_balloon`, or their `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, and `SLACK_REACTION_STEERED` overrides. Slack only removes the bot's own reaction, so a human's :eyes: stays.\n2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `\"\"`.\n\nThe block returns first and the Slack calls run after it. A slow or failing Slack call never delays the drop or turns it back into a task. Set `muteReaction` to `\"\"` for a true no-op: step 1 still clears the bot's acceptance reaction(s), and nothing is added.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `timeoutMs` | `3000` | Decision call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `provider` | `auto` | `auto`, `typesafe`, `openrouter`, `cloudflare`, `laya`, or `openai`. See [Provider](#provider) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `cloudflareModel` | `clef` | Clef model on Cloudflare Workers AI |\n| `minConfidence` | `0.55` | Minimum summed skip probability to skip |\n| `contextMessages` | `6` | Thread messages sent to the model, 0 to 20 |\n| `leadAliases` | `[\"lead\"]` | A literal `@alias` in a new message counts as a mention |\n| `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for the model |\n| `muteReaction` | `mute` | Reaction for a dropped message; `\"\"` only removes the acceptance reaction(s) |\n| `slackTimeoutMs` | `5000` | Timeout per Slack call |\n\n## Required secrets\n\nSet them as global `swarm_config` values. The server reads the provider credential from the global row when it makes the decision. `auto` and the Slack token read the API process environment, which loads global rows at boot and on config reload.\n\n- One provider credential from the [Provider](#provider) table is required for any evaluation. Without one, every message fails open. The `apiKey` config field of earlier versions is gone: the hook never holds a key, so store it as the global `TYPESAFE_API_KEY` secret.\n- `SLACK_BOT_TOKEN`: the Slack app's bot token. A swarm with Slack connected already has it. Without it, `enforce` still drops the message but cannot touch its reactions, and logs a `mute-error`.\n\nNo key is written to config, KV, logs, or a description.\n\n## KV namespace `ext:slack-reply-gate`\n\n- `d:<epochMs>:<channel>_<threadTs>`: one row per decision, with `action` (`pass`, `shadow-skip`, `skipped`, `fail-open`), `provider`, `reason`, `confidence`, `skipP`, `latencyMs`, `sender`, and a 300-character `preview`.\n- `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `\"\"`, `mute-error`), `removed`, `added`, and `errors`.\n- `last`: the latest decision. Mute rows never overwrite it.\n- Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.\n\n## Known limits\n\n- When Lead already has an active task in the thread, the buffer steers that task instead of creating one, so `pre.task.create` does not fire and the gate does not run.\n- A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.\n- A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.\n"
120
120
  },
121
121
  "task-digest": {
122
122
  "manifestFile": "manifest.yaml",