@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Channel discovery (the N axis, filesystem form). A channel file default-exports either the existing
3
3
  * route factory `(ctx) => Routes`, or an explicit long-connection module `{ name, connect(ctx, signal) }`.
4
+ *
5
+ * Engine-neutral, and living here rather than under `engines/` because of it: reading `channels/*.ts`
6
+ * is the Channel contract plus a directory, with no engine in sight.
4
7
  */
5
- import { readdir } from "node:fs/promises";
6
8
  import { isAbsolute, join } from "node:path";
7
- import { parseRouteKey, } from "../../host/node.js";
8
- import { isModuleFile, loadModuleDir } from "../../loader.js";
9
- import { assertInsideAgentDir } from "../../paths.js";
9
+ import { assertRouteKey, routeKeysConflict } from "./serve.js";
10
+ import { loadModuleDir, moduleInventory } from "../loader.js";
11
+ import { assertInsideAgentDir } from "../paths.js";
10
12
  function longConnectionModule(value) {
11
13
  return value !== null && typeof value === "object" && typeof value.connect === "function";
12
14
  }
@@ -15,28 +17,29 @@ function validateLongConnectionModule(value, label) {
15
17
  throw new Error(`${label}: long-connection channel name must be a non-empty string`);
16
18
  }
17
19
  }
20
+ /** Declared channels from basenames that share one ingress: the serving surface's mounted route list
21
+ * (a long-connection channel mounts no HTTP route, so every route IS a webhook channel), and fixtures. */
22
+ export function declaredChannels(names, ingress = "webhook") {
23
+ return names.map((name) => ({ name, ingress }));
24
+ }
18
25
  /**
19
26
  * Import channel files without mounting route factories or opening connections. Deployment needs only
20
- * the authored structural fact: function exports are route channels; `{ connect() }` exports are
27
+ * the authored structural fact: function exports are webhook channels; `{ connect() }` exports are
21
28
  * long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
22
29
  */
