@fastagent-sh/fastagent 0.14.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 (209) hide show
  1. package/README.md +3 -3
  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 +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -1,20 +1,7 @@
1
- /**
2
- * Run one turn (the IO half of canonical Feishu→Agent translation): assemble its inputs — resolve the reply
3
- * referent (a summon that replies to an earlier message names it only by `parent_id`; the content is
4
- * NOT in the event, so it is fetched here) and the attachments (vision images inline, files to disk) —
5
- * and stream `agent.invoke` with the assembled prompt. Split from parse.ts (which is pure) because this
6
- * half touches the Open API + disk; split from feishu.ts so the factory keeps only wiring and the
7
- * per-turn lifecycle.
8
- *
9
- * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
- * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
- * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
- * one expired background file must not block the current ask or hide its still-readable siblings.
13
- */
14
- import { SESSION_BUSY_CODE } from "../../agent.js";
15
1
  import { log } from "../../log.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
16
3
  import { parseContent } from "./parse.js";
17
- import { codePointPrefix } from "./text.js";
4
+ import { codePointPrefix } from "../text.js";
18
5
  /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
19
6
  * card's markdown element is the natural fit for LLM output — steer away from HTML/plain. */
20
7
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card.)";
@@ -84,46 +71,25 @@ async function resolveTurnInputs(t, attachments) {
84
71
  log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
85
72
  }
86
73
  }
87
- const missing = lost + attachments.buffered.skipped;
88
- const bufferedNote = missing > 0
89
- ? `\n[note: ${missing} attachment(s) from the earlier discussion are not loaded (expired, or older than the most recent few)]`
90
- : "";
91
- const backgroundImageManifest = backgroundImages.length
92
- ? `\n\n[background vision images from earlier discussion — appended after ${imageRefs.length} primary image(s):\n${backgroundImages
93
- .map(({ ref }, index) => `- vision image ${imageRefs.length + index + 1}: from ${ref.from}, msg ${ref.messageId}`)
94
- .join("\n")}\n]`
95
- : "";
74
+ const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
75
+ const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
96
76
  const allFiles = [
97
77
  ...downloaded,
98
78
  ...backgroundFiles.map(({ file, ref }) => ({
99
79
  ...file,
100
- name: `${file.name} (from ${ref.from}, msg ${ref.messageId}, earlier discussion)`,
80
+ name: attributedFileName(file.name, ref.from, ref.messageId),
101
81
  })),
102
82
  ];
103
- const manifest = allFiles.length
104
- ? `\n\n[attached files — read them with your tools:\n${allFiles.map((file) => `- ${file.name} (${file.size} bytes) → ${file.path}`).join("\n")}\n]`
105
- : "";
106
83
  const allImages = [...imageRefs, ...backgroundImages.map(({ image }) => image)];
107
84
  return {
108
85
  images: allImages.length ? allImages : undefined,
109
- promptSuffix: `${referentBlock}${bufferedNote}${backgroundImageManifest}${manifest}`,
86
+ promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
110
87
  };
111
88
  }
112
- // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
113
- // free, and the loop exits within one delay of the holder finishing. The cap is sized to outlast a real
114
- // tool-using wake turn (minutes); a holder that runs longer still surfaces the busy error to the user.
115
- const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
116
89
  /**
117
- * Run one turn: resolve its inputs, then stream agent.invoke. A primary-input failure surfaces as a
118
- * `failed` event (never a silent drop). `onCompleted` (if given) fires on the `completed` event — the
119
- * durable-commit point; the caller uses it to remove the turn intent (turn-store L1) at the earliest
120
- * moment the turn provably lives in the session.
121
- *
122
- * BUSY-WAIT: a `failed{code: session_busy}` FIRST event means an external turn holds this session's
123
- * lease and OUR turn never started — replay-safe. Retry (bounded) instead of yielding it: the user sees
124
- * the "Thinking…" preview while waiting, and only an exhausted wait surfaces the busy failure. Only a
125
- * FIRST-event busy retries — inputs are already resolved, and a fail-fast reject is the only shape the
126
- * engine emits it in, so nothing that started is ever re-run.
90
+ * Run one turn: resolve its inputs, then stream agent.invoke with the shared busy-wait
91
+ * (invoke-turn-kit `onCompleted` is the durable-commit point; see streamTurnWithBusyRetry). A
92
+ * primary-input failure surfaces as a `failed` event (never a silent drop).
127
93
  */
