@fastagent-sh/fastagent 0.12.0 → 0.13.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 (82) hide show
  1. package/README.md +42 -36
  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/crypto.d.ts +13 -0
  9. package/dist/channels/feishu/crypto.js +41 -0
  10. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  11. package/dist/channels/feishu/feishu-api.js +325 -0
  12. package/dist/channels/feishu/feishu.d.ts +36 -0
  13. package/dist/channels/feishu/feishu.js +359 -0
  14. package/dist/channels/feishu/invoke-turn.d.ts +59 -0
  15. package/dist/channels/feishu/invoke-turn.js +106 -0
  16. package/dist/channels/feishu/parse.d.ts +125 -0
  17. package/dist/channels/feishu/parse.js +175 -0
  18. package/dist/channels/feishu/preview.d.ts +36 -0
  19. package/dist/channels/feishu/preview.js +387 -0
  20. package/dist/channels/feishu/register-app.d.ts +70 -0
  21. package/dist/channels/feishu/register-app.js +141 -0
  22. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  23. package/dist/channels/feishu/register-webhook.js +106 -0
  24. package/dist/channels/feishu/scaffold/channel.ts +34 -0
  25. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  26. package/dist/channels/feishu/seen.d.ts +5 -0
  27. package/dist/channels/feishu/seen.js +47 -0
  28. package/dist/channels/feishu/text.d.ts +13 -0
  29. package/dist/channels/feishu/text.js +63 -0
  30. package/dist/channels/lark/lark.d.ts +15 -0
  31. package/dist/channels/lark/lark.js +10 -0
  32. package/dist/channels/lark/onboard.d.ts +39 -0
  33. package/dist/channels/lark/onboard.js +58 -0
  34. package/dist/channels/lark/scaffold/channel.ts +32 -0
  35. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  36. package/dist/channels/registration.d.ts +15 -0
  37. package/dist/channels/registration.js +1 -0
  38. package/dist/channels/{telegram/state.js → state.js} +6 -4
  39. package/dist/channels/telegram/context-buffer.js +1 -1
  40. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  41. package/dist/channels/telegram/register-webhook.js +17 -26
  42. package/dist/channels/telegram/telegram.js +2 -2
  43. package/dist/channels/telegram/turn-store.d.ts +8 -21
  44. package/dist/channels/telegram/turn-store.js +11 -130
  45. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  46. package/dist/channels/turn-store.d.ts +42 -0
  47. package/dist/channels/turn-store.js +139 -0
  48. package/dist/channels/wait-health.d.ts +6 -0
  49. package/dist/channels/wait-health.js +27 -0
  50. package/dist/cli-add-feishu.d.ts +8 -0
  51. package/dist/cli-add-feishu.js +223 -0
  52. package/dist/cli.js +68 -23
  53. package/dist/deploy/container.js +10 -6
  54. package/dist/deploy/fly/plan.d.ts +1 -1
  55. package/dist/deploy/fly/plan.js +15 -4
  56. package/dist/deploy/fly/run.d.ts +7 -4
  57. package/dist/deploy/fly/run.js +26 -5
  58. package/dist/deploy/railway/plan.d.ts +1 -1
  59. package/dist/deploy/railway/plan.js +17 -5
  60. package/dist/deploy/railway/run.d.ts +6 -3
  61. package/dist/deploy/railway/run.js +26 -4
  62. package/dist/deploy/registration-gate.d.ts +20 -0
  63. package/dist/deploy/registration-gate.js +20 -0
  64. package/dist/deploy/secrets.d.ts +10 -9
  65. package/dist/deploy/secrets.js +15 -14
  66. package/dist/dev-supervisor.js +2 -1
  67. package/dist/engines/pi/chat.js +3 -3
  68. package/dist/engines/pi/create.d.ts +0 -1
  69. package/dist/engines/pi/create.js +8 -7
  70. package/dist/feishu.d.ts +2 -0
  71. package/dist/feishu.js +2 -0
  72. package/dist/lark.d.ts +3 -0
  73. package/dist/lark.js +3 -0
  74. package/dist/open-url.d.ts +2 -0
  75. package/dist/open-url.js +6 -0
  76. package/dist/scaffold/add-channel.d.ts +9 -5
  77. package/dist/scaffold/add-channel.js +73 -7
  78. package/dist/tunnel.d.ts +9 -6
  79. package/dist/tunnel.js +48 -31
  80. package/package.json +19 -8
  81. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  82. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -0,0 +1,175 @@
