@fastagent-sh/fastagent 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -1,175 +1,65 @@
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
- }
1
+ import { decodeFeishuContent } from "./normalize.js";
11
2
  /**
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.
3
+ * Compatibility decoder for existing helpers/tests and parent-message resolution. The canonical
4
+ * decoder now emits typed resources; this wrapper projects them onto the historical parallel arrays.
15
5
  */
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
- }
6
+ export function parseContent(message) {
7
+ const decoded = decodeFeishuContent(message);
8
+ return {
9
+ text: decoded.text,
10
+ imageKeys: decoded.resources.filter((resource) => resource.kind === "image").map((resource) => resource.key),
11
+ fileRefs: decoded.resources
12
+ .filter((resource) => resource.kind === "file" || resource.kind === "audio" || resource.kind === "video")
13
+ .map((resource) => ({ key: resource.key, name: resource.name })),
14
+ };
106
15
  }
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. */
16
+ /** A stable sender label for attribution. Display names require an additional contacts permission. */
110
17
  export function senderLabel(sender) {
111
18
  const id = sender?.sender_id?.open_id ?? sender?.sender_id?.user_id ?? sender?.sender_id?.union_id;
112
19
  return id ? `user ${id}` : undefined;
113
20
  }
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;
21
+ /** The place a message lives (chat, or chat:topic in a topic group) — the legacy default session key. */
22
+ export function placeKey(message) {
23
+ return message.thread_id ? `${message.chat_id}:${message.thread_id}` : message.chat_id;
117
24
  }
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
- */
25
+ /** The canonical Feishu-branded prompt envelope. */
128
26
  export function feishuEnvelope(event) {
129
27
  return cloudEnvelope(event, "feishu");
130
28
  }
131
29
  /** Internal compatibility seam: bind the canonical envelope shape to one cloud's branded tag. */
132
30
  export function cloudEnvelope(event, tag) {
133
- const m = event.message;
134
- if (!m)
31
+ const message = event.message;
32
+ if (!message)
135
33
  return "";
34
+ const from = senderLabel(event.sender);
136
35
  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,
36
+ `chat ${message.chat_id} (${message.chat_type})`,
37
+ message.thread_id ? `topic ${message.thread_id}` : undefined,
38
+ from ? `from ${from}` : undefined,
140
39
  ]
141
40
  .filter(Boolean)
142
41
  .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}`;
42
+ const scope = message.chat_type === "group" ? "\n[group chat — multiple people; each message is prefixed with its sender]" : "";
43
+ const replyTo = message.parent_id ? `\n[in reply to msg ${message.parent_id}]` : "";
44
+ return `[${tag}: ${meta}]${scope}${replyTo}\n${parseContent(message).text}`;
146
45
  }
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) {
46
+ /** Whether the parsed mention list contains this bot's app-scoped open_id. */
47
+ export function mentionsBot(message, botOpenId) {
154
48
  if (!botOpenId)
155
49
  return false;
156
- return (m.mentions ?? []).some((x) => x.id?.open_id === botOpenId);
50
+ return (message.mentions ?? []).some((mention) => mention.id?.open_id === botOpenId);
157
51
  }
158
52
  /**
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).
53
+ * Default EXPLICIT-summon policy: ignore non-user senders, always answer p2p, and answer groups only
54
+ * when THIS bot is structurally mentioned. No bot identity means group routing fails closed. The
55
+ * stateful channel wiring may additionally admit unmentioned continuations from its managed-root index.
166
56
  */
167
57
  export function defaultFeishuRoute(event, options) {
168
- const m = event.message;
169
- if (!m)
58
+ const message = event.message;
59
+ if (!message)
170
60
  return null;
171
61
  if (event.sender?.sender_type !== "user")
172
62
  return null;
173
- const summoned = m.chat_type === "p2p" || mentionsBot(m, options?.botOpenId);
63
+ const summoned = message.chat_type === "p2p" || mentionsBot(message, options?.botOpenId);
174
64
  return summoned ? {} : null;
175
65
  }
@@ -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. */
@@ -88,8 +63,8 @@ async function finalize(api, target, preview, text, seq) {
88
63
  // Settle failed (card expired / rejected) — fall through to delete + fresh send below.
89
64
  }
90
65
  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.
66
+ // Threaded continuations must keep reply_in_thread; continuous top-level group replies intentionally
67
+ // avoid repeating the quote on every chunk. sendText owns the same distinction for its own chunking.
93
68
  // A continuation failure propagates: the card is already authoritative, so deleting it and sending
94
69
  // the full answer again would deterministically duplicate every continuation that already landed.
95
70
  const continuationTarget = target.replyInThread ? target : { chatId: target.chatId };
@@ -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 {};
@@ -7,7 +7,9 @@ import { feishuChannel } from "@fastagent-sh/fastagent/feishu";
7
7
  // 1. create a custom app → enable the BOT capability → copy App ID / App Secret into .env
8
8
  // 2. Permissions: add `im:message.p2p_msg:readonly` (direct messages), `im:message.group_at_msg:readonly`
9
9
  // (group @mentions), `im:message:send_as_bot` (reply), `im:resource` (attachments), and the
10
- // card scope ("Create and update card" — the live preview streams through a card)
10
+ // card scope ("Create and update card" — the live preview streams through a card). To answer bare
11
+ // messages in Agent-managed threads and buffer other unsummoned group/thread context, also add the
12
+ // sensitive `im:message.group_msg` scope (tenant-admin approval) and publish a new version.
11
13
  // 3. Events & Callbacks → subscribe to `im.message.receive_v1`; copy the Verification Token into
12
14
  // .env; RECOMMENDED: set an Encrypt Key there and mirror it in FEISHU_ENCRYPT_KEY
13
15
  // 4. the event Request URL (https://your.host/feishu) is registered AUTOMATICALLY by
@@ -21,14 +23,19 @@ export default feishuChannel({
21
23
  appSecret: process.env.FEISHU_APP_SECRET ?? "",
22
24
  verificationToken: process.env.FEISHU_VERIFICATION_TOKEN ?? "", // authenticates inbound events
23
25
  encryptKey: process.env.FEISHU_ENCRYPT_KEY || undefined, // optional; when set, plaintext events are refused
26
+ // Direct and group chats default to one Agent session/thread per top-level ask. Opt out independently:
27
+ // directMessageSession: "continuous",
28
+ // groupMessageSession: "continuous",
24
29
  // Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
25
30
  // chat is customer-facing by default — for a public bot, drop this or return a neutral string;
26
31
  // full details always go to the server log regardless.
27
32
  onError: (failed) => `⚠️ ${failed.details}`,
28
33
  // The channel owns transport + format (markdown card) + attachments (image→vision, file→disk) +
29
34
  // the live streaming preview. `route` (POLICY) is OPTIONAL — omitted, it uses defaultFeishuRoute:
30
- // p2p chats always answer, groups only on an @mention of this bot (matched by open_id, resolved at
31
- // startup). Override to customise, reusing the export:
35
+ // p2p chats always answer; groups answer on @this-bot, plus bare continuations in Agent-managed
36
+ // threads. Other human group/thread discussion buffers until that place's next answered turn;
37
+ // @other-only messages in managed threads buffer rather than triggering the Agent.
38
+ // Override to customise explicit routing, reusing the export:
32
39
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { session: `user:${e.sender?.sender_id?.open_id}` },
33
40
  // route: (e) => defaultFeishuRoute(e, { botOpenId: "ou_xxx" }) && { text: `${feishuEnvelope(e)}\n[extra]` },
34
41
  });
@@ -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 {};