128
94
  export async function* invokeFeishuTurn(agent, session, text, transport, attachments, onCompleted, busyRetry = DEFAULT_BUSY_RETRY) {
129
95
  let resolved;
@@ -135,23 +101,5 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
135
101
  return;
136
102
  }
137
103
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
138
- const deadline = Date.now() + busyRetry.maxWaitMs;
139
- for (;;) {
140
- let retryBusy = false;
141
- let first = true;
142
- for await (const e of agent.invoke({ session }, prompt)) {
143
- if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
144
- retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
145
- break;
146
- }
147
- first = false;
148
- if (e.type === "completed")
149
- onCompleted?.(); // the turn is durably in the session — commit point
150
- yield e;
151
- }
152
- if (!retryBusy)
153
- return;
154
- log.info(`${transport.label} session ${session} is busy (an external turn holds it) — retrying in ${busyRetry.delayMs}ms`);
155
- await new Promise((r) => setTimeout(r, busyRetry.delayMs));
156
- }
104
+ yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
157
105
  }
@@ -76,7 +76,7 @@ export type FeishuResourceKind = "image" | "file" | "audio" | "video";
76
76
  * A resource locator. User-sent resources are scoped by BOTH their carrying message id and resource
77
77
  * key; a bare file_key/image_key is insufficient for the message-resource download API.
78
78
  */
