@fastagent-sh/fastagent 0.12.1 → 0.14.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 (151) hide show
  1. package/README.md +6 -4
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  9. package/dist/channels/feishu/context-buffer.js +133 -0
  10. package/dist/channels/feishu/crypto.d.ts +15 -0
  11. package/dist/channels/feishu/crypto.js +43 -0
  12. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  13. package/dist/channels/feishu/feishu-api.js +325 -0
  14. package/dist/channels/feishu/feishu.d.ts +46 -0
  15. package/dist/channels/feishu/feishu.js +472 -0
  16. package/dist/channels/feishu/invoke-turn.d.ts +65 -0
  17. package/dist/channels/feishu/invoke-turn.js +157 -0
  18. package/dist/channels/feishu/model.d.ts +97 -0
  19. package/dist/channels/feishu/model.js +9 -0
  20. package/dist/channels/feishu/normalize.d.ts +22 -0
  21. package/dist/channels/feishu/normalize.js +132 -0
  22. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  23. package/dist/channels/feishu/owned-threads.js +47 -0
  24. package/dist/channels/feishu/parse.d.ts +43 -0
  25. package/dist/channels/feishu/parse.js +65 -0
  26. package/dist/channels/feishu/preview.d.ts +36 -0
  27. package/dist/channels/feishu/preview.js +387 -0
  28. package/dist/channels/feishu/register-app.d.ts +70 -0
  29. package/dist/channels/feishu/register-app.js +141 -0
  30. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  31. package/dist/channels/feishu/register-webhook.js +106 -0
  32. package/dist/channels/feishu/scaffold/channel.ts +41 -0
  33. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  34. package/dist/channels/feishu/seen.d.ts +5 -0
  35. package/dist/channels/feishu/seen.js +47 -0
  36. package/dist/channels/feishu/text.d.ts +13 -0
  37. package/dist/channels/feishu/text.js +63 -0
  38. package/dist/channels/lark/lark.d.ts +15 -0
  39. package/dist/channels/lark/lark.js +10 -0
  40. package/dist/channels/lark/onboard.d.ts +39 -0
  41. package/dist/channels/lark/onboard.js +58 -0
  42. package/dist/channels/lark/scaffold/channel.ts +39 -0
  43. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  44. package/dist/channels/registration.d.ts +15 -0
  45. package/dist/channels/registration.js +1 -0
  46. package/dist/channels/{telegram/state.js → state.js} +6 -4
  47. package/dist/channels/telegram/context-buffer.js +1 -1
  48. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  49. package/dist/channels/telegram/register-webhook.js +17 -26
  50. package/dist/channels/telegram/telegram.js +2 -2
  51. package/dist/channels/telegram/turn-store.d.ts +8 -21
  52. package/dist/channels/telegram/turn-store.js +11 -130
  53. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  54. package/dist/channels/turn-store.d.ts +42 -0
  55. package/dist/channels/turn-store.js +139 -0
  56. package/dist/channels/wait-health.d.ts +6 -0
  57. package/dist/channels/wait-health.js +27 -0
  58. package/dist/cli/commands/add.d.ts +9 -0
  59. package/dist/cli/commands/add.js +142 -0
  60. package/dist/cli/commands/chat.d.ts +3 -0
  61. package/dist/cli/commands/chat.js +16 -0
  62. package/dist/cli/commands/deploy.d.ts +13 -0
  63. package/dist/cli/commands/deploy.js +338 -0
  64. package/dist/cli/commands/dev.d.ts +11 -0
  65. package/dist/cli/commands/dev.js +76 -0
  66. package/dist/cli/commands/fire.d.ts +7 -0
  67. package/dist/cli/commands/fire.js +45 -0
  68. package/dist/cli/commands/info.d.ts +7 -0
  69. package/dist/cli/commands/info.js +108 -0
  70. package/dist/cli/commands/init.d.ts +8 -0
  71. package/dist/cli/commands/init.js +81 -0
  72. package/dist/cli/commands/invoke.d.ts +7 -0
  73. package/dist/cli/commands/invoke.js +29 -0
  74. package/dist/cli/commands/login.d.ts +6 -0
  75. package/dist/cli/commands/login.js +63 -0
  76. package/dist/cli/commands/models.d.ts +1 -0
  77. package/dist/cli/commands/models.js +15 -0
  78. package/dist/cli/commands/schedule.d.ts +12 -0
  79. package/dist/cli/commands/schedule.js +89 -0
  80. package/dist/cli/commands/start.d.ts +10 -0
  81. package/dist/cli/commands/start.js +90 -0
  82. package/dist/cli/commands/tool.d.ts +1 -0
  83. package/dist/cli/commands/tool.js +37 -0
  84. package/dist/cli/fail.d.ts +19 -0
  85. package/dist/cli/fail.js +32 -0
  86. package/dist/cli/kernel.d.ts +89 -0
  87. package/dist/cli/kernel.js +190 -0
  88. package/dist/cli/program.d.ts +11 -0
  89. package/dist/cli/program.js +421 -0
  90. package/dist/cli/serve.d.ts +28 -0
  91. package/dist/cli/serve.js +90 -0
  92. package/dist/cli/shared.d.ts +24 -0
  93. package/dist/cli/shared.js +116 -0
  94. package/dist/cli-add-feishu.d.ts +8 -0
  95. package/dist/cli-add-feishu.js +223 -0
  96. package/dist/cli.js +8 -1284
  97. package/dist/deploy/container.js +10 -6
  98. package/dist/deploy/docker/plan.d.ts +45 -0
  99. package/dist/deploy/docker/plan.js +139 -0
  100. package/dist/deploy/docker/run.d.ts +40 -0
  101. package/dist/deploy/docker/run.js +126 -0
  102. package/dist/deploy/fly/plan.d.ts +1 -1
  103. package/dist/deploy/fly/plan.js +15 -4
  104. package/dist/deploy/fly/run.d.ts +7 -4
  105. package/dist/deploy/fly/run.js +26 -5
  106. package/dist/deploy/preflight.js +4 -3
  107. package/dist/deploy/railway/plan.d.ts +1 -1
  108. package/dist/deploy/railway/plan.js +17 -5
  109. package/dist/deploy/railway/run.d.ts +6 -3
  110. package/dist/deploy/railway/run.js +26 -4
  111. package/dist/deploy/registration-gate.d.ts +20 -0
  112. package/dist/deploy/registration-gate.js +20 -0
  113. package/dist/deploy/runner.d.ts +4 -1
  114. package/dist/deploy/runner.js +1 -0
  115. package/dist/deploy/secrets.d.ts +10 -9
  116. package/dist/deploy/secrets.js +15 -14
  117. package/dist/dev-supervisor.js +2 -1
  118. package/dist/engines/pi/auth.js +160 -46
  119. package/dist/engines/pi/chat.js +80 -7
  120. package/dist/engines/pi/config.d.ts +12 -3
  121. package/dist/engines/pi/config.js +16 -1
  122. package/dist/engines/pi/create.d.ts +14 -6
  123. package/dist/engines/pi/create.js +52 -16
  124. package/dist/engines/pi/harness.d.ts +16 -1
  125. package/dist/engines/pi/harness.js +77 -1
  126. package/dist/engines/pi/invoke.d.ts +1 -1
  127. package/dist/engines/pi/invoke.js +37 -2
  128. package/dist/engines/pi/login.js +1 -1
  129. package/dist/engines/pi/search-tools.d.ts +10 -0
  130. package/dist/engines/pi/search-tools.js +138 -0
  131. package/dist/engines/pi/tool-context.d.ts +28 -0
  132. package/dist/engines/pi/tool-context.js +8 -0
  133. package/dist/engines/pi/tool.d.ts +32 -1
  134. package/dist/engines/pi/tool.js +42 -1
  135. package/dist/engines/pi/workspace.d.ts +4 -1
  136. package/dist/engines/pi/workspace.js +3 -1
  137. package/dist/feishu.d.ts +2 -0
  138. package/dist/feishu.js +2 -0
  139. package/dist/lark.d.ts +3 -0
  140. package/dist/lark.js +3 -0
  141. package/dist/open-url.d.ts +2 -0
  142. package/dist/open-url.js +6 -0
  143. package/dist/pi.d.ts +2 -1
  144. package/dist/scaffold/add-channel.d.ts +9 -5
  145. package/dist/scaffold/add-channel.js +75 -7
  146. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  147. package/dist/tunnel.d.ts +9 -6
  148. package/dist/tunnel.js +48 -31
  149. package/package.json +18 -4
  150. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  151. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