23
30
  export async function inspectChannels(dir) {
24
31
  await assertInsideAgentDir(dir, "channels");
25
32
  const { modules, failures } = await loadModuleDir(join(dir, "channels"));
26
33
  const channels = [];
27
- const routeChannels = [];
28
- const longConnectionChannels = [];
29
34
  for (const { name, label, file, mod } of modules) {
30
35
  try {
31
36
  if (typeof mod.default === "function") {
32
- channels.push(name);
33
- routeChannels.push(name);
37
+ channels.push({ name, ingress: "webhook" });
34
38
  continue;
35
39
  }
36
40
  if (longConnectionModule(mod.default)) {
37
41
  validateLongConnectionModule(mod.default, label);
38
- channels.push(name);
39
- longConnectionChannels.push(name);
42
+ channels.push({ name, ingress: "long-connection" });
40
43
  continue;
41
44
  }
42
45
  throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
@@ -45,7 +48,7 @@ export async function inspectChannels(dir) {
45
48
  failures.push({ label, file, message: error.message });
46
49
  }
47
50
  }
48
- return { channels, routeChannels, longConnectionChannels, failures };
51
+ return { channels, failures };
49
52
  }
50
53
  /**
51
54
  * Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
@@ -53,19 +56,8 @@ export async function inspectChannels(dir) {
53
56
  */
54
57
  export async function discoverChannelFiles(dir) {
55
58
  await assertInsideAgentDir(dir, "channels");
56
- let names;
57
- try {
58
- names = await readdir(join(dir, "channels"));
59
- }
60
- catch (error) {
61
- if (error.code === "ENOENT")
62
- return [];
63
- throw error;
64
- }
65
- return names
66
- .filter(isModuleFile)
67
- .map((name) => name.replace(/\.(ts|js|mjs)$/, ""))
68
- .sort();
59
+ const entries = await moduleInventory(join(dir, "channels"));
60
+ return entries.map((entry) => entry.name);
69
61
  }
70
62
  function validateRoutes(value, label) {
71
63
  if (value === null || typeof value !== "object" || value instanceof Map) {
@@ -79,9 +71,7 @@ function validateRoutes(value, label) {
79
71
  if (typeof handler !== "function") {
80
72
  throw new Error(`${label}: route "${route}" must map to a handler function, got ${typeof handler}`);
81
73
  }
82
- if (!parseRouteKey(route).path.startsWith("/")) {
83
- throw new Error(`${label}: route "${route}" is not a valid route key (expected "METHOD /path" or "/path")`);
84
- }
74
+ assertRouteKey(route, (problem) => `${label}: route "${route}" is not a valid route key — ${problem}`);
85
75
  }
86
76
  return routes;
87
77
  }
@@ -121,12 +111,7 @@ export async function loadChannels(dir, ctx) {
121
111
  }
122
112
  const declaredRoutes = validateRoutes(declared, label);
123
113
  for (const [route, handler] of declaredRoutes) {
124
- const parsed = parseRouteKey(route);
125
- const clash = Object.keys(routes).some((key) => {
126
- const existing = parseRouteKey(key);
127
- return (existing.path === parsed.path &&
128
- (existing.method === undefined || parsed.method === undefined || existing.method === parsed.method));
129
- });
114
+ const clash = Object.keys(routes).some((key) => routeKeysConflict(key, route));
130
115
  if (clash) {
131
116
  collisions.push({ route, source: label });
132
117
  continue;
@@ -17,7 +17,7 @@
17
17
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
18
18
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
19
19
  */
20
- import { truncateCodePointPrefix } from "../text.js";
20
+ import { truncateCodePointPrefix } from "../kit/text.js";
21
21
  /** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
22
22
  export const ANSWER_ELEMENT_ID = "answer";
23
23
  /** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
@@ -1,4 +1,4 @@
1
- import { type ContextBuffer } from "../context-buffer.ts";
1
+ import { type ContextBuffer } from "../kit/context-buffer.ts";
2
2
  import type { NormalizedFeishuMessage } from "./model.ts";
3
3
  export interface FeishuBufferedResource {
4
4
  messageId: string;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
- * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
3
+ * ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
4
  * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
5
  * concrete thread root) and folded into the next answered turn in that place.
6
6
  */
7
7
  import { log } from "../../log.js";
8
- import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
9
- import { loadStateFile, saveStateFile } from "../state.js";
10
- import { truncateCodePointPrefix } from "../text.js";
8
+ import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
9
+ import { loadStateFile, saveStateFile } from "../kit/state.js";
10
+ import { truncateCodePointPrefix } from "../kit/text.js";
11
11
  function bufferLine(entry) {
12
12
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
13
13
  .filter(Boolean)
@@ -20,8 +20,8 @@
20
20
  * here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
21
21
  */
22
22
  import { mkdir, writeFile } from "node:fs/promises";
23
- import { join } from "node:path";
24
- import { utf8Prefix } from "../text.js";
23
+ import { attachmentPath } from "../kit/attachment-path.js";
24
+ import { utf8Prefix } from "../kit/text.js";
25
25
  /** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
26
26
  const API_TIMEOUT_MS = 30_000;
27
27
  /** Timeout for downloading resource bytes — sized for a slow link, not a JSON call. */
@@ -283,13 +283,10 @@ export function createFeishuApi(opts) {
283
283
  },
284
284
  async fetchFile(messageId, fileKey, name, chatId, filesDir) {
285
285
  const { bytes } = await api.downloadResource(messageId, fileKey, "file");
286
- // The name is external input destined for a filesystem path — keep only its basename-safe core.
287
- const safe = name.replace(/[/\\]/g, "_").replace(/^\.+/, "_") || "file";
288
- const dir = join(filesDir, chatId);
289
- await mkdir(dir, { recursive: true });
290
- const dest = join(dir, safe);
291
- await writeFile(dest, bytes);
292
- return { path: dest, name: safe, size: bytes.byteLength };
286
+ const dest = attachmentPath(filesDir, chatId, name);
287
+ await mkdir(dest.dir, { recursive: true });
288
+ await writeFile(dest.path, bytes);
289
+ return { path: dest.path, name: dest.name, size: bytes.byteLength };
293
290
  },
294
291
  async getAppConfig(appId) {
295
292
  // v6 app detail — the one read surface that returns the event-security material (under data.app).
@@ -1,4 +1,4 @@
1
- import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
1
+ import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
2
2
  import { type FeishuCloudProfile } from "./cloud.ts";
3
3
  import { type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute, feishuEnvelope } from "./parse.ts";
4
4
  import { type FeishuFailure } from "./preview.ts";
@@ -12,31 +12,38 @@ import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
- import { createSeenRing } from "../seen.js";
16
- import { createTaskTracker } from "../tasks.js";
17
- import { ensureStateHome, loadStateFile, saveStateFile } from "../state.js";
18
- import { dispatchStop, isStopText } from "../stop-command.js";
19
- import { createTurnQueue } from "../turn-queue.js";
20
- import { createTurnStore } from "../turn-store.js";
15
+ import { createSeenRing } from "../kit/seen.js";
16
+ import { createTaskTracker } from "../kit/tasks.js";
17
+ import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
18
+ import { signatureIsFresh } from "../kit/signature.js";
19
+ import { dispatchStop, isStopText } from "../kit/stop-command.js";
20
+ import { createTurnQueue } from "../kit/turn-queue.js";
21
+ import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
22
+ import { discussionBlock } from "../kit/context-buffer.js";
21
23
  import { FEISHU_CLOUD } from "./cloud.js";
22
24
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
23
25
  import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
24
26
  import { invokeFeishuTurn } from "./invoke-turn.js";
25
27
  import { createFeishuApi } from "./feishu-api.js";
26
28
  import { normalizeFeishuMessage } from "./normalize.js";
27
- import { createThreadParticipants } from "../thread-participants.js";
29
+ import { createThreadParticipants } from "../kit/thread-participants.js";
28
30
  import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
29
31
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
30
32
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
31
33
  import { connectFeishuWs } from "./ws-ingress.js";
32
34
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
33
35
  export { defaultFeishuRoute, feishuEnvelope };
34
- /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
35
- * rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
36
- * at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
37
- const MAX_TURN_ATTEMPTS = 3;
38
36
  /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
39
37
  const MAX_EVENT_BYTES = 1 << 20;
38
+ /**
39
+ * Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
40
+ * 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
41
+ * is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
42
+ * to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
43
+ * turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
44
+ * enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
45
+ */
46
+ const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
40
47
  /** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
41
48
  * accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
42
49
  * is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
@@ -220,7 +227,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
220
227
  });
221
228
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
222
229
  // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
223
- const sideTasks = createTaskTracker();
230
+ const sideTasks = createTaskTracker(label);
224
231
  const toStored = (r) => {
225
232
  const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
226
233
  return { ...intent, attempts: 0 };
@@ -285,7 +292,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
285
292
  await notice?.done;
286
293
  notices.delete(rec.id);
287
294
  // Count this execution against the durable record (poison-turn ceiling) before running it again.
288
- const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
295
+ const decision = store.startAttempt(rec.id);
289
296
  if (decision === "exceeded") {
290
297
  notifyDropped(rec);
291
298
  return;
@@ -312,8 +319,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
312
319
  const roomBlock = room?.text
313
320
  ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
314
321
  : "";
315
- const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
316
- const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
322
+ const prompt = `${roomBlock}${discussionBlock(recent)}${rec.baseText}`;
317
323
  // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
318
324
  // attachments win the slots.
319
325
  const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
@@ -331,12 +337,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
331
337
  label,
332
338
  appId,
333
339
  ...(parentSession !== undefined ? { parentSession } : {}),
334
- }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
335
- // Drop intent first: a crash between these writes may re-fold answered context later,
336
- // but can never replay this turn after its context was removed.
337
- store.remove(rec.id);
338
- buffer.commit(rec.bufferKey, consumed);
339
- }), api, targetOf(rec), formatError, rec.preview, label);
340
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
340
341
  log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
341
342
  }
342
343
  catch (error) {
@@ -583,6 +584,12 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
583
584
  const { verificationToken, encryptKey } = opts;
584
585
  const { kind, envPrefix } = profile;
585
586
  const label = `[${kind}]`;
587
+ if (!encryptKey) {
588
+ // Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
589
+ // carry no signature, so the freshness window below never runs and a captured body replays for
590
+ // as long as the verification token lives.
591
+ log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
592
+ }
586
593
  const handler = async (req) => {
587
594
  if (req.method !== "POST")
588
595
  return text("POST only\n", 405);
@@ -609,9 +616,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
609
616
  nonce: req.headers.get("x-lark-request-nonce") ?? "",
610
617
  signature: req.headers.get("x-lark-signature") ?? "",
611
618
  };
612
- if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
613
- log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
614
- return text("invalid signature\n", 401);
619
+ if (sig.signature) {
620
+ // Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
621
+ // it, so without a window a captured body + its three x-lark-* headers replays forever. The
622
+ // `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
623
+ // turn (a re-sent message, a re-fired tool).
624
+ if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
625
+ log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
626
+ return text("stale signature\n", 401);
627
+ }
628
+ if (!verifySignature(encryptKey, sig, body.text)) {
629
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
630
+ return text("invalid signature\n", 401);
631
+ }
615
632
  }
616
633
  try {
617
634
  envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
@@ -13,7 +13,7 @@
13
13
  * or hide its still-readable siblings.
14
14
  */
15
15
  import type { Agent, AgentEvent } from "../../agent.ts";
16
- import { type BusyRetry } from "../invoke-turn-kit.ts";
16
+ import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
17
17
  import type { FeishuBufferedRef } from "./context-buffer.ts";
18
18
  import type { FeishuApi } from "./feishu-api.ts";
19
19
  /** Everything the transport needs to fetch a turn's attachments. */
@@ -1,8 +1,8 @@
1
1
  import { log } from "../../log.js";
2
- import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
3
- import { BUFFER_ATTACH_MAX } from "../context-buffer.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { BUFFER_ATTACH_MAX } from "../kit/context-buffer.js";
4
4
  import { parseContent } from "./parse.js";
5
- import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../kit/text.js";
6
6
  /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
7
7
  * concept — what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
8
8
  * natural fit for LLM output — steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
@@ -169,7 +169,6 @@ async function resolveTurnInputs(t, attachments) {
169
169
  referentBlock += chain.block;
170
170
  }
171
171
  }
172
- // Primary first and fail-fast: these are resources the current user explicitly pointed at.
173
172
  const imageRefs = [];
174
173
  for (const ref of images)
175
174
  imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
@@ -1,6 +1,6 @@
1
1
  import type { AgentEvent } from "../../agent.ts";
2
2
  import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
- import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
3
+ import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
4
4
  /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
5
5
  export type FeishuFailure = ChannelFailure;
6
6
  export { defaultErrorMessage };
@@ -25,8 +25,8 @@ import { setTimeout as sleep } from "node:timers/promises";
25
25
  import { log } from "../../log.js";
26
26
  import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
27
27
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
28
- import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
29
- import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
28
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../kit/preview-kit.js";
29
+ import { truncateCodePointPrefix, truncateUtf8 } from "../kit/text.js";
30
30
  export { defaultErrorMessage };
31
31
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
32
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
@@ -286,7 +286,11 @@ export async function streamFeishuReply(events, api, target, formatError, initia
286
286
  // we throw below regardless.
287
287
  finalized = true;
288
288
  {
289
- const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
289
+ const msg = formatError({
290
+ details: e.details,
291
+ retryable: e.retryable,
292
+ ...(e.code !== undefined ? { code: e.code } : {}),
293
+ }) ?? "";
290
294
  try {
291
295
  await settle(msg);
292
296
  }
@@ -1,4 +1,4 @@
1
- import type { LongConnection } from "../../host/node.ts";
1
+ import type { LongConnection } from "../../channel.ts";
2
2
  import type { FeishuCloudKind } from "./cloud.ts";
3
3
  import type { FeishuMessageEvent } from "./parse.ts";
4
4
  interface FeishuWsClient {
@@ -1,5 +1,5 @@
1
1
  import type { Schema } from "@octokit/webhooks-types";
2
- import type { ChannelModule } from "../../host/node.ts";
2
+ import type { ChannelModule } from "../../channel.ts";
3
3
  /** A verified GitHub webhook event. Header fields plus the official typed payload. */
4
4
  export interface GithubEvent {
5
5
  /** `X-GitHub-Event` (e.g. "pull_request", "issue_comment"). */
@@ -1,4 +1,14 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ /**
2
+ * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
+ *
4
+ * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
+ * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
+ * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
+ * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
+ *
9
+ * Serving it is somebody else's job: the node:http bridge and the route table belong to the HOST
10
+ * (`./serve.ts`). This file knows only the Agent contract and the wire shape of one stream.
11
+ */
2
12
  import type { Agent } from "../agent.ts";
3
13
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
14
  * dispatch imports it), so the two caps cannot drift apart. */
@@ -19,9 +29,3 @@ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hell
19
29
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
20
30
  */
21
31
  export declare function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;
22
- /**
23
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
24
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
25
- * cancels both the request signal and the response stream (→ invoke cancellation).
26
- */
27
- export declare function nodeListener(handler: (req: Request) => Promise<Response>): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,17 +1,5 @@
1
- /**
2
- * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
- *
4
- * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
- * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
- * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
- * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
- *
9
- * `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
10
- */
11
- import { Readable } from "node:stream";
12
- import { log } from "../log.js";
13
1
  import { readBodyCapped } from "./body.js";
14
- import { text, textHeaders } from "./respond.js";
2
+ import { text } from "./respond.js";
15
3
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
16
4
  * dispatch imports it), so the two caps cannot drift apart. */
17
5
  export const MAX_BODY_BYTES = 1 << 20;
@@ -109,99 +97,3 @@ export function createInvokeHandler(agent) {
109
97
  });
110
98
  };
111
99
  }
112
- /**
113
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
114
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
115
- * cancels both the request signal and the response stream (→ invoke cancellation).
116
- */
117
- export function nodeListener(handler) {
118
- return (req, res) => {
119
- void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
120
- };
121
- }
122
- /**
123
- * Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
124
- * the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
125
- * (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
126
- * mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
127
- * `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
128
- * response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
129
- * hang). The process installs no `unhandledRejection` handler by design: robustness against a background
130
- * throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
131
- * that would blanket-swallow.
132
- */
133
- async function pump(handler, req, res) {
134
- const controller = new AbortController();
135
- res.on("close", () => controller.abort());
136
- try {
137
- const method = req.method ?? "GET";
138
- const hasBody = method !== "GET" && method !== "HEAD";
139
- const headers = new Headers();
140
- for (const [k, v] of Object.entries(req.headers)) {
141
- if (Array.isArray(v))
142
- for (const vv of v)
143
- headers.append(k, vv);
144
- else if (v != null)
145
- headers.set(k, v);
146
- }
147
- const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
148
- method,
149
- headers,
150
- body: hasBody ? Readable.toWeb(req) : undefined,
151
- duplex: "half",
152
- signal: controller.signal,
153
- });
154
- const response = await handler(request);
155
- const outHeaders = {};
156
- response.headers.forEach((value, key) => {
157
- outHeaders[key] = value;
158
- });
159
- res.writeHead(response.status, outHeaders);
160
- if (!response.body) {
161
- res.end();
162
- return;
163
- }
164
- const reader = response.body.getReader();
165
- res.on("close", () => void reader.cancel());
166
- for (;;) {
167
- const { done, value } = await reader.read();
168
- if (done || res.destroyed)
169
- break;
170
- // Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
171
- // returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
172
- // suspend pump() forever (leaking the request/stream).
173
- if (!res.write(value)) {
174
- await new Promise((resolve) => {
175
- const settle = () => {
176
- res.off("drain", settle);
177
- res.off("close", settle);
178
- resolve();
179
- };
180
- res.once("drain", settle);
181
- res.once("close", settle);
182
- });
183
- }
184
- }
185
- if (!res.destroyed)
186
- res.end(); // normal completion
187
- }
188
- catch (error) {
189
- // The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
190
- // doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
191
- log.error(`[host] request failed: ${String(error)}`);
192
- // Never touch an already-terminal res: a client that disconnects during the handler await destroys res
193
- // (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
194
- // WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
195
- // with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
196
- // idempotent).
197
- if (res.destroyed)
198
- return;
199
- if (res.headersSent) {
200
- res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
201
- }
202
- else {
203
- res.writeHead(500, textHeaders); // pre-header → a clean 500
204
- res.end("internal error\n");
205
- }
206
- }
207
- }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
3
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
4
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
5
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
6
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
7
+ */
8
+ export declare function attachmentPath(filesDir: string, conversationId: string | number, fileName: string): {
9
+ dir: string;
10
+ name: string;
11
+ path: string;
12
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Where an inbound attachment lands: `<filesDir>/<conversation>/<file>`.
3
+ *
4
+ * Both halves come from outside the channel, and they are NOT the same question:
5
+ *
6
+ * - The conversation is an id, and turning an arbitrary caller id into a storage name is a job this
7
+ * repo already answers by ENCODING it, never by rejecting it (`piSessionId` in
8
+ * engines/pi/session-store.ts, same shape: a character whitelist under a fixed prefix). An id is
9
+ * the caller's — a route returning `../..` gets an odd directory, not a failed turn, because the
10
+ * place to notice that id is broken is the reply it cannot deliver, not the disk.
11
+ * - The file name is not an id: it is read by a human and by the model, in a path this puts into the
12
+ * prompt. Encoding it would cost that, so it is reduced instead — losing the odd character rather
13
+ * than the readability. A leading dot survives: `.gitignore` is a name, not an escape, and nothing
14
+ * lists this directory, so hiding is not a property anyone here depends on.
15
+ *
16
+ * The encoding makes containment structural rather than checked: `encodeURIComponent` emits no path
17
+ * separator (`/` → `%2F`, `\` → `%5C`, `:` → `%3A`), and the prefix leaves no way to spell `.`, `..`
18
+ * or the empty string.
19
+ *
20
+ * It is lossless for a well-formed id, so `a/b` and `a_b` are different places. Two exceptions, both
21
+ * outside what a platform hands over: ids differing only in malformed UTF-16 (see the substitution
22
+ * below), and ids differing only in case on a case-insensitive filesystem.
23
+ */
24
+ import { resolve, sep } from "node:path";
25
+ /**
26
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
27
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
28
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
29
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
30
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
31
+ */
32
+ export function attachmentPath(filesDir, conversationId, fileName) {
33
+ // A lone surrogate makes `encodeURIComponent` throw `URIError`, and this function rejecting an id
34
+ // is the one thing it must not do — a permanent bad id would surface as a retryable turn failure.
35
+ // The `u` flag matches by code point, so a valid pair (an emoji in a route's id) is left alone.
36
+ const id = String(conversationId).replace(/\p{Surrogate}/gu, "\uFFFD");
37
+ const dir = resolve(filesDir, `c-${encodeURIComponent(id)}`);
38
+ // The name keeps its own check because it is NOT encoded: separators go, and `resolve` settles
39
+ // whether what is left (`..`, a Windows `D:foo`) still lands in `dir`.
40
+ const cleaned = fileName.replace(/[/\\]/g, "_");
41
+ const name = resolve(dir, cleaned).startsWith(dir + sep) ? cleaned : "file";
42
+ return { dir, name, path: resolve(dir, name) };
43
+ }
@@ -9,6 +9,15 @@ export declare const BUFFER_LINE_MAX_CHARS = 280;
9
9
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
10
10
  * each channel's attachment collector caps against this. */
11
11
  export declare const BUFFER_ATTACH_MAX = 3;
12
+ /**
13
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
14
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
15
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
16
+ *
17
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
18
+ * different claim about a different place, not this block with another name.
19
+ */
20
+ export declare function discussionBlock(text: string): string;
12
21
  export interface ContextBuffer<E> {
13
22
  /** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
14
23
  push(placeKey: string, entry: E): void;