@fastagent-sh/fastagent 0.19.0 → 0.21.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
@@ -5,10 +5,9 @@
5
5
  * Engine-neutral, and living here rather than under `engines/` because of it: reading `channels/*.ts`
6
6
  * is the Channel contract plus a directory, with no engine in sight.
7
7
  */
8
- import { readdir } from "node:fs/promises";
9
8
  import { isAbsolute, join } from "node:path";
10
9
  import { assertRouteKey, routeKeysConflict } from "./serve.js";
11
- import { isModuleFile, loadModuleDir } from "../loader.js";
10
+ import { loadModuleDir, moduleInventory } from "../loader.js";
12
11
  import { assertInsideAgentDir } from "../paths.js";
13
12
  function longConnectionModule(value) {
14
13
  return value !== null && typeof value === "object" && typeof value.connect === "function";
@@ -18,28 +17,29 @@ function validateLongConnectionModule(value, label) {
18
17
  throw new Error(`${label}: long-connection channel name must be a non-empty string`);
19
18
  }
20
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
+ }
21
25
  /**
22
26
  * Import channel files without mounting route factories or opening connections. Deployment needs only
23
- * 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
24
28
  * long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
25
29
  */
26
30
  export async function inspectChannels(dir) {
27
31
  await assertInsideAgentDir(dir, "channels");
28
32
  const { modules, failures } = await loadModuleDir(join(dir, "channels"));
29
33
  const channels = [];
30
- const routeChannels = [];
31
- const longConnectionChannels = [];
32
34
  for (const { name, label, file, mod } of modules) {
33
35
  try {
34
36
  if (typeof mod.default === "function") {
35
- channels.push(name);
36
- routeChannels.push(name);
37
+ channels.push({ name, ingress: "webhook" });
37
38
  continue;
38
39
  }
39
40
  if (longConnectionModule(mod.default)) {
40
41
  validateLongConnectionModule(mod.default, label);
41
- channels.push(name);
42
- longConnectionChannels.push(name);
42
+ channels.push({ name, ingress: "long-connection" });
43
43
  continue;
44
44
  }
45
45
  throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
@@ -48,7 +48,7 @@ export async function inspectChannels(dir) {
48
48
  failures.push({ label, file, message: error.message });
49
49
  }
50
50
  }
51
- return { channels, routeChannels, longConnectionChannels, failures };
51
+ return { channels, failures };
52
52
  }
53
53
  /**
54
54
  * Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
@@ -56,19 +56,8 @@ export async function inspectChannels(dir) {
56
56
  */
57
57
  export async function discoverChannelFiles(dir) {
58
58
  await assertInsideAgentDir(dir, "channels");
59
- let names;
60
- try {
61
- names = await readdir(join(dir, "channels"));
62
- }
63
- catch (error) {
64
- if (error.code === "ENOENT")
65
- return [];
66
- throw error;
67
- }
68
- return names
69
- .filter(isModuleFile)
70
- .map((name) => name.replace(/\.(ts|js|mjs)$/, ""))
71
- .sort();
59
+ const entries = await moduleInventory(join(dir, "channels"));
60
+ return entries.map((entry) => entry.name);
72
61
  }
73
62
  function validateRoutes(value, label) {
74
63
  if (value === null || typeof value !== "object" || value instanceof Map) {
@@ -96,7 +85,6 @@ export async function loadChannels(dir, ctx) {
96
85
  const routes = {};
97
86
  const longConnections = [];
98
87
  const routeChannels = [];
99
- const longConnectionChannels = [];
100
88
  const collisions = [];
101
89
  for (const { name, label, file, mod } of modules) {
102
90
  try {
@@ -107,7 +95,6 @@ export async function loadChannels(dir, ctx) {
107
95
  name: channel.name,
108
96
  connect: (signal) => channel.connect(ctx, signal),
109
97
  });
110
- longConnectionChannels.push(name);
111
98
  continue;
112
99
  }
113
100
  if (typeof mod.default !== "function") {
@@ -135,5 +122,5 @@ export async function loadChannels(dir, ctx) {
135
122
  failures.push({ label, file, message: error.message });
136
123
  }
137
124
  }
138
- return { routes, longConnections, routeChannels, longConnectionChannels, collisions, failures };
125
+ return { routes, longConnections, routeChannels, collisions, failures };
139
126
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
+ * ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
+ * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
+ * concrete thread root) and folded into the next answered turn in that place.
6
+ */
1
7
  import { type ContextBuffer } from "../kit/context-buffer.ts";
2
8
  import type { NormalizedFeishuMessage } from "./model.ts";
3
9
  export interface FeishuBufferedResource {
@@ -4,9 +4,7 @@
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
- import { log } from "../../log.js";
8
7
  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
8
  import { truncateCodePointPrefix } from "../kit/text.js";
11
9
  function bufferLine(entry) {
12
10
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
@@ -80,43 +78,7 @@ function isEntry(value) {
80
78
  resources(entry.files) &&
81
79
  resources(entry.images));
82
80
  }
83
- /**
84
- * Buckets from the pre-participant-model keying (`<chat>:root:<root_id>`) can never be produced again —
85
- * a place is `<chat>` or `<chat>:thread:<thread_id>` — so nothing could ever fold or clear them, and
86
- * they would hold chat content on disk forever. Dropped here, before the buffer loads, so the shared
87
- * kernel never learns about a key shape one channel retired.
88
- *
89
- * TWO one-time losses, both accepted and both logged by count. (1) The retired shape covered every
90
- * thread bucket and every main-chat quoted-reply bucket, so buffered discussion in threads does not
91
- * survive the upgrade — it becomes unreachable BECAUSE of the re-keying, not before it. (2)
92
- * `turns.json` persists each in-flight turn's `bufferKey` verbatim and this runs before turn recovery,
93
- * so a turn spanning the upgrade finds its bucket already gone. Sparing referenced keys would couple
94
- * the buffer to the turn store to protect a single upgrade, and would not help (1) at all.
95
- *
96
- * PERMANENT, unlike the `owned-threads.json` cleanup it otherwise resembles. That one leaves an inert
97
- * orphan file, so deleting it a release later is free; this one is what stops user chat content
98
- * lingering, and a deployment that skips from before the model to well after it would never run an
99
- * expired version of this code. The standing cost is one key scan at load, and nothing when no retired
100
- * key is present.
101
- */
102
- function dropRetiredBuckets(path, label) {
103
- const raw = loadStateFile(path);
104
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
105
- return;
106
- const live = Object.entries(raw).filter(([placeKey]) => !placeKey.includes(":root:"));
107
- const dropped = Object.keys(raw).length - live.length;
108
- if (dropped === 0)
109
- return;
110
- log.info(`${label} dropped ${dropped} context bucket(s) with a retired key shape`);
111
- try {
112
- saveStateFile(path, Object.fromEntries(live));
113
- }
114
- catch (error) {
115
- log.warn(`${label} could not rewrite ${path} after dropping retired buckets: ${String(error)}`);
116
- }
117
- }
118
81
  export function createFeishuContextBuffer(path, label) {
119
- dropRetiredBuckets(path, label);
120
82
  return createGenericContextBuffer({
121
83
  path,
122
84
  label,
@@ -1,5 +1,3 @@
1
- /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
2
- export declare function timingSafeEqualStr(a: string, b: string): boolean;
3
1
  /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
4
2
  * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
5
3
  * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
@@ -14,13 +14,8 @@
14
14
  * Comparisons are constant-time (timingSafeEqual) so neither the signature check nor the verification-
15
15
  * token check leaks a timing signal.
16
16
  */
17
- import { createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
18
- /** Constant-time string equality (padded to equal length first — timingSafeEqual demands it). */
19
- export function timingSafeEqualStr(a, b) {
20
- const ab = Buffer.from(a);
21
- const bb = Buffer.from(b);
22
- return ab.length === bb.length && timingSafeEqual(ab, bb);
23
- }
17
+ import { createDecipheriv, createHash } from "node:crypto";
18
+ import { secretEquals } from "../secret.js";
24
19
  /** Decrypt an `{"encrypt": …}` event payload to its plaintext JSON string. Throws on malformed
25
20
  * input or invalid padding. AES-CBC is not authenticated, so a wrong key is not mathematically
26
21
  * guaranteed to fail padding; the caller verifies signed events before decrypting and JSON-parses every
@@ -39,5 +34,5 @@ export function eventSignature(encryptKey, timestamp, nonce, rawBody) {
39
34
  }
40
35
  /** Whether a request's signature headers verify against the raw body (constant-time). */
41
36
  export function verifySignature(encryptKey, headers, rawBody) {
42
- return timingSafeEqualStr(eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody), headers.signature);
37
+ return secretEquals(headers.signature, eventSignature(encryptKey, headers.timestamp, headers.nonce, rawBody));
43
38
  }
@@ -14,13 +14,11 @@
14
14
  * 5. Every failure is a {@link FeishuApiError} naming the call; self-description is a property of the
15
15
  * error type, not per-call-site string assembly.
16
16
  *
17
- * On top of the pipeline sit thin typed methods (send/reply/edit/card/resource) adding one is adding
18
- * a wrapper, not wire code. SDK tripwire: if this surface ever needs WebSocket long-connection ingress
19
- * or grows past ~a dozen methods, adopt @larksuiteoapi/node-sdk instead of growing it — the methods
20
- * here are shape-compatible with the SDK's `client.im.*` style, so the policy layer survives that swap.
17
+ * Typed methods cover messages, cards, resources and app configuration. Proactive tools share this
18
+ * transport through shared-api.ts. The official SDK handles WebSocket ingress in ws-ingress.ts.
21
19
  */
22
20
  import { mkdir, writeFile } from "node:fs/promises";
23
- import { join } from "node:path";
21
+ import { attachmentPath } from "../kit/attachment-path.js";
24
22
  import { utf8Prefix } from "../kit/text.js";
25
23
  /** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
26
24
  const API_TIMEOUT_MS = 30_000;
@@ -283,13 +281,10 @@ export function createFeishuApi(opts) {
283
281
  },
284
282
  async fetchFile(messageId, fileKey, name, chatId, filesDir) {
285
283
  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 };
284
+ const dest = attachmentPath(filesDir, chatId, name);
285
+ await mkdir(dest.dir, { recursive: true });
286
+ await writeFile(dest.path, bytes);
287
+ return { path: dest.path, name: dest.name, size: bytes.byteLength };
293
288
  },
294
289
  async getAppConfig(appId) {
295
290
  // v6 app detail — the one read surface that returns the event-security material (under data.app).
@@ -12,15 +12,18 @@ 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 { secretEquals } from "../secret.js";
15
16
  import { createSeenRing } from "../kit/seen.js";
16
17
  import { createTaskTracker } from "../kit/tasks.js";
17
18
  import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
19
+ import { signatureIsFresh } from "../kit/signature.js";
18
20
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
19
- import { createTurnQueue } from "../kit/turn-queue.js";
21
+ import { createTurnRunner } from "../kit/turn-runner.js";
20
22
  import { createTurnStore } from "../kit/turn-store.js";
23
+ import { discussionBlock } from "../kit/context-buffer.js";
21
24
  import { FEISHU_CLOUD } from "./cloud.js";
22
25
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
23
- import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
26
+ import { decryptEvent, verifySignature } from "./crypto.js";
24
27
  import { invokeFeishuTurn } from "./invoke-turn.js";
25
28
  import { createFeishuApi } from "./feishu-api.js";
26
29
  import { normalizeFeishuMessage } from "./normalize.js";
@@ -29,14 +32,20 @@ import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE
29
32
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
30
33
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
31
34
  import { connectFeishuWs } from "./ws-ingress.js";
35
+ import { registerFeishuApi } from "./shared-api.js";
32
36
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
33
37
  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
38
  /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
39
39
  const MAX_EVENT_BYTES = 1 << 20;
40
+ /**
41
+ * Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
42
+ * 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
43
+ * is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
44
+ * to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
45
+ * turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
46
+ * enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
47
+ */
48
+ const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
40
49
  /** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
41
50
  * accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
42
51
  * is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
@@ -72,16 +81,6 @@ export function feishuChannel(opts) {
72
81
  export function feishuWebSocketChannel(opts) {
73
82
  return buildFeishuWebSocketChannel(FEISHU_CLOUD, opts, feishuWebSocketChannel.name);
74
83
  }
75
- /** The participant model removed the session modes (docs/design/participant-model.md §12). An upgraded
76
- * workspace still passing one would otherwise start fine and silently get different placement AND a
77
- * different memory boundary — the one breaking change most likely to be hit, and invisible. */
78
- function rejectRemovedSessionOptions(opts, factoryName) {
79
- const removed = ["directMessageSession", "groupMessageSession"].filter((name) => opts[name] !== undefined);
80
- if (removed.length > 0) {
81
- throw new Error(`${factoryName} no longer accepts ${removed.join(" / ")}: a chat is one session and a thread is another, ` +
82
- "and the summon rule no longer depends on the mode — remove the option (see docs/design/participant-model.md)");
83
- }
84
- }
85
84
  function createFeishuRuntimeFactory(profile, opts, factoryName) {
86
85
  const { appId, appSecret, route, onError, queueNoticeDelayMs = QUEUE_NOTICE_DELAY_MS } = opts;
87
86
  const baseUrl = opts.apiBaseUrl ?? profile.apiBase;
@@ -220,11 +219,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
220
219
  });
221
220
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
222
221
  // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
223
- const sideTasks = createTaskTracker();
224
- const toStored = (r) => {
225
- const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
226
- return { ...intent, attempts: 0 };
227
- };
222
+ const sideTasks = createTaskTracker(label);
228
223
  const targetOf = (r) => ({
229
224
  chatId: r.chatId,
230
225
  replyTo: r.replyTo,
@@ -235,18 +230,28 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
235
230
  replyTo: r.queueReplyTo,
236
231
  replyInThread: r.replyInThread,
237
232
  });
238
- // In-memory: the pending queue-preview mount per turn. Immediate by default; with an explicit delay,
239
- // it mounts only if the turn is still waiting when the timer fires and is cancelled unsent otherwise.
240
- // `done` settles either way, awaited at dequeue so the runner reliably receives the mounted preview
241
- // instead of racing it and double-posting.
242
- const notices = new Map();
243
- const queue = createTurnQueue({
233
+ // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
234
+ // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
235
+ // leaving it pinned at "Queued" while sending a separate failure would double-post.
236
+ const notifyDropped = (r) => {
237
+ const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
238
+ void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
239
+ };
240
+ const runner = createTurnRunner({
244
241
  label,
245
- // Queue feedback: when this session already has a turn running/queued, a silent wait reads as
246
- // "the bot ignored me" once the current turn runs long — mount that turn's preview early with a
247
- // queue status. It reply-quotes the exact source message (including p2p), then the runner mutates
248
- // the SAME card/text into Thinking final answer. Best-effort and post-ACK: a failed mount is a
249
- // log line, never a failed event delivery; the turn later mounts its normal preview.
242
+ store,
243
+ buffer,
244
+ seen,
245
+ toStored: ({ preview: _live, ...intent }) => ({ ...intent, attempts: 0 }),
246
+ fromStored: ({ attempts: _a, ...intent }) => ({ ...intent, preview: undefined }),
247
+ bufferKey: (rec) => rec.bufferKey,
248
+ where: (rec) => `chat=${rec.chatId}`,
249
+ // Queue feedback: mount that turn's preview early with a queue status. It reply-quotes the exact
250
+ // source message (including p2p), then the runner mutates the SAME card/text into Thinking →
251
+ // final answer. Immediate by default; with an explicit delay it mounts only if the turn is still
252
+ // waiting when the timer fires and is cancelled unsent otherwise. Best-effort and post-ACK: a
253
+ // failed mount is a log line, never a failed event delivery; the turn later mounts its normal
254
+ // preview.
250
255
  onQueuedBehind: (rec) => {
251
256
  let fired = false;
252
257
  let settle = () => { };
@@ -264,8 +269,9 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
264
269
  const timer = queueNoticeDelayMs > 0 ? setTimeout(mount, queueNoticeDelayMs) : undefined;
265
270
  if (timer === undefined)
266
271
  mount();
267
- notices.set(rec.id, {
268
- // Cancel is a no-op once mounting started — the send is in flight and `done` settles with it.
272
+ return {
273
+ done,
274
+ // A no-op once mounting started — the send is in flight and `done` settles with it.
269
275
  cancel: () => {
270
276
  if (!fired) {
271
277
  if (timer !== undefined)
@@ -273,109 +279,47 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
273
279
  settle();
274
280
  }
275
281
  },
276
- done,
277
- });
282
+ };
278
283
  },
279
- run: async (rec) => {
280
- // Runs at DEQUEUE time (serialized). The turn's queue wait is over: cancel a not-yet-mounted
281
- // preview (fast turnover skips the Queued frame), then settle so rec.preview is final — in the
282
- // common path this await is instant. BEFORE the ceiling check so drop/defer can take it over too.
283
- const notice = notices.get(rec.id);
284
- notice?.cancel();
285
- await notice?.done;
286
- notices.delete(rec.id);
287
- // Count this execution against the durable record (poison-turn ceiling) before running it again.
288
- const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
289
- if (decision === "exceeded") {
290
- notifyDropped(rec);
291
- return;
292
- }
293
- if (decision === "defer") {
294
- // Couldn't record the attempt (disk failure): skip this cycle; a restart replays it. Do not
295
- // recall an existing queue preview (the client exposes a confusing tombstone): settle it in
296
- // place to an honest delayed status. The eventual replay mounts a fresh preview.
297
- if (rec.preview !== undefined) {
298
- void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
299
- }
300
- return;
284
+ // Do not recall an existing queue preview (the client exposes a confusing tombstone): settle it in
285
+ // place to an honest delayed status. The eventual replay mounts a fresh preview.
286
+ onDeferred: (rec) => {
287
+ if (rec.preview !== undefined) {
288
+ void settleFeishuPreview(api, targetOf(rec), rec.preview, DEFERRED_PLACEHOLDER).catch((e) => log.warn(`${label} could not update a deferred turn's queue preview: ${String(e)}`));
301
289
  }
302
- const startedAt = Date.now();
303
- log.info(`${label} turn start: turn=${rec.id} session=${rec.session} chat=${rec.chatId}`);
304
- // Snapshot background discussion at dequeue. Commit only this snapshot on `completed`, so a
305
- // message arriving while the turn runs remains buffered for the next answered turn.
306
- // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
307
- // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
308
- // would instead couple otherwise-independent root sessions and require failure rollback.
309
- const { text: recent, consumed } = buffer.peek(rec.bufferKey);
290
+ },
291
+ notifyDropped,
292
+ execute: (rec, discussion, onCompleted) => {
310
293
  // PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
294
+ // ponytail: independent threaded roots in one main chat dequeue concurrently and may both fold
295
+ // the thread's snapshot before either commits it. That fan-out loses nothing; claiming by buffer
296
+ // key would instead couple otherwise-independent root sessions and require failure rollback.
311
297
  const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
312
298
  const roomBlock = room?.text
313
299
  ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
314
300
  : "";
315
- const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
316
- const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
301
+ const prompt = `${roomBlock}${discussionBlock(discussion.text)}${rec.baseText}`;
317
302
  // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
318
303
  // attachments win the slots.
319
- const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
304
+ const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...discussion.consumed], {
320
305
  images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
321
306
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
322
307
  });
323
308
  // Recorded at ingress (see submit) — never re-derived from the session key, which may be a
324
309
  // routed OPAQUE id that only looks like a place key.
325
310
  const parentSession = rec.parentSession;
326
- try {
327
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
328
- api,
329
- chatId: rec.chatId,
330
- filesDir: join(stateHome, "files"),
331
- label,
332
- appId,
333
- ...(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
- log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
341
- }
342
- catch (error) {
343
- log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
344
- }
345
- finally {
346
- // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
347
- // never reach the completed hook above. Idempotent — a second remove is a no-op. Only an
348
- // INTERRUPTED run (this finally never runs — a crash or SIGTERM deploy) leaves the record for
349
- // replay; a transport throw is dropped, not retried (safe retry needs an L2 delivery key).
350
- store.remove(rec.id);
351
- }
311
+ return streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
312
+ api,
313
+ chatId: rec.chatId,
314
+ filesDir: join(stateHome, "files"),
315
+ label,
316
+ appId,
317
+ ...(parentSession !== undefined ? { parentSession } : {}),
318
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, onCompleted), api, targetOf(rec), formatError, rec.preview, label);
352
319
  },
353
320
  });
354
- // Accept a turn: persist its intent before the ACK, then record the platform delivery id and enqueue
355
- // it. The ordering is deliberate: recording first could turn a failed intent write into silent loss
356
- // when the platform redelivers. Recovery re-enqueues a crash survivor without re-persisting it.
357
- const submit = (rec, persist) => {
358
- if (persist) {
359
- store.add(toStored(rec)); // failed write → HTTP/WS 500 → platform re-push
360
- seen.add(rec.id); // post-persist, best-effort protection from documented duplicate pushes
361
- }
362
- queue.accept(rec);
363
- };
364
- // Tell the asker when a turn is dropped at the execution ceiling: the chain's end needs a signal,
365
- // not just an operator log line. Take over its queue preview in place if present (else send fresh) —
366
- // leaving it pinned at "Queued" while sending a separate failure would double-post.
367
- const notifyDropped = (r) => {
368
- const body = "⚠️ I couldn’t complete an earlier request — please ask again.";
369
- void settleFeishuPreview(api, targetOf(r), r.preview, body).catch((e) => log.warn(`${label} could not notify a dropped turn (session=${r.session}): ${String(e)}`));
370
- };
371
- // Re-enqueue turns a prior crash left mid-flight (ACKed but unfinished). Synchronous at construction:
372
- // the queue runs them on the next tick. The execution ceiling is enforced per turn at dequeue.
373
- const recovered = store.recover();
374
- if (recovered.length > 0)
375
- log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
376
- let seqCounter = recovered.reduce((max, r) => Math.max(max, r.seq), 0);
377
- for (const { attempts: _a, ...intent } of recovered)
378
- submit({ ...intent, preview: undefined }, false);
321
+ registerFeishuApi(stateRoot, kind, api);
322
+ let seqCounter = runner.recover().reduce((max, r) => Math.max(max, r.seq), 0);
379
323
  // Who the agent has heard in a thread decides whether a bare message addresses it (participant
380
324
  // model §3), and it comes from what this channel observed — see thread-participants.ts.
381
325
  let warnedUnidentified = false;
@@ -523,7 +467,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
523
467
  const baseText = r.text ?? cloudEnvelope(event, kind);
524
468
  if (baseText.trim() === "" && images.length === 0 && files.length === 0)
525
469
  return;
526
- submit({
470
+ runner.submit({
527
471
  id: m.message_id,
528
472
  seq: ++seqCounter, // arrival order; the turn store replays by it
529
473
  session,
@@ -576,13 +520,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
576
520
  threadParticipants.merge(heard.key, { agentSpoke: true, humans: [heard.speaker] });
577
521
  }
578
522
  };
579
- return { acceptEvent, turnsIdle: () => Promise.all([queue.idle(), sideTasks.drain()]).then(() => undefined) };
523
+ return { acceptEvent, turnsIdle: () => Promise.all([runner.idle(), sideTasks.drain()]).then(() => undefined) };
580
524
  };
581
525
  }
582
526
  function createFeishuWebhookRoutes(profile, opts, runtime) {
583
527
  const { verificationToken, encryptKey } = opts;
584
528
  const { kind, envPrefix } = profile;
585
529
  const label = `[${kind}]`;
530
+ if (!encryptKey) {
531
+ // Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
532
+ // carry no signature, so the freshness window below never runs and a captured body replays for
533
+ // as long as the verification token lives.
534
+ log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
535
+ }
586
536
  const handler = async (req) => {
587
537
  if (req.method !== "POST")
588
538
  return text("POST only\n", 405);
@@ -609,9 +559,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
609
559
  nonce: req.headers.get("x-lark-request-nonce") ?? "",
610
560
  signature: req.headers.get("x-lark-signature") ?? "",
611
561
  };
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);
562
+ if (sig.signature) {
563
+ // Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
564
+ // it, so without a window a captured body + its three x-lark-* headers replays forever. The
565
+ // `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
566
+ // turn (a re-sent message, a re-fired tool).
567
+ if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
568
+ log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
569
+ return text("stale signature\n", 401);
570
+ }
571
+ if (!verifySignature(encryptKey, sig, body.text)) {
572
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
573
+ return text("invalid signature\n", 401);
574
+ }
615
575
  }
616
576
  try {
617
577
  envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
@@ -639,7 +599,7 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
639
599
  (typeof envelope.header?.token === "string"
640
600
  ? envelope.header.token
641
601
  : undefined);
642
- if (!token || !timingSafeEqualStr(token, verificationToken)) {
602
+ if (!secretEquals(token, verificationToken)) {
643
603
  log.warn(`${label} rejected an event: verification token mismatch (check ${envPrefix}_VERIFICATION_TOKEN against the console)`);
644
604
  return text("invalid token\n", 401);
645
605
  }
@@ -659,7 +619,6 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
659
619
  return { [`POST /${kind}`]: handler };
660
620
  }
661
621
  export function buildFeishuChannel(profile, opts, factoryName) {
662
- rejectRemovedSessionOptions(opts, factoryName);
663
622
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
664
623
  return (ctx) => {
665
624
  if (!opts.verificationToken) {
@@ -669,7 +628,6 @@ export function buildFeishuChannel(profile, opts, factoryName) {
669
628
  };
670
629
  }
671
630
  export function buildFeishuWebSocketChannel(profile, opts, factoryName, deps = {}) {
672
- rejectRemovedSessionOptions(opts, factoryName);
673
631
  const createRuntime = createFeishuRuntimeFactory(profile, opts, factoryName);
674
632
  return {
675
633
  name: `${profile.kind} websocket`,
@@ -35,9 +35,10 @@ export declare function senderId(sender: FeishuSender | undefined): string | und
35
35
  *
36
36
  * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
37
37
  * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
38
- * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
39
- * percent-encodes it, so each `:` costs three), and the worst case here brand + a 35-char chat id +
40
- * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
38
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
39
+ * `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
40
+ * three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
41
+ * under 100 bytes against the filesystem's 255.
41
42
  */
42
43
  export declare function placeKey(kind: string, message: Pick<FeishuMessage, "chat_id" | "thread_id">): string;
43
44
  /** The canonical Feishu-branded prompt envelope. */
@@ -30,9 +30,10 @@ export function senderId(sender) {
30
30
  *
31
31
  * Branded with the channel kind, like Slack's twin, because session ids share ONE namespace across
32
32
  * every channel in a deployment: without it a `feishu` and a `lark` chat carrying the same platform id
33
- * would answer into the same memory. The length bound is the FILENAME the id becomes (sessions.ts
34
- * percent-encodes it, so each `:` costs three), and the worst case here brand + a 35-char chat id +
35
- * a 36-char thread id — encodes to well under 100 bytes against the filesystem's 255.
33
+ * would answer into the same memory. The length bound is the FILENAME the id becomes (session-store.ts
34
+ * `piSessionId` escapes every character outside `[A-Za-z0-9.-]` as `_XX` / `_uXXXX`, so each `:` costs
35
+ * three), and the worst case here — brand + a 35-char chat id + a 36-char thread id — encodes to well
36
+ * under 100 bytes against the filesystem's 255.
36
37
  */
37
38
  export function placeKey(kind, message) {
38
39
  const chat = `${kind}:${message.chat_id}`;
@@ -54,7 +55,7 @@ export function cloudEnvelope(event, tag) {
54
55
  from ? `from ${from}` : undefined,
55
56
  // The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
56
57
  // enters the session transcript, and session inheritance locates a thread's branch point by
57
- // searching the parent transcript for exactly these ids (scope.branchHints — sessions.ts).
58
+ // searching the parent transcript for exactly these ids (scope.branchHints — session-inheritance.ts).
58
59
  // Remove it and every thread quietly inherits from the room's present instead of the branch
59
60
  // point. It also lets the model name what it is answering in a busy chat.
60
61
  `msg ${message.message_id}`,