79
- export interface FeishuResourceRef {
79
+ interface FeishuResourceRef {
80
80
  kind: FeishuResourceKind;
81
81
  messageId: string;
82
82
  key: string;
@@ -95,3 +95,4 @@ export interface NormalizedFeishuMessage {
95
95
  resources: FeishuResourceRef[];
96
96
  };
97
97
  }
98
+ export {};
@@ -4,7 +4,7 @@
4
4
  * converts resource keys into message-scoped locators before the turn engine sees them.
5
5
  */
6
6
  import type { FeishuMessage, FeishuMessageEvent, FeishuResourceKind, NormalizedFeishuMessage } from "./model.ts";
7
- export interface DecodedFeishuResource {
7
+ interface DecodedFeishuResource {
8
8
  kind: FeishuResourceKind;
9
9
  key: string;
10
10
  name?: string;
@@ -20,3 +20,4 @@ export interface DecodedFeishuContent {
20
20
  export declare function decodeFeishuContent(message: Pick<FeishuMessage, "message_type" | "content" | "mentions">): DecodedFeishuContent;
21
21
  /** Normalize one verified message event. Returns null only when its required identity is absent. */
22
22
  export declare function normalizeFeishuMessage(event: FeishuMessageEvent): NormalizedFeishuMessage | null;
23
+ export {};
@@ -1,11 +1,7 @@
1
- export interface OwnedFeishuThread {
2
- rootId: string;
3
- chatId: string;
4
- createdAt: number;
5
- }
6
- export interface OwnedFeishuThreads {
1
+ interface OwnedFeishuThreads {
7
2
  has(chatId: string, rootId: string): boolean;
8
3
  /** Idempotent, synchronous, pre-ACK persistence. A failed write throws so the platform redelivers. */
9
4
  add(chatId: string, rootId: string): void;
10
5
  }
11
6
  export declare function createOwnedFeishuThreads(path: string, label: string, now?: () => number): OwnedFeishuThreads;
7
+ export {};
@@ -9,7 +9,7 @@ import type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, Fei
9
9
  export type { FeishuMention, FeishuMessage, FeishuMessageEvent, FeishuRoute, FeishuSender };
10
10
  /** Legacy compatibility shape returned by {@link parseContent}. New internal code consumes normalized
11
11
  * resource refs, which retain resource kind + carrying message id. */
12
- export interface FeishuAttachmentRef {
12
+ interface FeishuAttachmentRef {
13
13
  key: string;
14
14
  name?: string;
15
15
  }
@@ -1,12 +1,9 @@
1
1
  import type { AgentEvent } from "../../agent.ts";
2
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;
3
+ import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
4
+ /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
5
+ export type FeishuFailure = ChannelFailure;
6
+ export { defaultErrorMessage };
10
7
  /** A visible preview mounted into the chat. Exported only for the channel wiring: a queued turn mounts
11
8
  * one before execution, then hands the exact entity/message to {@link streamFeishuReply} for takeover. */
12
9
  export type MountedFeishuPreview = {
@@ -23,41 +23,16 @@ import { setTimeout as sleep } from "node:timers/promises";
23
23
  import { log } from "../../log.js";
24
24
  import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
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
- }
26
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
27
+ import { truncateUtf8 } from "../text.js";
28
+ export { defaultErrorMessage };
31
29
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
30
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
33
31
  * animates between snapshots).
34
32
  * Doubles as the answer-preview aging window (see answerView). */
35
33
  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
34
  /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
39
35
  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
36
  /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
62
37
  * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
63
38
  * window (which would redraw the whole card every frame). The full answer still lands at settle. */
@@ -174,35 +149,16 @@ export async function settleFeishuPreview(api, target, preview, text) {
174
149
  * than recalling it and posting another reply.
175
150
  */
176
151
  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
- };
152
+ // Event view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
153
+ // policy, the card-budget cap, and delivery below.
154
+ const turn = createTurnView();
201
155
  const view = () => {
202
- const v = [thinkingView(), toolView(), answerView()]
203
- .filter((s) => s.trim() !== "")
204
- .join("\n\n")
205
- .trim();
156
+ const v = composeTurnBody([
157
+ thinkingLine(turn, THINKING_PREVIEW),
158
+ toolLines(turn),
159
+ turn.retrying ? RETRY_NOTICE : "",
160
+ revealedAnswer(turn, STREAM_THROTTLE_MS),
161
+ ]);
206
162
  return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
207
163
  };
208
164
  // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
@@ -243,105 +199,31 @@ export async function streamFeishuReply(events, api, target, formatError, initia
243
199
  throw e;
244
200
  }
245
201
  };
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
- };
202
+ // The shared single-writer pump (preview-kit) serializes snapshots to the one preview — which also
203
+ // guarantees the card's strictly-increasing `sequence` lands in order (no concurrent frames).
204
+ const { touch, finish } = createPreviewPump({
205
+ flush: flushPreview,
206
+ throttleMs: STREAM_THROTTLE_MS,
207
+ onError: (e) => log.warn(`${label} live preview failed (final reply still sends): ${String(e)}`),
208
+ });
297
209
  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
210
  /** Terminal write, whatever tier the preview reached. */
306
211
  const settle = async (text) => {
307
212
  await finalize(api, target, preview, text, nextSeq);
308
213
  };
309
214
  try {
310
215
  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") {
216
+ if (e.type === "completed") {
335
217
  await finish();
336
218
  // Settle the preview into the final answer; the persisted card is the answer alone — the
337
219
  // process (thinking/tools) was preview-only. Mark finalized BEFORE delivering: the terminal was
338
220
  // reached, so a delivery failure here is a plain failure, not an "abnormal exit" (which would
339
221
  // wrongly fire the finally's neutral-notice fallback = double delivery + wrong text).
340
222
  finalized = true;
341
- await settle(answer.trim() !== "" ? answer : "(no reply)");
223
+ await settle(turn.answer.trim() !== "" ? turn.answer : "(no reply)");
342
224
  return;
343
225
  }
344
- else if (e.type === "failed") {
226
+ if (e.type === "failed") {
345
227
  await finish();
346
228
  // Two audiences: the chat (customer-facing — formatError, neutral by default) and the operator
347
229
  // log (dev-facing — the full details, via the throw below + the handler's catch). Same terminal
@@ -361,6 +243,8 @@ export async function streamFeishuReply(events, api, target, formatError, initia
361
243
  }
362
244
  throw new Error(`agent failed: ${e.details} (retryable=${e.retryable})`);
363
245
  }
246
+ if (applyTurnEvent(turn, e))
247
+ touch();
364
248
  }
365
249
  throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
366
250
  }
@@ -21,7 +21,7 @@
21
21
  * encoding (JSON → gzip → base64url) follow the official SDKs (provenance: node-sdk
22
22
  * scene/registration); item names unknown to the platform catalog are silently dropped by the page.
23
23
  */
24
- export interface FeishuAppAddons {
24
+ interface FeishuAppAddons {
25
25
  scopes?: {
26
26
  tenant?: string[];
27
27
  user?: string[];
@@ -68,3 +68,4 @@ export interface RegisteredFeishuApp {
68
68
  * abort, or a transport failure — every rejection is a plain Error whose message says what to do.
69
69
  */
70
70
  export declare function registerFeishuApp(options: RegisterFeishuAppOptions): Promise<RegisteredFeishuApp>;
71
+ export {};
@@ -7,7 +7,7 @@ import { type FeishuCloudKind } from "./cloud.ts";
7
7
  *
8
8
  * Reports its outcome as a {@link RegistrationOutcome} fact; gating policy belongs to the caller.
9
9
  */
10
- export interface FeishuManualRegistration {
10
+ interface FeishuManualRegistration {
11
11
  consoleUrl: string;
12
12
  requestUrl: string;
13
13
  }
@@ -20,3 +20,4 @@ export interface RegisterFeishuWebhookOptions {
20
20
  onManualRegistration?: (info: FeishuManualRegistration) => void;
21
21
  }
22
22
  export declare function registerFeishuWebhook(baseUrl: string, kind: FeishuCloudKind, opts?: RegisterFeishuWebhookOptions): Promise<RegistrationOutcome>;
23
+ export {};
@@ -0,0 +1,8 @@
1
+ /** Feishu/Lark app-level event subscription choice used by onboarding and scaffolding. */
2
+ export type FeishuSubscriptionMode = "webhook" | "websocket";
3
+ /** Onboarding choice for group visibility. `context` needs the tenant-wide group-message scope; the
4
+ * runtime remains capability-driven because the platform, not channel source, decides which events
5
+ * are delivered. */
6
+ export type FeishuGroupBehavior = "context" | "mentions";
7
+ /** The sensitive tenant scope behind both managed-thread bare replies and group context buffering. */
8
+ export declare const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
@@ -0,0 +1,2 @@
1
+ /** The sensitive tenant scope behind both managed-thread bare replies and group context buffering. */
2
+ export const FEISHU_GROUP_CONTEXT_SCOPE = "im:message.group_msg";
@@ -0,0 +1,28 @@
1
+ import type { LongConnection } from "../../host/node.ts";
2
+ import type { FeishuCloudKind } from "./cloud.ts";
3
+ import type { FeishuMessageEvent } from "./parse.ts";
4
+ interface FeishuWsClient {
5
+ start(): Promise<void>;
6
+ close(): void;
7
+ }
8
+ interface FeishuWsClientCallbacks {
9
+ onReady(): void;
10
+ onError(error: Error): void;
11
+ onReconnecting(): void;
12
+ onReconnected(): void;
13
+ onEvent(event: FeishuMessageEvent): void | Promise<void>;
14
+ }
15
+ export type CreateFeishuWsClient = (callbacks: FeishuWsClientCallbacks) => FeishuWsClient | Promise<FeishuWsClient>;
16
+ export interface FeishuWsConnectionOptions {
17
+ kind: FeishuCloudKind;
18
+ appId: string;
19
+ appSecret: string;
20
+ domain: string;
21
+ onEvent(event: FeishuMessageEvent): void | Promise<void>;
22
+ /** Internal SDK seam for deterministic tests. */
23
+ createClient?: CreateFeishuWsClient;
24
+ }
25
+ /** Open Feishu/Lark's official-SDK WebSocket connection. The SDK ACKs only after `onEvent` settles;
26
+ * a persistence throw therefore becomes a 500 response frame and the platform re-pushes the event. */
27
+ export declare function connectFeishuWs(options: FeishuWsConnectionOptions, signal: AbortSignal): LongConnection;
28
+ export {};
@@ -0,0 +1,136 @@
1
+ import { log } from "../../log.js";
2
+ function sdkLogger(label) {
3
+ return {
4
+ error: (...parts) => log.error(`${label} ${parts.map(String).join(" ")}`),
5
+ warn: (...parts) => log.warn(`${label} ${parts.map(String).join(" ")}`),
6
+ info: (...parts) => log.info(`${label} ${parts.map(String).join(" ")}`),
7
+ debug: (...parts) => log.debug(`${label} ${parts.map(String).join(" ")}`),
8
+ trace: (...parts) => log.debug(`${label} ${parts.map(String).join(" ")}`),
9
+ };
10
+ }
11
+ async function productionClient(options, callbacks) {
12
+ // Webhook-only users stay on the lightweight fetch path; load the proprietary-protocol SDK only
13
+ // when a WebSocket connection is actually opened.
14
+ const { EventDispatcher, LoggerLevel, WSClient } = await import("@larksuiteoapi/node-sdk");
15
+ const label = `[${options.kind}:ws]`;
16
+ const logger = sdkLogger(label);
17
+ const eventDispatcher = new EventDispatcher({ logger, loggerLevel: LoggerLevel.warn }).register({
18
+ "im.message.receive_v1": callbacks.onEvent,
19
+ });
20
+ const client = new WSClient({
21
+ appId: options.appId,
22
+ appSecret: options.appSecret,
23
+ domain: options.domain,
24
+ logger,
25
+ loggerLevel: LoggerLevel.warn,
26
+ autoReconnect: true,
27
+ source: "fastagent",
28
+ handshakeTimeoutMs: 15_000,
29
+ onReady: callbacks.onReady,
30
+ onError: callbacks.onError,
31
+ onReconnecting: callbacks.onReconnecting,
32
+ onReconnected: callbacks.onReconnected,
33
+ });
34
+ return {
35
+ start: () => client.start({ eventDispatcher }),
36
+ close: () => client.close(),
37
+ };
38
+ }
39
+ /** Open Feishu/Lark's official-SDK WebSocket connection. The SDK ACKs only after `onEvent` settles;
40
+ * a persistence throw therefore becomes a 500 response frame and the platform re-pushes the event. */
41
+ export function connectFeishuWs(options, signal) {
42
+ const label = `[${options.kind}:ws]`;
43
+ // The SDK only logs + returns for this case (no onError), which would leave readiness pending forever.
44
+ if (!/^cli_[0-9a-fA-F]{16}$/.test(options.appId)) {
45
+ throw new Error(`${options.kind} websocket requires an App ID shaped like cli_<16 hex characters>`);
46
+ }
47
+ let readySettled = false;
48
+ let closedSettled = false;
49
+ let resolveReady;
50
+ let rejectReady;
51
+ let resolveClosed;
52
+ let rejectClosed;
53
+ const ready = new Promise((resolve, reject) => {
54
+ resolveReady = resolve;
55
+ rejectReady = reject;
56
+ });
57
+ const closed = new Promise((resolve, reject) => {
58
+ resolveClosed = resolve;
59
+ rejectClosed = reject;
60
+ });
61
+ const fail = (error) => {
62
+ if (signal.aborted || closedSettled)
63
+ return;
64
+ // Settle-then-close: closedSettled first makes a close()-triggered SDK callback re-entry a no-op.
65
+ closedSettled = true;
66
+ // Terminal failure must release the transport here: the abort listener's close() no-ops once
67
+ // closedSettled is set, and only close() destroys SDK-held resources (e.g. its cache sweep timer).
68
+ if (client)
69
+ closeClient(client);
70
+ const failure = error instanceof Error ? error : new Error(String(error));
71
+ if (!readySettled) {
72
+ readySettled = true;
73
+ rejectReady(failure);
74
+ }
75
+ rejectClosed(failure);
76
+ };
77
+ const createClient = options.createClient ?? ((callbacks) => productionClient(options, callbacks));
78
+ let client;
79
+ const callbacks = {
80
+ onReady() {
81
+ if (readySettled || signal.aborted)
82
+ return;
83
+ readySettled = true;
84
+ log.info(`${label} connected`);
85
+ resolveReady();
86
+ },
87
+ onError(error) {
88
+ fail(new Error(`${error.message} — check that Events & Callbacks uses long connection and the published app version includes im.message.receive_v1`));
89
+ },
90
+ onReconnecting() {
91
+ if (!signal.aborted)
92
+ log.warn(`${label} disconnected — reconnecting…`);
93
+ },
94
+ onReconnected() {
95
+ if (!signal.aborted)
96
+ log.info(`${label} reconnected`);
97
+ },
98
+ onEvent: options.onEvent,
99
+ };
100
+ const closeClient = (target) => {
101
+ try {
102
+ target.close();
103
+ }
104
+ catch (error) {
105
+ log.warn(`${label} close failed: ${String(error)}`);
106
+ }
107
+ };
108
+ const close = () => {
109
+ if (closedSettled)
110
+ return;
111
+ closedSettled = true; // before closeClient, so a callback re-entry from close() is a no-op
112
+ if (client)
113
+ closeClient(client);
114
+ if (!readySettled) {
115
+ // Abort before the first connection: `ready` still settles, and resolution here means
116
+ // cancellation, not readiness (the LongConnection contract; serve skips ready-side effects).
117
+ readySettled = true;
118
+ resolveReady();
119
+ }
120
+ resolveClosed();
121
+ };
122
+ if (signal.aborted) {
123
+ close();
124
+ return { ready, closed };
125
+ }
126
+ signal.addEventListener("abort", close, { once: true });
127
+ void Promise.resolve(createClient(callbacks)).then((created) => {
128
+ client = created;
129
+ if (signal.aborted || closedSettled) {
130
+ closeClient(created);
131
+ return;
132
+ }
133
+ void created.start().catch(fail);
134
+ }, fail);
135
+ return { ready, closed };
136
+ }
@@ -17,12 +17,7 @@ const MAX_WEBHOOK_BYTES = 25 << 20;
17
17
  * `(ctx) => ({ "POST /gh": githubChannel(opts)(ctx)["POST /webhook"]! })`.
18
18
  */
19
19
  export function githubChannel({ secret, on }) {
20
- // A non-empty secret is mandatory: verify() against an empty key accepts a signature anyone can
21
- // compute, so an unset secret must fail at construction, never silently run forgeable.
22
- if (!secret) {
23
- throw new Error("githubChannel requires a non-empty secret (the GitHub webhook secret, e.g. GITHUB_WEBHOOK_SECRET)");
24
- }
25
- return ({ agent }) => ({
20
+ const channel = ({ agent }) => ({
26
21
  "POST /webhook": async (req) => {
27
22
  if (req.method !== "POST")
28
23
  return text("POST only\n", 405);
@@ -77,4 +72,11 @@ export function githubChannel({ secret, on }) {
77
72
  return new Response(null, { status: 202 });
78
73
  },
79
74
  });
75
+ return (ctx) => {
76
+ // Validate at activation so deploy may inspect the module shape before secrets are provisioned.
77
+ if (!secret) {
78
+ throw new Error("githubChannel requires a non-empty secret (the GitHub webhook secret, e.g. GITHUB_WEBHOOK_SECRET)");
79
+ }
80
+ return channel(ctx);
81
+ };
80
82
  }
@@ -1,5 +1,19 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Agent } from "../agent.ts";
3
+ /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
+ * dispatch imports it), so the two caps cannot drift apart. */
5
+ export declare const MAX_BODY_BYTES: number;
6
+ /** SSE comment-heartbeat interval, shared by every SSE surface (the control events route imports
7
+ * it, and the remote client sizes its dead-connection watchdog as a multiple of it). */
8
+ export declare const SSE_HEARTBEAT_MS = 30000;
9
+ /** The emitting half of the heartbeat contract (the client watchdog is the other): starts the
10
+ * `: ping` comment interval on an SSE stream controller and returns its stop function — ONE
11
+ * implementation for every SSE surface, so the emission side cannot regress on one route while
12
+ * the shared client watchdog keeps assuming it. Self-stops if the controller is already closed. */
13
+ export declare function sseHeartbeat(controller: ReadableStreamDefaultController<Uint8Array>): () => void;
14
+ /** A valid example request body for the invoke handler — lives HERE, next to the shape check it must
15
+ * satisfy, so the CLI's "try it" hint can't drift from the protocol. */
16
+ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hello\"}";
3
17
  /**
4
18
  * Fetch-shaped invoke handler. Mount it at any route in the host app; it accepts POST only.
5
19
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.