@fastagent-sh/fastagent 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
package/README.md CHANGED
@@ -36,10 +36,10 @@ FastAgent is the missing bridge from local agent directory to live service.
36
36
  ## Features
37
37
 
38
38
  - **Vibe first — a directory is an agent.** Point FastAgent at the `AGENTS.md` + `skills/` you already vibed in a coding agent. Markdown instructions, reusable skills, and TypeScript tools stay as files you inspect, edit, and commit — no new DSL, no framework rewrite.
39
- - **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, an HTTP/SSE endpoint, or your own adapter verified webhooks, streaming replies, group-aware.
39
+ - **Channels.** Serve the same agent as a GitHub PR reviewer, a Telegram bot, a Feishu or Lark bot, an HTTP/SSE endpoint, or your own adapter: verified webhooks, streaming replies, group-aware.
40
40
  - **Models, tools & skills.** Any model provider (OpenAI, Anthropic, Google, …) via OAuth or API key; typed tools discovered from `tools/` (the filename is the name, Zod-validated); Agent Skills loaded on demand. Built on the open-source [pi](https://github.com/earendil-works/pi) harness.
41
41
  - **App embedding — your stack, we plug in.** Mount the agent in your Next / Astro / Hono / Bun / Node route with one handler, or call `invoke` like any function from your own code — your auth, your database, your infra. FastAgent composes with your app, never owns it.
42
- - **Deploy anywhere.** No build step — the directory is the deployable unit. `fastagent deploy fly|railway` generates the deploy config + a runbook (`--run` drives it to completion); idle agents scale to zero and resume in hundreds of milliseconds on the next webhook. The generated container runs on any Docker host.
42
+ - **Deploy anywhere.** No application build step — the directory is the deployable unit. `fastagent deploy docker|fly|railway` generates the container + target config and a runbook (`--run` drives it to completion). Local Docker gets user-owned Compose + durable state; optional `--tunnel` adds an ephemeral Quick Tunnel service for webhook channels. Durable ingress remains yours.
43
43
 
44
44
  ## Design philosophy
45
45
 
@@ -145,7 +145,7 @@ const agent = createPiAgent({
145
145
  | [Embedding](https://fastagent.sh/docs/embedding/) | Use FastAgent as a library inside your own app |
146
146
  | [Channels](https://fastagent.sh/docs/channels/) | Add webhook/bot channels |
147
147
  | [Deploy](https://fastagent.sh/docs/deploy/) | Ship the directory to Fly, Railway, or any Docker host |
148
- | [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) / [Feishu and Lark](https://fastagent.sh/docs/feishu/) | First-party channel guides |
148
+ | [GitHub](https://fastagent.sh/docs/github/) / [Telegram](https://fastagent.sh/docs/telegram/) / [Slack](https://fastagent.sh/docs/slack/) / [Feishu and Lark](https://fastagent.sh/docs/feishu/) | First-party channel guides |
149
149
  | [Channel development](https://fastagent.sh/docs/channel-development/) | Build custom channel adapters |
150
150
  | [API reference](https://fastagent.sh/docs/api-reference/) | Public TypeScript API reference |
151
151
  | [Troubleshooting](https://fastagent.sh/docs/troubleshooting/) | Common setup/runtime issues |
@@ -194,11 +194,11 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
194
194
 
195
195
  The neutral contract leaves room for capabilities that are not complete product features yet:
196
196
 
197
- - **Durable execution** Telegram accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
197
+ - **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
198
198
  - **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
199
199
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
200
200
  - **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
201
- - **More deploy targets** — Fly and Railway ship today; the generated container is the portable path for other hosts.
201
+ - **More deploy targets** — local Docker, Fly, and Railway ship today; the generated container is the portable path for other hosts.
202
202
 
203
203
  See [Contributing](https://github.com/fastagent-sh/fastagent/blob/main/CONTRIBUTING.md) if one of these is the problem you want to work on.
204
204
 
package/dist/agent.d.ts CHANGED
@@ -39,6 +39,17 @@ export type AgentEvent = {
39
39
  isError: boolean;
40
40
  content: Json;
41
41
  }
42
+ /** Advisory, non-terminal (engines MAY emit it): a transient internal failure scheduled a retry
43
+ * with backoff — the turn is still alive. Explains a quiet gap that would otherwise read as a
44
+ * hang; deliberately unclosed — the next event is its closure. Terminal consumers ignore it
45
+ * (SPEC MUST 4). */
46
+ | {
47
+ type: "retrying";
48
+ attempt: number;
49
+ maxAttempts: number;
50
+ delayMs: number;
51
+ reason: string;
52
+ }
42
53
  /** Terminal: success. `data` is attached only when the engine produces a structured result. */
43
54
  | {
44
55
  type: "completed";
@@ -62,6 +73,15 @@ export type AgentEvent = {
62
73
  * not a public cross-engine mandate.
63
74
  */
64
75
  export declare const SESSION_BUSY_CODE = "session_busy";
76
+ /**
77
+ * The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
78
+ * harness-level abort the engine attributes (`stopReason: "aborted"`) — rather than failing on its
79
+ * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
80
+ * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
81
+ * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
82
+ * a consumer that must branch on it should not string-match.
83
+ */
84
+ export declare const ABORTED_CODE = "aborted";
65
85
  /**
66
86
  * One turn = one invoke, returning a single async event stream. The stream MUST terminate with
67
87
  * exactly one of completed / failed, or be cancelled by the caller (no terminal event). Any
package/dist/agent.js CHANGED
@@ -12,3 +12,12 @@
12
12
  * not a public cross-engine mandate.
13
13
  */
14
14
  export const SESSION_BUSY_CODE = "session_busy";
15
+ /**
16
+ * The `failed.code` set when a run was DELIBERATELY stopped — a control-plane abort, or any
17
+ * harness-level abort the engine attributes (`stopReason: "aborted"`) — rather than failing on its
18
+ * own. Channels can render cancellation distinctly from an error, and MUST treat it as a settled
19
+ * outcome — durable turn-intent cleanup included — so a deliberate stop is never replayed as a
20
+ * fresh turn on restart. Exported as a constant for the same reason as {@link SESSION_BUSY_CODE}:
21
+ * a consumer that must branch on it should not string-match.
22
+ */
23
+ export const ABORTED_CODE = "aborted";
@@ -0,0 +1,30 @@
1
+ /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
2
+ * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
3
+ * group posting many attachments between summons. Skipped ones must be counted into the prompt
4
+ * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
5
+ * each channel's attachment collector caps against this. */
6
+ export declare const BUFFER_ATTACH_MAX = 3;
7
+ export interface ContextBuffer<E> {
8
+ /** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
9
+ push(placeKey: string, entry: E): void;
10
+ /** Render the fold text and snapshot the consumed entries (see the module header's consume protocol). */
11
+ peek(placeKey: string): {
12
+ text: string;
13
+ consumed: E[];
14
+ };
15
+ /** Remove exactly `consumed` (by identity) — call on the turn's `completed` event, when the folded
16
+ * discussion provably lives in the durable session. Consumes entries WHOLE, including ones whose
17
+ * attachments failed to load or were cap-skipped: their text is in the session (keeping them would
18
+ * re-fold duplicate text), and the prompt note said what is missing; re-post an attachment to use
19
+ * it. Post-ACK: a failed write is logged, never thrown (it must not abort the turn's delivery). */
20
+ commit(placeKey: string, consumed: E[]): void;
21
+ }
22
+ export declare function createContextBuffer<E>(options: {
23
+ path: string;
24
+ /** Log label, e.g. "[telegram]". */
25
+ label: string;
26
+ /** Shape validator for one persisted entry (the IO boundary — see the module header). */
27
+ isEntry: (value: unknown) => value is E;
28
+ /** One fold line for an entry — ALSO the eviction cost basis. */
29
+ line: (entry: E) => string;
30
+ }): ContextBuffer<E>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Generic durable context buffer — the SHARED mechanics behind each stateful channel's
3
+ * "un-summoned group discussion" module (telegram/feishu/slack `context-buffer.ts`): recent
4
+ * un-summoned messages per conversation "place", kept under a char budget and folded into the next
5
+ * answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
6
+ *
7
+ * Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
8
+ * entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
9
+ * must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
10
+ * fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
11
+ * derivation, and buffered-attachment selection (platform resource shapes are real differences).
12
+ *
13
+ * DURABLE, with the consume protocol every channel inherits:
14
+ * - `push` persists synchronously BEFORE the transport ACK (an ACKed delivery is not redelivered,
15
+ * so ACK-then-persist would be a silent-loss window): a throw becomes the webhook's 500 and the
16
+ * platform redelivers once the disk recovers — staged on a copy and rolled back on a failed
17
+ * write, so the redelivery does not double-append the entry already in memory.
18
+ * - `peek` renders WITHOUT clearing and snapshots exactly which entries it consumed.
19
+ * - `commit` removes only that snapshot, by object identity, on the turn's `completed` — so a
20
+ * failure or crash before `completed` leaves the discussion intact for the next summon, and a
21
+ * message that arrives while the turn runs survives for the next answered turn (a whole-bucket
22
+ * delete would lose it).
23
+ */
24
+ import { log } from "../log.js";
25
+ import { loadStateFile, saveStateFile } from "./state.js";
26
+ /** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
27
+ * the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
28
+ * sparse-but-relevant lines, a busy burst is capped). The `line` renderer is the eviction cost
29
+ * basis: the budget must price what the fold actually renders, or it would systematically overrun. */
30
+ const BUFFER_MAX_CHARS = 4000;
31
+ /** How many buffered files and images (each, most recent first) a summon pulls in with the folded
32
+ * discussion — bounds the latency/token cost of "summarize the file from earlier" against a chatty
33
+ * group posting many attachments between summons. Skipped ones must be counted into the prompt
34
+ * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
35
+ * each channel's attachment collector caps against this. */
36
+ export const BUFFER_ATTACH_MAX = 3;
37
+ export function createContextBuffer(options) {
38
+ const { path, label, isEntry, line } = options;
39
+ const load = () => {
40
+ const raw = loadStateFile(path);
41
+ if (raw === undefined)
42
+ return new Map();
43
+ if (typeof raw === "object" &&
44
+ raw !== null &&
45
+ !Array.isArray(raw) &&
46
+ Object.values(raw).every((entries) => Array.isArray(entries) && entries.every(isEntry))) {
47
+ return new Map(Object.entries(raw));
48
+ }
49
+ log.warn(`${label} unexpected shape in ${path} — starting with an empty context buffer`);
50
+ return new Map();
51
+ };
52
+ const buffers = load();
53
+ const persist = () => saveStateFile(path, Object.fromEntries(buffers));
54
+ return {
55
+ push(placeKey, entry) {
56
+ const previous = buffers.get(placeKey);
57
+ const entries = previous ? [...previous] : [];
58
+ entries.push(entry);
59
+ let total = entries.reduce((sum, candidate) => sum + line(candidate).length + 1, 0);
60
+ while (entries.length > 1 && total > BUFFER_MAX_CHARS) {
61
+ const dropped = entries.shift();
62
+ if (dropped)
63
+ total -= line(dropped).length + 1;
64
+ }
65
+ buffers.set(placeKey, entries);
66
+ try {
67
+ persist();
68
+ }
69
+ catch (error) {
70
+ if (previous)
71
+ buffers.set(placeKey, previous);
72
+ else
73
+ buffers.delete(placeKey);
74
+ throw error;
75
+ }
76
+ },
77
+ peek(placeKey) {
78
+ const entries = buffers.get(placeKey) ?? [];
79
+ return { text: entries.map(line).join("\n"), consumed: [...entries] };
80
+ },
81
+ commit(placeKey, consumed) {
82
+ const entries = buffers.get(placeKey);
83
+ if (!entries)
84
+ return;
85
+ const remaining = entries.filter((entry) => !consumed.includes(entry));
86
+ if (remaining.length === 0)
87
+ buffers.delete(placeKey);
88
+ else
89
+ buffers.set(placeKey, remaining);
90
+ try {
91
+ persist();
92
+ }
93
+ catch (error) {
94
+ log.error(`${label} context-buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(error)}`);
95
+ }
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,28 @@
1
+ import { type SessionControl, type SessionEvent } from "../session.ts";
2
+ import type { Agent } from "../agent.ts";
3
+ import type { Routes } from "../host/node.ts";
4
+ /** The SSE payload: one control-plane event in its transport envelope. */
5
+ export interface WireEvent {
6
+ sessionId: string;
7
+ /** Serving-process incarnation (per `controlRoutes` call). A change means the server restarted:
8
+ * live continuity is gone — run the reconnect steps (entries cursor + state). */
9
+ epoch: string;
10
+ /** Per-connection monotonic counter. A gap means events were lost in transit on THIS connection. */
11
+ seq: number;
12
+ event: SessionEvent;
13
+ }
14
+ export interface ControlRoutesOptions {
15
+ /** Shared bearer secret, required on every route. Never optional: an unauthenticated
16
+ * remote-control endpoint must not be constructible by omission. */
17
+ token: string;
18
+ /** The DATA plane over the wire: when provided, `POST /control/invoke` mounts the standard
19
+ * invoke handler behind the same bearer token — a remote client (Web panel, desktop app,
20
+ * `attach`) can START runs regardless of which channels occupy `/invoke`. Same contract, same
21
+ * SSE event stream; disconnecting the response cancels the run (SPEC cancellation). */
22
+ agent?: Agent;
23
+ }
24
+ /**
25
+ * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
26
+ * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
27
+ */
28
+ export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
@@ -0,0 +1,214 @@
1
+ import { INVALID_COMMAND_CODE } from "../session.js";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { readBodyCapped } from "./body.js";
4
+ import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
+ import { text } from "./respond.js";
6
+ const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
7
+ // ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
8
+ // caps cannot drift apart): commands carry Prompts, which may ride base64 images.
9
+ const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
10
+ /**
11
+ * Parse-don't-validate at the wire: a remote client can send any JSON, and the hub's inner layers
12
+ * trust command shapes (a malformed `steer` would surface as an ENGINE failure misclassified as
13
+ * `run_command_failed`). Returns the typed command, or undefined for anything malformed — which
14
+ * answers protocol-level `invalid_command`, same responsibility as the hub's unknown-type default.
15
+ */
16
+ function parseWireCommand(raw) {
17
+ // COMPILE-TIME drift guard, variant level: this switch hand-mirrors the SessionCommand union,
18
+ // and a new variant added in session.ts would otherwise compile clean while the wire answers it
19
+ // `invalid_command` — silently breaking local/remote isomorphism. A new variant must break THIS
20
+ // line first, forcing the decision of how the wire carries it.
21
+ const _commandDriftGuard = {
22
+ steer: true,
23
+ follow_up: true,
24
+ abort: true,
25
+ compact: true,
26
+ set_model: true,
27
+ set_thinking: true,
28
+ };
29
+ void _commandDriftGuard;
30
+ if (typeof raw !== "object" || raw === null)
31
+ return undefined;
32
+ const c = raw;
33
+ const imageOk = (i) => typeof i === "object" &&
34
+ i !== null &&
35
+ typeof i.data === "string" &&
36
+ typeof i.mimeType === "string";
37
+ const promptOk = (p) => {
38
+ if (typeof p !== "object" || p === null)
39
+ return false;
40
+ if (typeof p.text !== "string")
41
+ return false;
42
+ const images = p.images;
43
+ // Element-level: `images: [42]` reaching the engine would resurface exactly the misclassified
44
+ // failure this parser exists to prevent (ImageRef shape from src/session.ts's Prompt).
45
+ return images === undefined || (Array.isArray(images) && images.every(imageOk));
46
+ };
47
+ // REBUILD, never pass raw through: "typed command out" must be construction, not assertion — a
48
+ // passed-through object would carry arbitrary extra keys into the engine.
49
+ const rebuildPrompt = (p) => {
50
+ const images = p.images;
51
+ return {
52
+ text: p.text,
53
+ ...(images ? { images: images.map((i) => ({ data: i.data, mimeType: i.mimeType })) } : {}),
54
+ };
55
+ };
56
+ // COMPILE-TIME drift guard: whitelist reconstruction silently strips any field it does not know.
57
+ // A new Prompt field must break THIS line (non-empty Exclude → {} unassignable), not vanish on
58
+ // the wire while the client believes it was sent.
59
+ const _promptDriftGuard = {};
60
+ void _promptDriftGuard;
61
+ // Same guard one level down: the image whitelist ({data, mimeType}) must break when ImageRef
62
+ // grows a field — top-level coverage alone would let element fields vanish silently.
63
+ const _imageDriftGuard = {};
64
+ void _imageDriftGuard;
65
+ switch (c.type) {
66
+ case "steer":
67
+ case "follow_up":
68
+ return promptOk(c.prompt) ? { type: c.type, prompt: rebuildPrompt(c.prompt) } : undefined;
69
+ case "abort":
70
+ return { type: "abort" };
71
+ case "compact":
72
+ return c.instructions === undefined || typeof c.instructions === "string"
73
+ ? { type: "compact", instructions: c.instructions }
74
+ : undefined;
75
+ case "set_model":
76
+ return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
77
+ case "set_thinking":
78
+ return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+ /**
84
+ * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
85
+ * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
86
+ */
87
+ export function controlRoutes(control, options) {
88
+ const { token } = options;
89
+ if (!token)
90
+ throw new Error("controlRoutes: a bearer token is required (empty tokens are not a mode)");
91
+ const epoch = crypto.randomUUID();
92
+ // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
93
+ // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
94
+ const expected = Buffer.from(`Bearer ${token}`);
95
+ const authed = (req) => {
96
+ const header = Buffer.from(req.headers.get("authorization") ?? "");
97
+ return header.length === expected.length && timingSafeEqual(header, expected);
98
+ };
99
+ const invokeHandler = options.agent ? createInvokeHandler(options.agent) : undefined;
100
+ /** Wrap a handler with auth + the session query param most routes need. */
101
+ const guard = (handler) => (req) => {
102
+ if (!authed(req))
103
+ return text("unauthorized\n", 401);
104
+ return handler(req, new URL(req.url));
105
+ };
106
+ // Extraction only — each route still answers its own 400 (the name must not imply enforcement).
107
+ const sessionParam = (url) => url.searchParams.get("session") ?? undefined;
108
+ return {
109
+ ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
110
+ "GET /control/capabilities": guard(() => json(control.capabilities())),
111
+ "GET /control/state": guard(async (_req, url) => {
112
+ const session = sessionParam(url);
113
+ if (!session)
114
+ return text("missing ?session\n", 400);
115
+ return json(await control.state(session));
116
+ }),
117
+ "GET /control/entries": guard(async (_req, url) => {
118
+ const session = sessionParam(url);
119
+ if (!session)
120
+ return text("missing ?session\n", 400);
121
+ const since = url.searchParams.get("since") ?? undefined;
122
+ return json(await control.entries(session, since !== undefined ? { since } : undefined));
123
+ }),
124
+ "POST /control/dispatch": guard(async (req) => {
125
+ const body = await readBodyCapped(req, DISPATCH_BODY_LIMIT);
126
+ // The 413 names the ceiling: the docs promise images on this plane, and an unexplained
127
+ // rejection would send a client author hunting everywhere but the cap.
128
+ if ("tooLarge" in body) {
129
+ // Derived from the constant — a hardcoded "1 MiB" would lie the day the cap changes.
130
+ return text(`body too large (limit ${MAX_BODY_BYTES >> 20} MiB — images count base64-inflated)\n`, 413);
131
+ }
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(body.text);
135
+ }
136
+ catch {
137
+ return text("invalid JSON\n", 400);
138
+ }
139
+ if (typeof parsed.session !== "string") {
140
+ return text("expected { session: string, command: SessionCommand }\n", 400);
141
+ }
142
+ const command = parseWireCommand(parsed.command);
143
+ if (!command) {
144
+ // Malformed shape = a protocol-level answer, mirrored from the hub's unknown-type default.
145
+ return json({
146
+ ok: false,
147
+ error: { code: INVALID_COMMAND_CODE, message: "malformed command", retryable: false },
148
+ });
149
+ }
150
+ // The result rides HTTP 200 either way: `ok: false` is a protocol-level answer (rejected
151
+ // before acceptance), not a transport failure.
152
+ return json(await control.dispatch(parsed.session, command));
153
+ }),
154
+ "GET /control/events": guard((_req, url) => {
155
+ const session = sessionParam(url);
156
+ if (!session)
157
+ return text("missing ?session\n", 400);
158
+ const iterator = control.events(session)[Symbol.asyncIterator]();
159
+ // EAGER registration: issue the first pull NOW, before the Response (and thus the client's
160
+ // fetch resolution) exists — hub subscription is registered synchronously inside next(), so
161
+ // "the client saw response headers" implies "events from that moment on will be delivered".
162
+ // Shrinks the subscribe/backfill race to network reordering instead of a full pull cycle.
163
+ let pending = iterator.next();
164
+ // Observed here so a client that disconnects BEFORE the first pull cannot turn a rejecting
165
+ // events iterator (this is the neutral contract face — any implementation may reject) into a
166
+ // process-killing unhandledRejection; awaiting `pending` at pull still surfaces the error.
167
+ pending.catch(() => { });
168
+ let seq = 0;
169
+ let stopHeartbeat = () => { };
170
+ const encoder = new TextEncoder();
171
+ const stream = new ReadableStream({
172
+ start(controller) {
173
+ stopHeartbeat = sseHeartbeat(controller);
174
+ },
175
+ async pull(controller) {
176
+ let next;
177
+ try {
178
+ next = await (pending ?? iterator.next());
179
+ }
180
+ catch (error) {
181
+ // A rejecting implementation (the neutral contract permits it) must not leak its
182
+ // subscription: an errored stream never gets cancel(), so the unsubscribe and the
183
+ // heartbeat teardown happen HERE.
184
+ stopHeartbeat();
185
+ void iterator.return?.(undefined)?.catch?.(() => { });
186
+ controller.error(error);
187
+ return;
188
+ }
189
+ pending = undefined;
190
+ if (next.done) {
191
+ stopHeartbeat();
192
+ controller.close();
193
+ return;
194
+ }
195
+ const wire = { sessionId: session, epoch, seq: seq++, event: next.value };
196
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(wire)}\n\n`));
197
+ },
198
+ cancel() {
199
+ stopHeartbeat();
200
+ // Same neutral-contract defense as the pull error path: a rejecting return() on client
201
+ // disconnect must not become a process-level unhandledRejection.
202
+ void iterator.return?.(undefined)?.catch?.(() => { });
203
+ },
204
+ });
205
+ return new Response(stream, {
206
+ headers: {
207
+ "content-type": "text/event-stream",
208
+ "cache-control": "no-cache",
209
+ connection: "keep-alive",
210
+ },
211
+ });
212
+ }),
213
+ };
214
+ }
@@ -11,7 +11,7 @@
11
11
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
12
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
13
  */
14
- import { truncateCodePointPrefix } from "./text.js";
14
+ import { truncateCodePointPrefix } from "../text.js";
15
15
  /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
16
16
  export const ANSWER_ELEMENT_ID = "answer";
17
17
  /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
+ * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
+ * concrete thread root) and folded into the next answered turn in that place.
6
+ */
7
+ import { type ContextBuffer } from "../context-buffer.ts";
8
+ import type { NormalizedFeishuMessage } from "./model.ts";
9
+ export interface FeishuBufferedResource {
10
+ messageId: string;
11
+ key: string;
12
+ name?: string;
13
+ }
14
+ export interface FeishuBufferEntry {
15
+ sender: string;
16
+ body: string;
17
+ messageId: string;
18
+ replyTo?: string;
19
+ files?: FeishuBufferedResource[];
20
+ images?: FeishuBufferedResource[];
21
+ }
22
+ /** A background resource carried into a later turn, with attribution for its prompt manifest. */
23
+ export interface FeishuBufferedRef extends FeishuBufferedResource {
24
+ from: string;
25
+ }
26
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
27
+ export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "rootId" | "threadId">): string;
28
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
29
+ export declare function feishuBufferText(text: string): string;
30
+ /**
31
+ * Select the most recent background resources, excluding resources already primary on this turn. A
32
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
33
+ */
34
+ export declare function collectFeishuBufferedAttachments(consumed: FeishuBufferEntry[], primary: {
35
+ files: FeishuBufferedResource[];
36
+ images: FeishuBufferedResource[];
37
+ }): {
38
+ files: FeishuBufferedRef[];
39
+ images: FeishuBufferedRef[];
40
+ skipped: number;
41
+ };
42
+ export type FeishuContextBuffer = ContextBuffer<FeishuBufferEntry>;
43
+ export declare function createFeishuContextBuffer(path: string, label: string): FeishuContextBuffer;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
+ * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
+ * concrete thread root) and folded into the next answered turn in that place.
6
+ */
7
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
8
+ function bufferLine(entry) {
9
+ const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
10
+ .filter(Boolean)
11
+ .join(", ");
12
+ return `${entry.sender} (${meta}): ${entry.body}`;
13
+ }
14
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
15
+ export function feishuBufferPlaceKey(conversation) {
16
+ const topic = conversation.rootId ?? conversation.threadId;
17
+ return topic ? `${conversation.chatId}:root:${topic}` : conversation.chatId;
18
+ }
19
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
20
+ export function feishuBufferText(text) {
21
+ return text.replace(/\s+/g, " ").trim().slice(0, 280);
22
+ }
23
+ function resourceIdentity(resource) {
24
+ return `${resource.messageId}\u0000${resource.key}`;
25
+ }
26
+ /**
27
+ * Select the most recent background resources, excluding resources already primary on this turn. A
28
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
29
+ */
30
+ export function collectFeishuBufferedAttachments(consumed, primary) {
31
+ const refs = (pick, primaryRefs) => {
32
+ const excluded = new Set(primaryRefs.map(resourceIdentity));
33
+ const seen = new Set();
34
+ const out = [];
35
+ for (const entry of consumed) {
36
+ for (const resource of pick(entry) ?? []) {
37
+ const identity = resourceIdentity(resource);
38
+ if (excluded.has(identity) || seen.has(identity))
39
+ continue;
40
+ seen.add(identity);
41
+ out.push({ ...resource, from: entry.sender });
42
+ }
43
+ }
44
+ return out;
45
+ };
46
+ const files = refs((entry) => entry.files, primary.files);
47
+ const images = refs((entry) => entry.images, primary.images);
48
+ return {
49
+ files: files.slice(-BUFFER_ATTACH_MAX),
50
+ images: images.slice(-BUFFER_ATTACH_MAX),
51
+ skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
52
+ };
53
+ }
54
+ function isResource(value) {
55
+ const resource = value;
56
+ return (typeof resource?.messageId === "string" &&
57
+ typeof resource.key === "string" &&
58
+ (resource.name === undefined || typeof resource.name === "string"));
59
+ }
60
+ function isEntry(value) {
61
+ const entry = value;
62
+ const resources = (candidate) => candidate === undefined || (Array.isArray(candidate) && candidate.every(isResource));
63
+ return (typeof entry?.sender === "string" &&
64
+ typeof entry.body === "string" &&
65
+ typeof entry.messageId === "string" &&
66
+ (entry.replyTo === undefined || typeof entry.replyTo === "string") &&
67
+ resources(entry.files) &&
68
+ resources(entry.images));
69
+ }
70
+ export function createFeishuContextBuffer(path, label) {
71
+ return createGenericContextBuffer({ path, label, isEntry, line: bufferLine });
72
+ }
@@ -1,7 +1,9 @@
1
1
  /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
2
2
  export declare function timingSafeEqualStr(a: string, b: string): boolean;
3
- /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
4
- * or a wrong key (bad padding) the caller turns that into a 4xx, never a silent drop. */
3
+ /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
4
+ * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
5
+ * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
6
+ * plaintext envelope, turning wrong-key garbage into a 4xx rather than a silent drop. */
5
7
  export declare function decryptEvent(encryptKey: string, encryptB64: string): string;
6
8
  /** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
7
9
  export declare function eventSignature(encryptKey: string, timestamp: string, nonce: string, rawBody: string): string;
@@ -21,8 +21,10 @@ export function timingSafeEqualStr(a, b) {
21
21
  const bb = Buffer.from(b);
22
22
  return ab.length === bb.length && timingSafeEqual(ab, bb);
23
23
  }
24
- /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed input
25
- * or a wrong key (bad padding) the caller turns that into a 4xx, never a silent drop. */
24
+ /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
25
+ * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
26
+ * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
27
+ * plaintext envelope, turning wrong-key garbage into a 4xx rather than a silent drop. */
26
28
  export function decryptEvent(encryptKey, encryptB64) {
27
29
  const key = createHash("sha256").update(encryptKey, "utf8").digest();
28
30
  const buf = Buffer.from(encryptB64, "base64");