1
+ /** Restore the platform's mention placeholders (`@_user_1`) in a text body to readable `@Name`. */
2
+ function restoreMentions(text, mentions) {
3
+ let out = text;
4
+ for (const m of mentions ?? []) {
5
+ if (!m.key)
6
+ continue;
7
+ out = out.split(m.key).join(`@${m.name ?? "user"}`);
8
+ }
9
+ return out;
10
+ }
11
+ /**
12
+ * Decode a message's `content` by its `message_type` — the single decoder (module header). Unknown or
13
+ * malformed content degrades to a visible marker (`[sticker message]`), never a throw: the payload is
14
+ * external input, and a message the agent cannot read should still say WHAT it couldn't read.
15
+ */
16
+ export function parseContent(m) {
17
+ let c;
18
+ try {
19
+ c = JSON.parse(m.content);
20
+ if (typeof c !== "object" || c === null)
21
+ throw new Error("not an object");
22
+ }
23
+ catch {
24
+ return { text: `[unreadable ${m.message_type} message]`, imageKeys: [], fileRefs: [] };
25
+ }
26
+ const imageKeys = [];
27
+ const fileRefs = [];
28
+ const str = (v) => (typeof v === "string" && v !== "" ? v : undefined);
29
+ switch (m.message_type) {
30
+ case "text":
31
+ return { text: restoreMentions(str(c.text) ?? "", m.mentions), imageKeys, fileRefs };
32
+ case "post": {
33
+ // A post is paragraphs of typed nodes; renders as text lines with inline markers. Mentions in a
34
+ // post are `at` NODES (user_name inline), not placeholders — no restore pass needed.
35
+ const lines = [];
36
+ const title = str(c.title);
37
+ if (title)
38
+ lines.push(title);
39
+ const paragraphs = Array.isArray(c.content) ? c.content : [];
40
+ for (const para of paragraphs) {
41
+ if (!Array.isArray(para))
42
+ continue;
43
+ const parts = [];
44
+ for (const node of para) {
45
+ if (typeof node !== "object" || node === null)
46
+ continue;
47
+ if (node.tag === "at")
48
+ parts.push(`@${str(node.user_name) ?? str(node.user_id) ?? "user"}`);
49
+ else if (node.tag === "a")
50
+ parts.push(node.href ? `${str(node.text) ?? node.href} (${node.href})` : (str(node.text) ?? ""));
51
+ else if (node.tag === "img") {
52
+ if (str(node.image_key))
53
+ imageKeys.push(node.image_key);
54
+ parts.push("[image]");
55
+ }
56
+ else if (node.tag === "media") {
57
+ if (str(node.file_key))
58
+ fileRefs.push({ key: node.file_key, name: str(node.file_name) });
59
+ parts.push("[video]");
60
+ }
61
+ else if (node.tag === "code_block")
62
+ parts.push(`\n\`\`\`${str(node.language)?.toLowerCase() ?? ""}\n${str(node.text) ?? ""}\n\`\`\`\n`);
63
+ else if (str(node.text))
64
+ parts.push(node.text);
65
+ }
66
+ const line = parts.join("").trim();
67
+ if (line)
68
+ lines.push(line);
69
+ }
70
+ return { text: lines.join("\n"), imageKeys, fileRefs };
71
+ }
72
+ case "image": {
73
+ if (str(c.image_key))
74
+ imageKeys.push(c.image_key);
75
+ return { text: "[image]", imageKeys, fileRefs };
76
+ }
77
+ case "file": {
78
+ const name = str(c.file_name);
79
+ if (str(c.file_key))
80
+ fileRefs.push({ key: c.file_key, name });
81
+ return { text: `[file: ${name ?? "file"}]`, imageKeys, fileRefs };
82
+ }
83
+ case "audio": {
84
+ if (str(c.file_key))
85
+ fileRefs.push({ key: c.file_key, name: "voice-message" });
86
+ return { text: "[voice message]", imageKeys, fileRefs };
87
+ }
88
+ case "media": {
89
+ const name = str(c.file_name);
90
+ if (str(c.file_key))
91
+ fileRefs.push({ key: c.file_key, name });
92
+ return { text: `[video: ${name ?? "video"}]`, imageKeys, fileRefs };
93
+ }
94
+ case "location": {
95
+ const name = str(c.name);
96
+ return {
97
+ text: `[location: ${name ? `${name} — ` : ""}${str(c.latitude) ?? "?"},${str(c.longitude) ?? "?"}]`,
98
+ imageKeys,
99
+ fileRefs,
100
+ };
101
+ }
102
+ default:
103
+ // sticker / share_chat / share_user / system / … — name the type so the agent can say what it got.
104
+ return { text: `[${m.message_type} message]`, imageKeys, fileRefs };
105
+ }
106
+ }
107
+ /** A stable sender label for attribution. The receive event carries only ids (a display name needs a
108
+ * contacts-API scope), so the label is the open_id — stable across turns, which is what a shared
109
+ * multi-user session needs to tell participants apart. */
110
+ export function senderLabel(sender) {
111
+ const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
112
+ return id ? `user ${id}` : undefined;
113
+ }
114
+ /** The place a message lives (chat, or chat:topic in a topic group) — the default session key. */
115
+ export function placeKey(m) {
116
+ return m.thread_id ? `${m.chat_id}:${m.thread_id}` : m.chat_id;
117
+ }
118
+ /**
119
+ * The default base prompt: a context envelope (chat/thread/sender + a group note + a reply marker),
120
+ * then the message's decoded body. The sender is named on every message and a group chat is flagged —
121
+ * in a shared multi-user session that is how the model tells participants apart and knows it is not a
122
+ * 1:1. A reply carries only `[in reply to msg …]` here: the referent's CONTENT is not in the event, so
123
+ * the channel fetches and appends it in the IO half (invoke-turn.ts), keeping this layer pure. Exported
124
+ * so a custom Feishu `route` can reuse it, e.g. `text: `${feishuEnvelope(event)}\n\n[extra]``. The
125
+ * internal compatibility seam binds the same shape to `[lark: …]`; each kind's send tool reads the
126
+ * chat id from its own branded line.
127
+ */
128
+ export function feishuEnvelope(event) {
129
+ return cloudEnvelope(event, "feishu");
130
+ }
131
+ /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
132
+ export function cloudEnvelope(event, tag) {
133
+ const m = event.message;
134
+ if (!m)
135
+ return "";
136
+ const meta = [
137
+ `chat ${m.chat_id} (${m.chat_type})`,
138
+ m.thread_id ? `topic ${m.thread_id}` : undefined,
139
+ senderLabel(event.sender) ? `from ${senderLabel(event.sender)}` : undefined,
140
+ ]
141
+ .filter(Boolean)
142
+ .join(", ");
143
+ const scope = m.chat_type === "group" ? "\n[group chat — multiple people; each message is prefixed with its sender]" : "";
144
+ const replyTo = m.parent_id ? `\n[in reply to msg ${m.parent_id}]` : "";
145
+ return `[${tag}: ${meta}]${scope}${replyTo}\n${parseContent(m).text}`;
146
+ }
147
+ /**
148
+ * Whether the message @mentions the bot — read from the `mentions` array the platform already parsed
149
+ * (never a regex over the text: a pasted `@bot` in a code block is not a mention entry), matched on the
150
+ * bot's open_id (stable identity; names are mutable). No id → fail closed (false): answering "is this
151
+ * mention me?" with "I don't know who I am, so yes" would mis-summon in every multi-bot group.
152
+ */
153
+ export function mentionsBot(m, botOpenId) {
154
+ if (!botOpenId)
155
+ return false;
156
+ return (m.mentions ?? []).some((x) => x.id?.open_id === botOpenId);
157
+ }
158
+ /**
159
+ * The default routing policy (used when `route` is omitted; exported so a custom route can reuse it):
160
+ * answer humans only (a non-`user` sender is another bot/app — two bots answering each other loop
161
+ * forever), p2p chats always, a group only on an @mention of THIS bot (matched by open_id, which
162
+ * feishuChannel resolves via bot/v3/info). NOTE the platform side of the same coin: with the default
163
+ * `im:message.group_at_msg` scope, un-mentioned group messages are never even delivered — receiving
164
+ * everything needs the sensitive `im:message.group_msg` scope. Returns `{}` (act; the channel fills
165
+ * session/target/prompt from the message) or `null` (ignore).
166
+ */
167
+ export function defaultFeishuRoute(event, options) {
168
+ const m = event.message;
169
+ if (!m)
170
+ return null;
171
+ if (event.sender?.sender_type !== "user")
172
+ return null;
173
+ const summoned = m.chat_type === "p2p" || mentionsBot(m, options?.botOpenId);
174
+ return summoned ? {} : null;
175
+ }
@@ -0,0 +1,36 @@
1
+ import type { AgentEvent } from "../../agent.ts";
2
+ import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
+ /** A terminal failure, as the channel hands it to `onError`. */
4
+ export interface FeishuFailure {
5
+ details: string;
6
+ retryable: boolean;
7
+ }
8
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
9
+ export declare function defaultErrorMessage(failed: FeishuFailure): string;
10
+ /** A visible preview mounted into the chat. Exported only for the channel wiring: a queued turn mounts
11
+ * one before execution, then hands the exact entity/message to {@link streamFeishuReply} for takeover. */
12
+ export type MountedFeishuPreview = {
13
+ kind: "card";
14
+ cardId: string;
15
+ messageId: string;
16
+ } | {
17
+ kind: "text";
18
+ messageId: string;
19
+ };
20
+ /**
21
+ * Mount one preview message: preferably a streaming card entity, with a static text message as the
22
+ * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
23
+ * has exactly the same shape the stream pump expects to take over later.
24
+ */
25
+ export declare function mountFeishuPreview(api: FeishuApi, target: FeishuTarget, initial: string, label?: string): Promise<MountedFeishuPreview>;
26
+ /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
27
+ * Card and text tiers both change in place; only a missing/failed preview sends a fresh message. */
28
+ export declare function settleFeishuPreview(api: FeishuApi, target: FeishuTarget, preview: MountedFeishuPreview | undefined, text: string): Promise<void>;
29
+ /**
30
+ * Consume one turn's event stream into a Feishu-compatible chat, live (see the module header for the preview
31
+ * model). Preview updates are best-effort (logged once if they fail); the final write is authoritative
32
+ * and surfaces a real failure (bad credentials, etc.). `initialPreview`, when present, is the queued
33
+ * turn's already-mounted card/text message: the pump and terminal write mutate that same message rather
34
+ * than recalling it and posting another reply.
35
+ */
36
+ export declare function streamFeishuReply(events: AsyncIterable<AgentEvent>, api: FeishuApi, target: FeishuTarget, formatError: (failed: FeishuFailure) => string | undefined, initialPreview?: MountedFeishuPreview, label?: string): Promise<void>;
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Canonical Feishu live-preview rendering (also reused by Lark compatibility). The preview is ONE
3
+ * streaming CARD (create entity → mount it with a reply/send → stream full-text snapshots at its
4
+ * markdown element with a strictly increasing `sequence`; the client renders the typewriter effect);
5
+ * on completion the same card is settled in place with the final answer (streaming off). Streaming
6
+ * updates ride the cardkit quota (50 QPS per app, 10 QPS per card entity, no edit ceiling) — NOT the
7
+ * 5 QPS per-chat message quota or
8
+ * the 20-edit cap on text messages, which is why the preview is a card and not an edited text message.
9
+ *
10
+ * A queued turn mounts this same card early with its queue status, reply-quoted to that turn's source
11
+ * message; when execution starts the preview takes the entity over in place. This mirrors Telegram's
12
+ * one-message lifecycle without trying to change a text message into a card (which the platform does
13
+ * not support), and keeps multiple queued asks attributable even if their card mounts race visually.
14
+ *
15
+ * Fallback tier (fail visibly, degrade per turn): if the card cannot be created or mounted, the turn
16
+ * runs with a TEXT placeholder and NO live updates (text edits are capped at 20 per message, so the
17
+ * text tier spends them only on terminal writes); if the platform closes streaming mid-turn (idle
18
+ * timeout), the preview freezes and the settle still lands. The final write is authoritative either
19
+ * way, mirroring the telegram preview's terminal-write matrix (completed/failed/abnormal ×
20
+ * settle/delete+send/suppress).
21
+ */
22
+ import { setTimeout as sleep } from "node:timers/promises";
23
+ import { log } from "../../log.js";
24
+ import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
+ import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
26
+ import { truncateCodePointPrefix, truncateCodePointSuffix, truncateUtf8 } from "./text.js";
27
+ /** The customer-facing default: neutral, no leaked internals; differentiate only on whether to retry. */
28
+ export function defaultErrorMessage(failed) {
29
+ return failed.retryable ? "⚠️ Temporary problem — please try again." : "⚠️ Sorry, something went wrong.";
30
+ }
31
+ /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
+ * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
33
+ * animates between snapshots).
34
+ * Doubles as the answer-preview aging window (see answerView). */
35
+ const STREAM_THROTTLE_MS = 1000;
36
+ /** Max length of a tool's arg preview in the live view. */
37
+ const TOOL_ARG_MAX = 48;
38
+ /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
39
+ const THINKING_PREVIEW = 280;
40
+ /** The placeholder shown before any reasoning/tool/text arrives. */
41
+ const THINKING_PLACEHOLDER = "💭 Thinking…";
42
+ /** One-line, truncated: collapse whitespace so a multi-line command/arg stays on one line. */
43
+ function clip(s) {
44
+ const one = s.replace(/\s+/g, " ").trim();
45
+ return truncateCodePointPrefix(one, TOOL_ARG_MAX);
46
+ }
47
+ /**
48
+ * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
49
+ * rather than just `🔧 read`. Generic (the channel knows no tool schemas): show the salient value — the
50
+ * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
51
+ */
52
+ function summarizeArgs(args) {
53
+ if (args === null || typeof args !== "object" || Array.isArray(args))
54
+ return clip(String(args));
55
+ const values = Object.values(args);
56
+ const primary = values.find((v) => typeof v === "string" || typeof v === "number");
57
+ if (primary !== undefined)
58
+ return clip(String(primary));
59
+ return values.length > 0 ? clip(JSON.stringify(args)) : "";
60
+ }
61
+ /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
62
+ * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
63
+ * window (which would redraw the whole card every frame). The full answer still lands at settle. */
64
+ function capBytes(s, maxBytes) {
65
+ return truncateUtf8(s, maxBytes);
66
+ }
67
+ /**
68
+ * The terminal-write POLICY: resolve the preview into `text`. One card → settle it in place (final
69
+ * markdown, streaming off); an over-budget answer settles the card with its first chunk and sends the
70
+ * rest as follow-up messages. A failed settle falls back to delete + fresh send, so no "Thinking…" card
71
+ * is left pinned above the answer. Text tier → ONE edit into the final text (or delete + fresh sends
72
+ * when it doesn't fit). No preview → fresh send. EMPTY text = "say nothing" → just delete the preview.
73
+ */
74
+ async function finalize(api, target, preview, text, seq) {
75
+ if (text.trim() === "") {
76
+ if (preview.kind !== "none")
77
+ await api.deleteMessage(preview.messageId).catch(() => { });
78
+ return;
79
+ }
80
+ if (preview.kind === "card") {
81
+ const [head, ...rest] = chunkFeishuText(text, CARD_MARKDOWN_MAX_BYTES);
82
+ let settled = false;
83
+ try {
84
+ await api.updateCard(preview.cardId, finalCardJson(head ?? ""), seq());
85
+ settled = true;
86
+ }
87
+ catch {
88
+ // Settle failed (card expired / rejected) — fall through to delete + fresh send below.
89
+ }
90
+ if (settled) {
91
+ // Topic continuations must keep reply_in_thread; ordinary group continuations intentionally avoid
92
+ // repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
93
+ // A continuation failure propagates: the card is already authoritative, so deleting it and sending
94
+ // the full answer again would deterministically duplicate every continuation that already landed.
95
+ const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
96
+ for (const chunk of rest)
97
+ await api.sendText(continuationTarget, chunk);
98
+ return;
99
+ }
100
+ }
101
+ if (preview.kind === "text") {
102
+ if (chunkFeishuText(text).length === 1) {
103
+ try {
104
+ await api.editTextMessage(preview.messageId, text);
105
+ return;
106
+ }
107
+ catch {
108
+ // Edit failed (edit window / count / policy) — fall through to delete + fresh send below.
109
+ }
110
+ }
111
+ }
112
+ if (preview.kind !== "none")
113
+ await api.deleteMessage(preview.messageId).catch(() => { });
114
+ await api.sendText(target, text);
115
+ }
116
+ /**
117
+ * Mount one preview message: preferably a streaming card entity, with a static text message as the
118
+ * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
119
+ * has exactly the same shape the stream pump expects to take over later.
120
+ */
121
+ export async function mountFeishuPreview(api, target, initial, label = "[feishu]") {
122
+ try {
123
+ const cardId = await api.createCard(streamingCardJson(initial));
124
+ const content = cardEntityContent(cardId);
125
+ const mountOnce = () => target.replyTo !== undefined
126
+ ? api.replyMessage(target.replyTo, "interactive", content, { replyInThread: target.replyInThread })
127
+ : api.sendMessage(target.chatId, "interactive", content);
128
+ let messageId;
129
+ // Field-observed: the mount can reject a JUST-minted card id (code 230099 / "cardid is invalid")
130
+ // — the entity is not yet visible to the IM side (eventual consistency between cardkit and IM).
131
+ // That specific rejection gets a short backoff and another try before degrading; anything else
132
+ // degrades immediately.
133
+ for (let attempt = 1;; attempt++) {
134
+ try {
135
+ messageId = await mountOnce();
136
+ break;
137
+ }
138
+ catch (e) {
139
+ if (attempt >= 3 || !/230099|11310|cardid is invalid/i.test(String(e)))
140
+ throw e;
141
+ log.warn(`${label} mount rejected the fresh card (card=${cardId}, attempt ${attempt}) — retrying: ${String(e)}`);
142
+ await sleep(attempt * 400);
143
+ }
144
+ }
145
+ if (messageId === undefined)
146
+ throw new Error("interactive send returned ok without a message_id");
147
+ return { kind: "card", cardId, messageId };
148
+ }
149
+ catch (e) {
150
+ // Card tier failed — degrade to a text placeholder with NO live updates (the text tier's 20-edit
151
+ // cap is spent on terminal writes only). Visible: the operator learns why the preview is static.
152
+ log.warn(`${label} streaming card unavailable — live preview degrades to a static placeholder: ${String(e)}`);
153
+ const messageId = target.replyTo !== undefined
154
+ ? await api.replyMessage(target.replyTo, "text", JSON.stringify({ text: initial }), {
155
+ replyInThread: target.replyInThread,
156
+ })
157
+ : await api.sendMessage(target.chatId, "text", JSON.stringify({ text: initial }));
158
+ if (messageId === undefined)
159
+ throw new Error("text preview send returned ok without a message_id");
160
+ return { kind: "text", messageId };
161
+ }
162
+ }
163
+ /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
164
+ * Card and text tiers both change in place; only a missing/failed preview sends a fresh message. */
165
+ export async function settleFeishuPreview(api, target, preview, text) {
166
+ let sequence = 0;
167
+ await finalize(api, target, preview ?? { kind: "none" }, text, () => ++sequence);
168
+ }
169
+ /**
170
+ * Consume one turn's event stream into a Feishu-compatible chat, live (see the module header for the preview
171
+ * model). Preview updates are best-effort (logged once if they fail); the final write is authoritative
172
+ * and surfaces a real failure (bad credentials, etc.). `initialPreview`, when present, is the queued
173
+ * turn's already-mounted card/text message: the pump and terminal write mutate that same message rather
174
+ * than recalling it and posting another reply.
175
+ */
176
+ export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
177
+ const tools = [];
178
+ const toolIndexById = new Map();
179
+ let thinking = "";
180
+ let answer = "";
181
+ let answerPreviewSince;
182
+ const mark = { running: "…", ok: "✓", error: "✗" };
183
+ const toolView = () => tools.map((t) => `🔧 ${t.label} ${mark[t.status]}`).join("\n");
184
+ // Reasoning is process, not the answer: shown (capped to its tail) in the live preview only, never
185
+ // in the settled final card (which is `answer` alone).
186
+ const thinkingView = () => {
187
+ const t = thinking.replace(/\s+/g, " ").trim();
188
+ if (t === "")
189
+ return "";
190
+ return `💭 ${truncateCodePointSuffix(t, THINKING_PREVIEW)}`;
191
+ };
192
+ // The answer is hidden until its first delta has aged one STREAM_THROTTLE_MS: the pump's leading-edge
193
+ // flush would otherwise turn the very first content delta (often a lone character) into its own frame
194
+ // — the short-reply flicker. Aging is anchored at delta ARRIVAL (set in the event loop, not here) so
195
+ // an in-flight update can't skew the clock; a turn completing within the window settles directly.
196
+ const answerView = () => {
197
+ if (answer.trim() === "" || answerPreviewSince === undefined)
198
+ return "";
199
+ return Date.now() - answerPreviewSince >= STREAM_THROTTLE_MS ? answer : "";
200
+ };
201
+ const view = () => {
202
+ const v = [thinkingView(), toolView(), answerView()]
203
+ .filter((s) => s.trim() !== "")
204
+ .join("\n\n")
205
+ .trim();
206
+ return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
207
+ };
208
+ // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
209
+ // mounted lazily on this turn's first flush. `sequence` must increase strictly per card — the single-
210
+ // writer pump guarantees it by construction. A queue card has had no updates yet, so sequence starts
211
+ // at zero in both paths.
212
+ let preview = initialPreview ?? { kind: "none" };
213
+ let setupAttempted = initialPreview !== undefined;
214
+ let sequence = 0;
215
+ const nextSeq = () => ++sequence;
216
+ let streamDead = false; // the platform closed streaming (idle timeout) — freeze the live view
217
+ let finalized = false; // a terminal write (completed/failed) ran — the finally skips its orphan cleanup
218
+ let lastSent = "";
219
+ const flushPreview = async () => {
220
+ const text = view();
221
+ if (!setupAttempted) {
222
+ setupAttempted = true;
223
+ preview = await mountFeishuPreview(api, target, text, label);
224
+ lastSent = text;
225
+ return;
226
+ }
227
+ if (preview.kind !== "card" || streamDead)
228
+ return; // text tier / dead stream: frozen until the terminal write
229
+ if (text === lastSent)
230
+ return; // skip an unchanged snapshot
231
+ lastSent = text;
232
+ try {
233
+ await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, text, nextSeq());
234
+ }
235
+ catch (e) {
236
+ if (isCardStreamingClosed(e)) {
237
+ // The platform closed streaming (idle timeout). Freeze the live view; the settle write replaces
238
+ // the whole entity (streaming off) and still lands.
239
+ streamDead = true;
240
+ log.warn(`${label} card streaming closed mid-turn — preview frozen; the final answer still lands`);
241
+ return;
242
+ }
243
+ throw e;
244
+ }
245
+ };
246
+ // ── Live-preview pump: a SINGLE serialized writer. ──────────────────────────────────────────
247
+ // Events mutate state (thinking / tools / answer) and mark the preview dirty; the pump pushes the
248
+ // LATEST view() with at most ONE update in flight, paced by a throttle. One-in-flight also guarantees
249
+ // the card's strictly-increasing `sequence` lands in order (no concurrent frames).
250
+ let dirty = false;
251
+ let pumping = false;
252
+ let stopped = false;
253
+ let previewErrLogged = false;
254
+ let pumpDone;
255
+ let wakeThrottle; // set while the pump is mid-throttle; finish() cuts it short
256
+ const runPump = async () => {
257
+ pumping = true;
258
+ try {
259
+ while (dirty && !stopped) {
260
+ dirty = false;
261
+ try {
262
+ await flushPreview();
263
+ }
264
+ catch (e) {
265
+ // Best-effort preview (the final write is authoritative), but a failing update must be visible —
266
+ // log once per turn so a never-rendering preview is diagnosable, not silent.
267
+ if (!previewErrLogged) {
268
+ previewErrLogged = true;
269
+ log.warn(`${label} live preview failed (final reply still sends): ${String(e)}`);
270
+ }
271
+ }
272
+ if (dirty && !stopped) {
273
+ // Pace + coalesce a burst into one snapshot. Interruptible: finish() cuts this short so the
274
+ // final write is not delayed by up to STREAM_THROTTLE_MS after the turn completes.
275
+ await new Promise((resolve) => {
276
+ const t = setTimeout(resolve, STREAM_THROTTLE_MS);
277
+ wakeThrottle = () => {
278
+ clearTimeout(t);
279
+ resolve();
280
+ };
281
+ });
282
+ wakeThrottle = undefined;
283
+ }
284
+ }
285
+ }
286
+ finally {
287
+ pumping = false;
288
+ }
289
+ };
290
+ // Mark the preview dirty and ensure the single writer is running (an update already in flight picks
291
+ // up the new state on its next loop). Synchronous — callers never await a network write.
292
+ const touch = () => {
293
+ dirty = true;
294
+ if (!pumping)
295
+ pumpDone = runPump();
296
+ };
297
+ touch(); // mount the "💭 Thinking…" preview immediately
298
+ // Stop the pump and await any in-flight update, so the final write below is strictly the LAST one to
299
+ // the preview (no stale frame landing after the answer).
300
+ const finish = async () => {
301
+ stopped = true;
302
+ wakeThrottle?.(); // cut an in-flight throttle so the final write is not delayed up to STREAM_THROTTLE_MS
303
+ await pumpDone?.catch(() => { });
304
+ };
305
+ /** Terminal write, whatever tier the preview reached. */
306
+ const settle = async (text) => {
307
+ await finalize(api, target, preview, text, nextSeq);
308
+ };
309
+ try {
310
+ for await (const e of events) {
311
+ if (e.type === "text") {
312
+ answer += e.delta;
313
+ if (answerPreviewSince === undefined && answer.trim() !== "")
314
+ answerPreviewSince = Date.now();
315
+ touch();
316
+ }
317
+ else if (e.type === "thinking") {
318
+ thinking += e.delta;
319
+ touch();
320
+ }
321
+ else if (e.type === "tool_started") {
322
+ const arg = summarizeArgs(e.args);
323
+ toolIndexById.set(e.id, tools.length);
324
+ tools.push({ label: arg ? `${e.name} ${arg}` : e.name, status: "running" });
325
+ touch();
326
+ }
327
+ else if (e.type === "tool_ended") {
328
+ const i = toolIndexById.get(e.id);
329
+ const t = i === undefined ? undefined : tools[i];
330
+ if (t)
331
+ t.status = e.isError ? "error" : "ok";
332
+ touch();
333
+ }
334
+ else if (e.type === "completed") {
335
+ await finish();
336
+ // Settle the preview into the final answer; the persisted card is the answer alone — the
337
+ // process (thinking/tools) was preview-only. Mark finalized BEFORE delivering: the terminal was
338
+ // reached, so a delivery failure here is a plain failure, not an "abnormal exit" (which would
339
+ // wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
340
+ finalized = true;
341
+ await settle(answer.trim() !== "" ? answer : "(no reply)");
342
+ return;
343
+ }
344
+ else if (e.type === "failed") {
345
+ await finish();
346
+ // Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
347
+ // log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
348
+ // write as completed; an empty notice deletes the preview (suppress = no residue). Best-effort —
349
+ // we throw below regardless.
350
+ finalized = true;
351
+ {
352
+ const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
353
+ try {
354
+ await settle(msg);
355
+ }
356
+ catch (deliveryError) {
357
+ // Preserve the Agent failure as the primary error below, but keep the broken final hop in
358
+ // the operator-visible chain — otherwise the log falsely implies the user saw the notice.
359
+ log.error(`${label} failed to deliver the agent-failure notice: ${String(deliveryError)}`);
360
+ }
361
+ }
362
+ throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
363
+ }
364
+ }
365
+ throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
366
+ }
367
+ finally {
368
+ await finish();
369
+ // Abnormal exit (stream ended without a terminal, the generator threw, or the consumer abandoned):
370
+ // no terminal write ran. Show the SAME neutral notice a `failed` event would — the preview may show
371
+ // real partial work, so don't delete it silently, and don't leave the user in silence. A suppressing
372
+ // onError still collapses to a delete (finalize on empty text).
373
+ if (!finalized) {
374
+ // retryable:false — an abnormal end (no terminal / a throw) is of UNKNOWN retryability, so use the
375
+ // neutral "something went wrong" default rather than promising "try again" that may not help.
376
+ const notice = formatError({ details: "the turn ended without completing", retryable: false }) ?? "";
377
+ try {
378
+ await settle(notice);
379
+ }
380
+ catch (deliveryError) {
381
+ // The stream's original throw remains primary; this explicit line records that the user-facing
382
+ // terminal notice failed too instead of silently breaking the responsibility chain.
383
+ log.error(`${label} failed to deliver the abnormal-turn notice: ${String(deliveryError)}`);
384
+ }
385
+ }
386
+ }
387
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One-click app creation ("scan to create") — the OAuth 2.0 Device Authorization Grant (RFC 8628)
3
+ * flow the platform provides for agent apps: `begin` returns a one-time verification URL the user
4
+ * opens in Feishu/Lark and confirms (the platform pre-configures the agent app template: bot
5
+ * capability, messaging scopes, event subscriptions); polling returns the new app's credentials.
6
+ *
7
+ * Hand-rolled on fetch, no SDK: the wire protocol is two form-encoded POSTs to the accounts endpoint
8
+ * plus RFC 8628's polling error dance — shared verbatim by all four official SDKs (node/python/java/go),
9
+ * which makes it a de-facto stable surface even though only the SDKs document it. Provenance:
10
+ * larksuite/node-sdk `scene/registration` (registerApp). If the platform ever moves this behind
11
+ * something non-trivial (signed payloads, websockets), adopt the official SDK instead of chasing it —
12
+ * the same tripwire as feishu-api.ts.
13
+ *
14
+ * The scanning user's tenant decides the brand: a Lark-tenant user flips polling to the Lark accounts
15
+ * domain mid-flow (`tenant_brand: "lark"`), and the result carries the brand so the caller can point
16
+ * everything else (API origin) at the right cloud.
17
+ */
18
+ /**
19
+ * Additive app config carried on the confirm-page URL (`addons` query param): extra scopes/events
20
+ * merged ON TOP of the platform's agent template — base permissions can never be removed. Shape and
21
+ * encoding (JSON → gzip → base64url) follow the official SDKs (provenance: node-sdk
22
+ * scene/registration); item names unknown to the platform catalog are silently dropped by the page.
23
+ */
24
+ export interface FeishuAppAddons {
25
+ scopes?: {
26
+ tenant?: string[];
27
+ user?: string[];
28
+ };
29
+ events?: {
30
+ items?: {
31
+ tenant?: string[];
32
+ user?: string[];
33
+ };
34
+ };
35
+ callbacks?: {
36
+ items?: string[];
37
+ };
38
+ }
39
+ export interface RegisterFeishuAppOptions {
40
+ /** Pre-filled app name shown on the confirm page (`{user}` expands to the scanning user's name). */
41
+ name?: string;
42
+ /** Pre-filled app description. */
43
+ desc?: string;
44
+ /** Extra scopes/events merged onto the agent template at creation (see {@link FeishuAppAddons}). */
45
+ addons?: FeishuAppAddons;
46
+ /** Called once the one-time verification URL is ready — print it / render it as a QR code. */
47
+ onVerificationUrl: (info: {
48
+ url: string;
49
+ expiresInS: number;
50
+ }) => void;
51
+ /** Cancel the polling. */
52
+ signal?: AbortSignal;
53
+ /** Accounts origins, for tests. */
54
+ accountsBaseUrl?: string;
55
+ larkAccountsBaseUrl?: string;
56
+ }
57
+ export interface RegisteredFeishuApp {
58
+ appId: string;
59
+ appSecret: string;
60
+ /** "feishu" | "lark" — which cloud the scanning user's tenant lives on (drives the API origin). */
61
+ tenantBrand?: string;
62
+ /** The scanning user's open_id, when the platform returns it. */
63
+ openId?: string;
64
+ }
65
+ /**
66
+ * Run the scan-to-create flow (module header): begin → hand the verification URL to the caller →
67
+ * poll until the user confirms. Resolves with the new app's credentials; rejects on denial, expiry,
68
+ * abort, or a transport failure — every rejection is a plain Error whose message says what to do.
69
+ */
70
+ export declare function registerFeishuApp(options: RegisterFeishuAppOptions): Promise<RegisteredFeishuApp>;