package/README.md CHANGED
@@ -39,7 +39,7 @@ FastAgent is the missing bridge from local agent directory to live service.
39
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.
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/) | First-party channel guides |
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 |
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 |
@@ -170,7 +170,9 @@ Subpath exports:
170
170
  - `@fastagent-sh/fastagent/core` — engine-neutral contract, consumption helpers, channel/host kit, schedules;
171
171
  - `@fastagent-sh/fastagent/pi` — the pi reference implementation;
172
172
  - `@fastagent-sh/fastagent/github` — GitHub webhook channel;
173
- - `@fastagent-sh/fastagent/telegram` — Telegram bot channel.
173
+ - `@fastagent-sh/fastagent/telegram` — Telegram bot channel;
174
+ - `@fastagent-sh/fastagent/feishu` — canonical Feishu bot channel (飞书, open.feishu.cn);
175
+ - `@fastagent-sh/fastagent/lark` — Lark-international compatibility profile over the Feishu engine.
174
176
 
175
177
  ## Repository layout
176
178
 
@@ -196,7 +198,7 @@ The neutral contract leaves room for capabilities that are not complete product
196
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.
197
199
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
198
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.
199
- - **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.
200
202
 
201
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.
202
204
 
@@ -0,0 +1,42 @@
1
+ import type { FeishuCloudKind } from "./cloud.ts";
2
+ /** What the bootstrap needs from the API pipeline (subset of FeishuApi; injectable in tests). */
3
+ interface EventSubscriptionPatcher {
4
+ updateEventSubscription(appId: string, cfg: {
5
+ subscriptionType: "webhook";
6
+ requestUrl: string;
7
+ }): Promise<void>;
8
+ }
9
+ export interface FeishuBootstrapTokenOptions {
10
+ api: EventSubscriptionPatcher;
11
+ appId: string;
12
+ /** Canonical Feishu by default; Lark compatibility binds its own route explicitly. */
13
+ kind?: FeishuCloudKind;
14
+ /** Expose local `port` on a public URL (production: startCloudflareTunnel; tests: loopback). */
15
+ startTunnel: (port: number) => Promise<{
16
+ url: string;
17
+ close(): void;
18
+ } | undefined>;
19
+ /** Budget for the whole capture (the challenge normally lands within the PATCH round-trip). */
20
+ timeoutMs?: number;
21
+ /** PATCH attempts × delay — the PATCH is the one and only readiness probe (see below). */
22
+ patchAttempts?: number;
23
+ patchRetryMs?: number;
24
+ /** Retry classifier. Default: retry every PATCH failure (Feishu edge warm-up compatibility).
25
+ * Lark onboarding rejects a definitive config-route 404 immediately so it can fall back by hand. */
26
+ shouldRetryPatch?: (error: unknown) => boolean;
27
+ /** Progress hooks: production prints the assigned URL/retries; tests keep transport IO injected. */
28
+ onTunnelReady?: (url: string) => void;
29
+ onPatchRetry?: (info: {
30
+ error: unknown;
31
+ attempt: number;
32
+ attempts: number;
33
+ retryMs: number;
34
+ }) => void;
35
+ }
36
+ /**
37
+ * Run the bootstrap (module header). Resolves with the app's Verification Token; rejects with a
38
+ * plain, actionable Error (tunnel unavailable, PATCH refused, challenge never arrived) — the caller
39
+ * degrades to the manual console instruction.
40
+ */
41
+ export declare function bootstrapFeishuVerificationToken(options: FeishuBootstrapTokenOptions): Promise<string>;
42
+ export {};
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Verification-Token bootstrap — the token has NO read API (field-tested 2026-07: the v6 app detail
3
+ * never returns `encryption`, and the v7 config route only supports PATCH). The platform's only
4
+ * programmatic delivery is the `url_verification` challenge it POSTs to the request URL while an
5
+ * event-subscription PATCH is verified — the challenge body carries `token`, the app's Verification
6
+ * Token. So `add feishu`'s create flow finishes the job by: standing up a throwaway local responder,
7
+ * exposing it on an ephemeral quick tunnel, PATCHing the app's event subscription at it, capturing
8
+ * the token from the challenge, and tearing everything down. `dev --tunnel` / `deploy … --run`
9
+ * re-PATCH the request URL onto the real server later (the token is app-level and survives that).
10
+ *
11
+ * Security: the serving path keeps its strict non-empty-token requirement — no bootstrap mode there.
12
+ * The throwaway URL is random (unguessable), lives for seconds, and only the FIRST challenge is
13
+ * accepted, immediately after our own credential-authenticated PATCH.
14
+ */
15
+ import { createServer } from "node:http";
16
+ /**
17
+ * Run the bootstrap (module header). Resolves with the app's Verification Token; rejects with a
18
+ * plain, actionable Error (tunnel unavailable, PATCH refused, challenge never arrived) — the caller
19
+ * degrades to the manual console instruction.
20
+ */
21
+ export async function bootstrapFeishuVerificationToken(options) {
22
+ let capturedToken;
23
+ const token = new Promise((resolve) => {
24
+ capturedToken = resolve;
25
+ });
26
+ // The throwaway responder: answers /health (diagnostics) and the url_verification challenge
27
+ // (echo `challenge` back), capturing `token` from the FIRST challenge only.
28
+ const server = createServer((req, res) => {
29
+ if (req.method === "GET") {
30
+ res.end("ok");
31
+ return;
32
+ }
33
+ let body = "";
34
+ req.on("data", (d) => {
35
+ body += String(d);
36
+ });
37
+ req.on("end", () => {
38
+ try {
39
+ const j = JSON.parse(body);
40
+ if (j.type === "url_verification" && typeof j.challenge === "string" && typeof j.token === "string") {
41
+ capturedToken?.(j.token);
42
+ capturedToken = undefined; // first challenge only
43
+ res.setHeader("content-type", "application/json");
44
+ res.end(JSON.stringify({ challenge: j.challenge }));
45
+ return;
46
+ }
47
+ }
48
+ catch {
49
+ /* not the challenge — fall through to the empty ACK */
50
+ }
51
+ res.end("{}");
52
+ });
53
+ });
54
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
55
+ const address = server.address();
56
+ const port = typeof address === "object" && address !== null ? address.port : 0;
57
+ const tunnel = await options.startTunnel(port);
58
+ try {
59
+ if (!tunnel)
60
+ throw new Error("no tunnel came up (is cloudflared installed?)");
61
+ options.onTunnelReady?.(tunnel.url);
62
+ // Do NOT health-poll the edge here: "can WE reach it" says nothing about platform→edge, the path
63
+ // the challenge uses (field-tested: local negative-DNS caching failed while that path worked).
64
+ // PATCH immediately; it is the real probe because success requires the challenge round-trip.
65
+ const attempts = options.patchAttempts ?? 8;
66
+ for (let attempt = 1;; attempt++) {
67
+ try {
68
+ await options.api.updateEventSubscription(options.appId, {
69
+ subscriptionType: "webhook",
70
+ requestUrl: `${tunnel.url}/${options.kind ?? "feishu"}`,
71
+ });
72
+ break;
73
+ }
74
+ catch (e) {
75
+ if (attempt >= attempts || options.shouldRetryPatch?.(e) === false)
76
+ throw e;
77
+ const retryMs = options.patchRetryMs ?? 10_000;
78
+ options.onPatchRetry?.({ error: e, attempt, attempts, retryMs });
79
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
80
+ }
81
+ }
82
+ const winner = await Promise.race([
83
+ token,
84
+ new Promise((resolve) => setTimeout(() => resolve(undefined), options.timeoutMs ?? 30_000)),
85
+ ]);
86
+ if (winner === undefined)
87
+ throw new Error("the registration challenge never arrived");
88
+ return winner;
89
+ }
90
+ finally {
91
+ tunnel?.close();
92
+ server.close();
93
+ }
94
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Card JSON builders — PURE: the streaming-card entity (JSON 2.0) the live preview creates, the settled
3
+ * final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
4
+ * the card DSL is data-in → string-out and testable without the pump.
5
+ *
6
+ * The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
+ * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
+ * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
+ * element, `streaming_mode` off — one write flips content and mode together.
10
+ *
11
+ * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
+ * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
+ */
14
+ /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
15
+ export declare const ANSWER_ELEMENT_ID = "answer";
16
+ /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
17
+ export declare const CARD_MARKDOWN_MAX_BYTES: number;
18
+ /**
19
+ * The answer's first line as plain text — what the chat list and the push notification show for the
20
+ * settled card (`config.summary.content`). Without it a card message previews as a generic "[Card]"
21
+ * placeholder: the user's notification would never carry the actual answer. Markdown is stripped
22
+ * lightly (this is a one-line teaser, not a renderer): fenced code dropped, links/images → their text,
23
+ * emphasis/heading/list markers removed.
24
+ */
25
+ export declare function cardSummary(markdown: string): string;
26
+ /** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
27
+ export declare function streamingCardJson(initial: string): string;
28
+ /** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
29
+ * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
30
+ export declare function finalCardJson(markdown: string): string;
31
+ /** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
32
+ export declare function cardEntityContent(cardId: string): string;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Card JSON builders — PURE: the streaming-card entity (JSON 2.0) the live preview creates, the settled
3
+ * final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
4
+ * the card DSL is data-in → string-out and testable without the pump.
5
+ *
6
+ * The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
+ * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
+ * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
+ * element, `streaming_mode` off — one write flips content and mode together.
10
+ *
11
+ * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
+ * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
+ */
14
+ import { truncateCodePointPrefix } from "./text.js";
15
+ /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
16
+ export const ANSWER_ELEMENT_ID = "answer";
17
+ /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
18
+ export const CARD_MARKDOWN_MAX_BYTES = 20 * 1024;
19
+ /** Character budget for the settled card's summary (the chat-list / push-notification preview). */
20
+ const SUMMARY_MAX_CHARS = 60;
21
+ /**
22
+ * The answer's first line as plain text — what the chat list and the push notification show for the
23
+ * settled card (`config.summary.content`). Without it a card message previews as a generic "[Card]"
24
+ * placeholder: the user's notification would never carry the actual answer. Markdown is stripped
25
+ * lightly (this is a one-line teaser, not a renderer): fenced code dropped, links/images → their text,
26
+ * emphasis/heading/list markers removed.
27
+ */
28
+ export function cardSummary(markdown) {
29
+ const line = markdown
30
+ .replace(/```[\s\S]*?(```|$)/g, " ") // fenced code is never a readable one-line preview
31
+ .split("\n")
32
+ .map((l) => l
33
+ .replace(/^\s{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])\s+/, "") // heading / quote / list markers
34
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // images → alt text
35
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → link text
36
+ .replace(/[*_~`]/g, "")
37
+ .trim())
38
+ .find((l) => l !== "") ?? "";
39
+ return truncateCodePointPrefix(line, SUMMARY_MAX_CHARS);
40
+ }
41
+ function cardJson(markdown, streaming, summary) {
42
+ return JSON.stringify({
43
+ schema: "2.0",
44
+ config: {
45
+ streaming_mode: streaming,
46
+ update_multi: true,
47
+ // Only the settled card sets a summary — while streaming, the platform's default (a localized
48
+ // "[Generating…]") is better than any fixed text we could pin.
49
+ ...(summary ? { summary: { content: summary } } : {}),
50
+ },
51
+ body: { elements: [{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }] },
52
+ });
53
+ }
54
+ /** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
55
+ export function streamingCardJson(initial) {
56
+ return cardJson(initial, true);
57
+ }
58
+ /** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
59
+ * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
60
+ export function finalCardJson(markdown) {
61
+ return cardJson(markdown, false, cardSummary(markdown));
62
+ }
63
+ /** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
64
+ export function cardEntityContent(cardId) {
65
+ return JSON.stringify({ type: "card", data: { card_id: cardId } });
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Feishu is the canonical implementation. Lark international speaks the same protocol but trails the
3
+ * primary cloud in control-plane capabilities, so it binds the Feishu engine through this explicit
4
+ * compatibility profile instead of becoming the engine's naming/design center.
5
+ */
6
+ export type FeishuCloudKind = "feishu" | "lark";
7
+ export interface FeishuCloudProfile {
8
+ kind: FeishuCloudKind;
9
+ envPrefix: "FEISHU" | "LARK";
10
+ apiBase: string;
11
+ capabilities: {
12
+ appCreation: "scan-to-create" | "guided-console";
13
+ };
14
+ }
15
+ export declare const FEISHU_CLOUD: FeishuCloudProfile;
16
+ export declare const LARK_COMPAT_CLOUD: FeishuCloudProfile;
17
+ export declare function cloudFor(kind: FeishuCloudKind): FeishuCloudProfile;
@@ -0,0 +1,19 @@
1
+ export const FEISHU_CLOUD = {
2
+ kind: "feishu",
3
+ envPrefix: "FEISHU",
4
+ apiBase: "https://open.feishu.cn",
5
+ capabilities: {
6
+ appCreation: "scan-to-create",
7
+ },
8
+ };
9
+ export const LARK_COMPAT_CLOUD = {
10
+ kind: "lark",
11
+ envPrefix: "LARK",
12
+ apiBase: "https://open.larksuite.com",
13
+ capabilities: {
14
+ appCreation: "guided-console",
15
+ },
16
+ };
17
+ export function cloudFor(kind) {
18
+ return kind === "feishu" ? FEISHU_CLOUD : LARK_COMPAT_CLOUD;
19
+ }
@@ -0,0 +1,46 @@
1
+ import type { NormalizedFeishuMessage } from "./model.ts";
2
+ export interface FeishuBufferedResource {
3
+ messageId: string;
4
+ key: string;
5
+ name?: string;
6
+ }
7
+ export interface FeishuBufferEntry {
8
+ sender: string;
9
+ body: string;
10
+ messageId: string;
11
+ replyTo?: string;
12
+ files?: FeishuBufferedResource[];
13
+ images?: FeishuBufferedResource[];
14
+ }
15
+ /** A background resource carried into a later turn, with attribution for its prompt manifest. */
16
+ export interface FeishuBufferedRef extends FeishuBufferedResource {
17
+ from: string;
18
+ }
19
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
20
+ export declare function feishuBufferPlaceKey(conversation: Pick<NormalizedFeishuMessage["conversation"], "chatId" | "rootId" | "threadId">): string;
21
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
22
+ export declare function feishuBufferText(text: string): string;
23
+ /**
24
+ * Select the most recent background resources, excluding resources already primary on this turn. A
25
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
26
+ */
27
+ export declare function collectFeishuBufferedAttachments(consumed: FeishuBufferEntry[], primary: {
28
+ files: FeishuBufferedResource[];
29
+ images: FeishuBufferedResource[];
30
+ }): {
31
+ files: FeishuBufferedRef[];
32
+ images: FeishuBufferedRef[];
33
+ skipped: number;
34
+ };
35
+ export interface FeishuContextBuffer {
36
+ /** Persist before webhook ACK. A failed write throws and rolls memory back for safe redelivery. */
37
+ push(placeKey: string, entry: FeishuBufferEntry): void;
38
+ /** Render and snapshot without clearing. */
39
+ peek(placeKey: string): {
40
+ text: string;
41
+ consumed: FeishuBufferEntry[];
42
+ };
43
+ /** Remove only the consumed snapshot after `completed`; a post-ACK write failure is logged. */
44
+ commit(placeKey: string, consumed: FeishuBufferEntry[]): void;
45
+ }
46
+ export declare function createFeishuContextBuffer(path: string, label: string): FeishuContextBuffer;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Durable context buffer for human group messages that do NOT currently summon the Feishu/Lark Agent.
3
+ * Entries are bucketed by conversation place (main chat, or one concrete thread root) and folded into
4
+ * the next answered turn in that place. The consume protocol mirrors Telegram: peek without clearing,
5
+ * then commit exactly that snapshot only after the Agent emits `completed`.
6
+ */
7
+ import { log } from "../../log.js";
8
+ import { loadStateFile, saveStateFile } from "../state.js";
9
+ const BUFFER_MAX_CHARS = 4000;
10
+ const BUFFER_ATTACH_MAX = 3;
11
+ function bufferLine(entry) {
12
+ const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
13
+ .filter(Boolean)
14
+ .join(", ");
15
+ return `${entry.sender} (${meta}): ${entry.body}`;
16
+ }
17
+ /** Main-chat discussion stays in the chat bucket; a topic uses its stable root id (thread id fallback). */
18
+ export function feishuBufferPlaceKey(conversation) {
19
+ const topic = conversation.rootId ?? conversation.threadId;
20
+ return topic ? `${conversation.chatId}:root:${topic}` : conversation.chatId;
21
+ }
22
+ /** One-line, bounded background text. Resource-only messages already carry a visible decoder marker. */
23
+ export function feishuBufferText(text) {
24
+ return text.replace(/\s+/g, " ").trim().slice(0, 280);
25
+ }
26
+ function resourceIdentity(resource) {
27
+ return `${resource.messageId}\u0000${resource.key}`;
28
+ }
29
+ /**
30
+ * Select the most recent background resources, excluding resources already primary on this turn. A
31
+ * resource is message-scoped in Feishu/Lark, so identity is `message_id + key`, never the bare key.
32
+ */
33
+ export function collectFeishuBufferedAttachments(consumed, primary) {
34
+ const refs = (pick, primaryRefs) => {
35
+ const excluded = new Set(primaryRefs.map(resourceIdentity));
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const entry of consumed) {
39
+ for (const resource of pick(entry) ?? []) {
40
+ const identity = resourceIdentity(resource);
41
+ if (excluded.has(identity) || seen.has(identity))
42
+ continue;
43
+ seen.add(identity);
44
+ out.push({ ...resource, from: entry.sender });
45
+ }
46
+ }
47
+ return out;
48
+ };
49
+ const files = refs((entry) => entry.files, primary.files);
50
+ const images = refs((entry) => entry.images, primary.images);
51
+ return {
52
+ files: files.slice(-BUFFER_ATTACH_MAX),
53
+ images: images.slice(-BUFFER_ATTACH_MAX),
54
+ skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) + Math.max(0, images.length - BUFFER_ATTACH_MAX),
55
+ };
56
+ }
57
+ function isResource(value) {
58
+ const resource = value;
59
+ return (typeof resource?.messageId === "string" &&
60
+ typeof resource.key === "string" &&
61
+ (resource.name === undefined || typeof resource.name === "string"));
62
+ }
63
+ function isEntry(value) {
64
+ const entry = value;
65
+ const resources = (candidate) => candidate === undefined || (Array.isArray(candidate) && candidate.every(isResource));
66
+ return (typeof entry?.sender === "string" &&
67
+ typeof entry.body === "string" &&
68
+ typeof entry.messageId === "string" &&
69
+ (entry.replyTo === undefined || typeof entry.replyTo === "string") &&
70
+ resources(entry.files) &&
71
+ resources(entry.images));
72
+ }
73
+ export function createFeishuContextBuffer(path, label) {
74
+ const load = () => {
75
+ const raw = loadStateFile(path);
76
+ if (raw === undefined)
77
+ return new Map();
78
+ if (typeof raw === "object" &&
79
+ raw !== null &&
80
+ !Array.isArray(raw) &&
81
+ Object.values(raw).every((entries) => Array.isArray(entries) && entries.every(isEntry))) {
82
+ return new Map(Object.entries(raw));
83
+ }
84
+ log.warn(`${label} unexpected shape in ${path} — starting with an empty context buffer`);
85
+ return new Map();
86
+ };
87
+ const buffers = load();
88
+ const persist = () => saveStateFile(path, Object.fromEntries(buffers));
89
+ return {
90
+ push(placeKey, entry) {
91
+ const previous = buffers.get(placeKey);
92
+ const entries = previous ? [...previous] : [];
93
+ entries.push(entry);
94
+ let total = entries.reduce((sum, candidate) => sum + bufferLine(candidate).length + 1, 0);
95
+ while (entries.length > 1 && total > BUFFER_MAX_CHARS) {
96
+ const dropped = entries.shift();
97
+ if (dropped)
98
+ total -= bufferLine(dropped).length + 1;
99
+ }
100
+ buffers.set(placeKey, entries);
101
+ try {
102
+ persist();
103
+ }
104
+ catch (error) {
105
+ if (previous)
106
+ buffers.set(placeKey, previous);
107
+ else
108
+ buffers.delete(placeKey);
109
+ throw error;
110
+ }
111
+ },
112
+ peek(placeKey) {
113
+ const entries = buffers.get(placeKey) ?? [];
114
+ return { text: entries.map(bufferLine).join("\n"), consumed: [...entries] };
115
+ },
116
+ commit(placeKey, consumed) {
117
+ const entries = buffers.get(placeKey);
118
+ if (!entries)
119
+ return;
120
+ const remaining = entries.filter((entry) => !consumed.includes(entry));
121
+ if (remaining.length === 0)
122
+ buffers.delete(placeKey);
123
+ else
124
+ buffers.set(placeKey, remaining);
125
+ try {
126
+ persist();
127
+ }
128
+ catch (error) {
129
+ log.error(`${label} context-buffer write failed post-ACK (a restart may re-fold answered discussion): ${String(error)}`);
130
+ }
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,15 @@
1
+ /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
2
+ export declare function timingSafeEqualStr(a: string, b: string): boolean;
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. */
7
+ export declare function decryptEvent(encryptKey: string, encryptB64: string): string;
8
+ /** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
9
+ export declare function eventSignature(encryptKey: string, timestamp: string, nonce: string, rawBody: string): string;
10
+ /** Whether a request's signature headers verify against the raw body (constant-time). */
11
+ export declare function verifySignature(encryptKey: string, headers: {
12
+ timestamp: string;
13
+ nonce: string;
14
+ signature: string;
15
+ }, rawBody: string): boolean;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Canonical Feishu webhook security (reused by Lark compatibility) — PURE: AES event decryption and request
3
+ * signature, exactly as the open platform defines them. When an Encrypt Key is configured in the
4
+ * developer console, every event arrives as `{"encrypt": "<base64>"}` with signature headers; without
5
+ * one, events arrive in plaintext and carry only the verification token in the body. feishu.ts owns the
6
+ * fail-closed policy (which checks run when); this module owns the math.
7
+ *
8
+ * - Decryption: AES-256-CBC. The key is sha256(encryptKey); the base64 payload is IV (16 bytes) ‖
9
+ * ciphertext; the plaintext is the event JSON (PKCS#7 padding handled by the cipher).
10
+ * - Signature: `X-Lark-Signature = sha256(timestamp + nonce + encryptKey + rawBody)` hex, where
11
+ * rawBody is the VERBATIM request body (the encrypted form) — computed over bytes, so the caller
12
+ * must pass the raw text, never a re-serialization.
13
+ *
14
+ * Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
15
+ * token check leaks a timing signal.
16
+ */
17
+ import { createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
18
+ /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
19
+ export function timingSafeEqualStr(a, b) {
20
+ const ab = Buffer.from(a);
21
+ const bb = Buffer.from(b);
22
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
23
+ }
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. */
28
+ export function decryptEvent(encryptKey, encryptB64) {
29
+ const key = createHash("sha256").update(encryptKey, "utf8").digest();
30
+ const buf = Buffer.from(encryptB64, "base64");
31
+ if (buf.length <= 16)
32
+ throw new Error("encrypted event payload is too short to carry an IV + ciphertext");
33
+ const decipher = createDecipheriv("aes-256-cbc", key, buf.subarray(0, 16));
34
+ return Buffer.concat([decipher.update(buf.subarray(16)), decipher.final()]).toString("utf8");
35
+ }
36
+ /** The expected `X-Lark-Signature` for a request: sha256(timestamp + nonce + encryptKey + rawBody) hex. */
37
+ export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
38
+ return createHash("sha256").update(`${timestamp}${nonce}${encryptKey}${rawBody}`, "utf8").digest("hex");
39
+ }
40
+ /** Whether a request's signature headers verify against the raw body (constant-time). */
41
+ export function verifySignature(encryptKey, headers, rawBody) {
42
+ return timingSafeEqualStr(eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody), headers.signature);
43
+ }