@animalabs/agent-framework 0.22.3 → 0.23.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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -1
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -46,12 +46,15 @@ import type {
46
46
  } from './types.js';
47
47
  import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
48
48
  import { logValue } from '../../mcpl/log-value.js';
49
+ import { unknownToolMessage } from '../unknown-tool.js';
50
+ import { DiscordRefusal } from './refusal.js';
49
51
 
50
52
  // Re-export event types for consumers who want to handle them
51
53
  export type { DiscordMessageEvent, DiscordEditEvent, DiscordDeleteEvent } from './types.js';
52
54
 
53
55
  export * from './types.js';
54
56
  export { DiscordJsClient, type DiscordJsClientConfig } from './discord-js-client.js';
57
+ export { DiscordRefusal } from './refusal.js';
55
58
 
56
59
  const DEFAULT_CONFIG: Partial<DiscordModuleConfig> = {
57
60
  handleDMs: true,
@@ -107,10 +110,57 @@ const TEXT_FILE_EXTENSIONS = [
107
110
 
108
111
  const CONVERSATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
109
112
 
113
+ /** A text attachment this many bytes or larger isn't read into the agent's context. */
114
+ const TEXT_FILE_LIMIT_BYTES = 100_000;
115
+
116
+ /** activate's and deactivate's answer before the module has started, or once it has stopped. */
117
+ const NOT_RUNNING = "Speech handling wasn't changed: the Discord module hasn't started on this host, or has stopped. Calling it again won't change that until it runs; ask whoever runs this host if you need Discord.";
118
+
110
119
  /** What the agent reads for a text attachment whose contents couldn't be
111
120
  * fetched: by an error, or by an answer that wasn't OK. */
112
121
  function attachmentNotFetched(filename: string): { type: 'text'; text: string } {
113
- return { type: 'text', text: `[Attachment: ${filename} (failed to fetch)]` };
122
+ return {
123
+ type: 'text',
124
+ text: `[Attachment: ${filename} couldn't be fetched, so its text isn't in your context. If you need it, ask the sender in the channel to paste it.]`,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * What the agent reads when a tool stops on an error that isn't a refusal
130
+ * (DiscordRefusal), such as a network failure: its outcome is unknown. A send
131
+ * repeated blindly can post twice, so those say how to check; the other tools
132
+ * are safe to repeat, since a second call changes nothing more.
133
+ */
134
+ function unexpectedFailure(call: ToolCall, error: unknown): string {
135
+ const stopped = `discord--${call.name} stopped on an unexpected error: ${errorText(error)}.`;
136
+ const channelId = (call.input as { channelId?: unknown } | null | undefined)?.channelId;
137
+ switch (call.name) {
138
+ case 'send':
139
+ return `${stopped} Whether the message was sent is unknown: discord--fetch_history with channelId ${String(channelId)} shows it, so check before you send it again.`;
140
+ case 'dm':
141
+ return `${stopped} Whether the message was sent is unknown. If an earlier discord--dm to this user gave you its channelId, discord--fetch_history with it shows whether this one went; without it, sending it again may send it twice.`;
142
+ case 'edit':
143
+ case 'react':
144
+ case 'delete':
145
+ case 'create_thread':
146
+ return `${stopped} Whether it took effect is unknown. Calling it again is safe: if it did, the second call changes nothing more.`;
147
+ case 'list_guilds':
148
+ case 'list_channels':
149
+ case 'fetch_history':
150
+ return `${stopped} It changes nothing on Discord, so you can call it again.`;
151
+ default:
152
+ return `${stopped} Calling it again is safe.`;
153
+ }
154
+ }
155
+
156
+ /** An error's own words, for a sentence about it. */
157
+ function errorText(error: unknown): string {
158
+ return error instanceof Error ? error.message : String(error);
159
+ }
160
+
161
+ /** A count of seconds, for the agent's eyes. */
162
+ function seconds(n: number): string {
163
+ return `${n} second${n === 1 ? '' : 's'}`;
114
164
  }
115
165
 
116
166
  /** What a stored message reads when it has no text of its own and nothing the
@@ -229,6 +279,79 @@ function holdsNotFetched(content: ContentBlock[], message: HistoryMessage): bool
229
279
  return content.slice(1).some((block) => block.type === 'text' && notes.has(block.text));
230
280
  }
231
281
 
282
+ /** A URL without its query or fragment: Discord's CDN signs an attachment's
283
+ * URL afresh in each payload, so only its path names the attachment. */
284
+ function urlPath(url: string): string {
285
+ return url.split(/[?#]/)[0] ?? url;
286
+ }
287
+
288
+ /**
289
+ * Whether a block the host added for an attachment stands for none of
290
+ * `attachments`, matched as the live conversion writes it: an image by its
291
+ * URL, a file's text by its header, and a note by the filename it opens
292
+ * with, as every note for an attachment does, whatever its words after it.
293
+ * A block that stands for no attachment, such as EMPTY_MESSAGE, never does.
294
+ */
295
+ function forGoneAttachment(block: ContentBlock, attachments: readonly DiscordAttachment[]): boolean {
296
+ if (block.type === 'image') {
297
+ const source = (block as { source?: { type?: string; url?: unknown } }).source;
298
+ if (source?.type !== 'url' || typeof source.url !== 'string') return false;
299
+ const path = urlPath(source.url);
300
+ return !attachments.some((attachment) => urlPath(attachment.url) === path);
301
+ }
302
+ if (block.type !== 'text') return false;
303
+ const { text } = block;
304
+ const opening = text.startsWith('\n--- File: ') ? (filename: string) => `\n--- File: ${filename} ---\n`
305
+ : text.startsWith('[Attachment: ') ? (filename: string) => `[Attachment: ${filename} `
306
+ : null;
307
+ return opening !== null && !attachments.some((attachment) => text.startsWith(opening(attachment.filename)));
308
+ }
309
+
310
+ /**
311
+ * A text-first row's content without the blocks the host added for
312
+ * attachments its message no longer has, or `content` itself when none is
313
+ * gone. A block stays while an attachment it may stand for remains, so of two
314
+ * files of one name, removing one keeps both blocks, and a block that names
315
+ * no attachment stays. EMPTY_MESSAGE comes back once nothing is left.
316
+ */
317
+ function withoutGoneAttachments(content: ContentBlock[], attachments: readonly DiscordAttachment[]): ContentBlock[] {
318
+ const [first, ...rest] = content;
319
+ if (first?.type !== 'text') return content;
320
+ const kept = rest.filter((block) => !forGoneAttachment(block, attachments));
321
+ if (kept.length === rest.length) return content;
322
+ return textFirst(first.text, kept);
323
+ }
324
+
325
+ /** The ids to remove for a message's rows: each row once, and a body stored
326
+ * in shards once, since removing one shard takes out its whole body. */
327
+ function oncePerBody(rows: readonly StoredMessage[]): string[] {
328
+ const bodies = new Set<string>();
329
+ const ids: string[] = [];
330
+ for (const row of rows) {
331
+ if (row.bodyGroupId) {
332
+ if (bodies.has(row.bodyGroupId)) continue;
333
+ bodies.add(row.bodyGroupId);
334
+ }
335
+ ids.push(row.id);
336
+ }
337
+ return ids;
338
+ }
339
+
340
+ /** A live create on its way to the store (DiscordModule.creating). */
341
+ interface LiveCreate {
342
+ /** converting: the live path fetches its attachments; queued: its event
343
+ * waits in the queue; writing: onProcess answered it, and each Discord
344
+ * event after it looks for the message's row, and ends the entry once it
345
+ * shows. */
346
+ phase: 'converting' | 'queued' | 'writing';
347
+ /** A delete for the message came before its create was handled, so no row
348
+ * comes, and the entry waits for that delete to be handled. */
349
+ deleted: boolean;
350
+ /** The latest edit's text and attachments, from while it converted. */
351
+ text?: string;
352
+ attachments?: DiscordAttachment[];
353
+ }
354
+
232
355
  /**
233
356
  * Discord module for the agent framework.
234
357
  */
@@ -269,6 +392,22 @@ export class DiscordModule implements Module {
269
392
  // those messages alone.
270
393
  private readonly liveDuringSync = new Set<Set<string>>();
271
394
 
395
+ // The Discord messages whose live create is on its way to the store: from
396
+ // the moment the live path takes one up, through its conversion and the
397
+ // event queue, until a query shows the row onProcess answered with, stored
398
+ // or held. The framework holds a module's writes while the agent's turn is
399
+ // alive or the host is quiesced, and queryMessages shows a held write's
400
+ // row, marked held, so the next Discord event finds it either way and ends
401
+ // its entry. A history sync stores none of these messages, so no message is
402
+ // stored twice. A delete before the create is handled drops it: nothing is
403
+ // stored and nobody is woken. Any message's entry ends when its delete is
404
+ // handled, since a sync running then skips the message (noteLive) and a
405
+ // later one doesn't fetch it. An edit while it converts is folded into it.
406
+ // A create whose answer the framework fails to write or hold shows no row,
407
+ // and keeps its entry, and the sync off its message, until its delete is
408
+ // handled or the module stops.
409
+ private readonly creating = new Map<string, LiveCreate>();
410
+
272
411
  // Typing keepalive: agentName -> { interval, deadline }. Started on
273
412
  // inference:started, stopped on the turn's terminal trace event. Discord's
274
413
  // typing indicator auto-expires after ~10s, so we re-trigger on an interval
@@ -316,7 +455,7 @@ export class DiscordModule implements Module {
316
455
 
317
456
  // Set up Discord event handlers
318
457
  this.client.onMessage((message) => this.handleDiscordMessage(message));
319
- this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
458
+ this.client.onMessageEdit((id, content, attachments) => this.handleDiscordMessageEdit(id, content, attachments));
320
459
  this.client.onMessageDelete((id) => this.handleDiscordMessageDelete(id));
321
460
 
322
461
  // Set up ready handler for history sync on (re)connect
@@ -355,8 +494,14 @@ export class DiscordModule implements Module {
355
494
  }
356
495
  this.typingLoops.clear();
357
496
 
358
- await this.client.disconnect();
497
+ // From here the module writes nothing, though the framework may dispatch
498
+ // to it until stop() resolves: the live path takes nothing up, a create
499
+ // still converting finds the context gone, and onProcess answers nothing.
500
+ // The record of creates on their way goes with the context.
359
501
  this.ctx = null;
502
+ this.creating.clear();
503
+
504
+ await this.client.disconnect();
360
505
  }
361
506
 
362
507
  /**
@@ -546,7 +691,7 @@ export class DiscordModule implements Module {
546
691
  },
547
692
  {
548
693
  name: 'list_channels',
549
- description: 'List all channels in a Discord server',
694
+ description: 'List the channels in a Discord server, with their ids. Threads aren\'t listed.',
550
695
  inputSchema: {
551
696
  type: 'object',
552
697
  properties: {
@@ -591,7 +736,8 @@ export class DiscordModule implements Module {
591
736
  case 'set_reply_context':
592
737
  return await this.handleSetReplyContext(call.input as SetReplyContextInput);
593
738
  case 'activate':
594
- return await this.handleActivate(call.input as { additive?: boolean });
739
+ // A call with no input at all activates with the defaults.
740
+ return await this.handleActivate((call.input ?? {}) as { additive?: boolean });
595
741
  case 'deactivate':
596
742
  return await this.handleDeactivate();
597
743
  case 'list_guilds':
@@ -601,21 +747,36 @@ export class DiscordModule implements Module {
601
747
  case 'fetch_history':
602
748
  return await this.handleFetchHistory(call.input as FetchHistoryInput);
603
749
  default:
604
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
750
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
605
751
  }
606
752
  } catch (error) {
753
+ // A refusal says what was refused and what to do instead; anything else
754
+ // is unexpected, and its outcome is worded by the tool.
607
755
  return {
608
756
  success: false,
609
- error: error instanceof Error ? error.message : String(error),
757
+ error: error instanceof DiscordRefusal ? error.message : unexpectedFailure(call, error),
610
758
  isError: true,
611
759
  };
612
760
  }
613
761
  }
614
762
 
615
763
  async onProcess(event: ProcessEvent, state: ProcessState): Promise<EventResponse> {
764
+ // A stopped module answers nothing (stop()).
765
+ if (!this.ctx) return {};
766
+
767
+ // Each Discord event ends the entries whose row a query shows now, stored
768
+ // or held.
769
+ if (event.type.startsWith('discord:')) {
770
+ for (const [id, live] of this.creating) if (live.phase === 'writing' && this.storedRow(id)) this.creating.delete(id);
771
+ }
772
+
616
773
  // Handle new Discord message
617
774
  if (event.type === 'discord:message') {
618
775
  const msg = event as DiscordMessageEvent;
776
+ const live = this.creating.get(msg.discordMessageId);
777
+ // Deleted while its create waited in the queue: nothing is stored, and
778
+ // nobody is woken by a message its author took back.
779
+ if (live?.deleted) return {};
619
780
  const currentState = state.getState<DiscordModuleState>() ?? {
620
781
  connectedGuilds: [],
621
782
  conversations: {},
@@ -665,20 +826,29 @@ export class DiscordModule implements Module {
665
826
  const contentBlocks = msg.contentBlocks ?? textFirst(ownText(msg.content));
666
827
  this.noteLive(msg.discordMessageId);
667
828
 
829
+ // A message a history sync wrote first, stored or held, keeps that row:
830
+ // the create adds none, and still sets the reply context and asks for
831
+ // inference, since the message arrived live. Either way the entry waits
832
+ // until a Discord event finds the message's row.
833
+ const stored = this.storedRow(msg.discordMessageId);
834
+ if (live) live.phase = 'writing';
835
+
668
836
  // Return declarative response - framework applies all writes atomically
669
837
  return {
670
- addMessages: [
671
- {
672
- participant: msg.authorName,
673
- content: contentBlocks,
674
- metadata: {
675
- external: { source: 'discord', id: msg.discordMessageId },
676
- channelId: msg.channelId,
677
- timestamp: msg.timestamp,
678
- [TEXT_FIRST]: true,
838
+ ...(stored ? {} : {
839
+ addMessages: [
840
+ {
841
+ participant: msg.authorName,
842
+ content: contentBlocks,
843
+ metadata: {
844
+ external: { source: 'discord', id: msg.discordMessageId },
845
+ channelId: msg.channelId,
846
+ timestamp: msg.timestamp,
847
+ [TEXT_FIRST]: true,
848
+ },
679
849
  },
680
- },
681
- ],
850
+ ],
851
+ }),
682
852
  stateUpdate: newState,
683
853
  requestInference: msg.shouldTriggerInference ?? true,
684
854
  };
@@ -687,23 +857,29 @@ export class DiscordModule implements Module {
687
857
  // Handle Discord message edit
688
858
  if (event.type === 'discord:edit') {
689
859
  const edit = event as DiscordEditEvent;
690
- // The stored row, not only its id: an edit changes the message's own
691
- // text and keeps what the host added beside it.
692
- // An edit to a message nobody has stored yet is dropped: whatever
693
- // stores the message, its live add or a history sync running now,
694
- // stores the text it got, and the next sync's text rule changes it.
695
- // A row stored in shards can't be edited one shard at a time, so it
696
- // keeps its text, and the host's log says so. Only an edit that writes
697
- // marks the message live, so an update that changes nothing leaves a
698
- // running sync free to rewrite the row.
860
+ // The message's row, not only its id: an edit changes the message's own
861
+ // text and keeps what the host added beside it, but for the blocks of
862
+ // attachments the edit took away, when the client passed the edit's
863
+ // attachments and the row is text-first.
864
+ // An edit that comes while the live path converts the message's create
865
+ // is folded into that create (handleDiscordMessageEdit). Any other edit
866
+ // to a message with no row, stored or held, is dropped: a history sync
867
+ // running now stores the text it fetched, and the next sync's text rule
868
+ // changes it. A row stored in shards can't be edited one shard at a
869
+ // time, so it keeps its text, and the host's log says so. Only an edit
870
+ // that writes marks the message live, so an update that changes nothing
871
+ // leaves a running sync free to rewrite the row.
699
872
  const stored = this.storedRow(edit.discordMessageId);
700
873
  if (!stored) return {};
701
874
  if (stored.bodyGroupId) {
702
875
  console.warn(`Discord: An update to message ${edit.discordMessageId} wasn't applied: it's stored in shards, which can't be edited, so if its text changed, the agent still has the earlier text`);
703
876
  return {};
704
877
  }
705
- const content = contentForText(stored, edit.newContent);
706
- if (!content) return {};
878
+ const texted = contentForText(stored, edit.newContent);
879
+ const content = edit.attachments && stored.metadata?.[TEXT_FIRST] === true
880
+ ? withoutGoneAttachments(texted ?? stored.content, edit.attachments)
881
+ : texted;
882
+ if (!content || content === stored.content) return {};
707
883
  this.noteLive(edit.discordMessageId);
708
884
  return { editMessages: [{ messageId: stored.id, content }] };
709
885
  }
@@ -711,17 +887,19 @@ export class DiscordModule implements Module {
711
887
  // Handle Discord message delete
712
888
  if (event.type === 'discord:delete') {
713
889
  const del = event as DiscordDeleteEvent;
714
- // A row stored in shards is removed whole: the framework takes out the
715
- // body a shard belongs to. The message is the live path's even when no
716
- // row is found, so a running sync doesn't store what its fetch may
717
- // still hold.
890
+ // Every row of the message goes, so one stored twice doesn't outlive
891
+ // its delete as a copy. A row stored in shards is removed whole: the
892
+ // framework takes out the body a shard belongs to. The message is the
893
+ // live path's even when no row is found, so a running sync doesn't
894
+ // store what its fetch may still hold, and that ends its entry.
718
895
  this.noteLive(del.discordMessageId);
719
- const stored = this.storedRow(del.discordMessageId);
720
- if (!stored) {
896
+ this.creating.delete(del.discordMessageId);
897
+ const rows = this.storedRows(del.discordMessageId);
898
+ if (rows.length === 0) {
721
899
  return {};
722
900
  }
723
901
  return {
724
- removeMessages: [stored.id],
902
+ removeMessages: oncePerBody(rows),
725
903
  };
726
904
  }
727
905
 
@@ -773,7 +951,7 @@ export class DiscordModule implements Module {
773
951
  }
774
952
 
775
953
  // Check rate limit
776
- if (!this.checkRateLimit(replyCtx.channelId)) {
954
+ if (this.rateLimitWait(replyCtx.channelId) > 0) {
777
955
  console.warn(`Discord: Rate limited for channel ${replyCtx.channelId}`);
778
956
  return;
779
957
  }
@@ -890,7 +1068,8 @@ export class DiscordModule implements Module {
890
1068
 
891
1069
  /**
892
1070
  * Sync history for a specific channel - compare with Context Manager and update.
893
- * Uses queryMessages() to enumerate stored messages for proper edit/delete detection.
1071
+ * Uses queryMessages() to enumerate the channel's rows, a held write's among
1072
+ * them, for proper edit/delete detection.
894
1073
  *
895
1074
  * The live path can store, edit or remove a row whenever the sync awaits.
896
1075
  * So the sync fetches history and runs the live conversion it needs first,
@@ -940,7 +1119,7 @@ export class DiscordModule implements Module {
940
1119
  // edited one shard at a time, so it's left as it is.
941
1120
  const converted = new Map<string, ContentBlock[]>();
942
1121
  for (const discordMsg of chronologicalMessages) {
943
- const stored = before.get(discordMsg.id);
1122
+ const stored = before.get(discordMsg.id)?.[0];
944
1123
  if (stored && (stored.bodyGroupId || !needsRebuild(stored, discordMsg))) continue;
945
1124
  const content = await this.convertMessageToContent(discordMsg);
946
1125
  if (this.ctx !== ctx) return unchanged;
@@ -958,14 +1137,15 @@ export class DiscordModule implements Module {
958
1137
  let deletedMessages = 0;
959
1138
  for (const discordMsg of chronologicalMessages) {
960
1139
  if (live.has(discordMsg.id)) continue;
961
- const stored = storedMessageMap.get(discordMsg.id);
1140
+ const stored = storedMessageMap.get(discordMsg.id)?.[0];
962
1141
 
963
1142
  if (!stored) {
964
1143
  // A message we haven't stored. One that was stored when the sync
965
1144
  // began, and isn't now, was removed meanwhile: not the sync's to
966
- // bring back.
1145
+ // bring back. One the live path is storing (creating) is the live
1146
+ // path's, so it isn't stored twice.
967
1147
  const content = converted.get(discordMsg.id);
968
- if (!content || before.has(discordMsg.id)) continue;
1148
+ if (!content || before.has(discordMsg.id) || this.creating.has(discordMsg.id)) continue;
969
1149
  // For bot's own messages (from previous sessions), use the agent name
970
1150
  // This ensures continuity of conversation history
971
1151
  const isBotMessage = discordMsg.authorId === this.state.botUserId;
@@ -1004,7 +1184,8 @@ export class DiscordModule implements Module {
1004
1184
  const oldestFetchedTimestamp = discordMessages.length > 0
1005
1185
  ? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
1006
1186
  : undefined;
1007
- for (const [externalId, stored] of storedMessageMap) {
1187
+ for (const [externalId, rows] of storedMessageMap) {
1188
+ const stored = rows[0]!;
1008
1189
  const sourceTimestamp = typeof stored.metadata?.timestamp === 'number'
1009
1190
  ? stored.metadata.timestamp
1010
1191
  : undefined;
@@ -1020,8 +1201,9 @@ export class DiscordModule implements Module {
1020
1201
  // Message exists in our store but not in Discord - it was deleted
1021
1202
  // Note: This only catches deletes within the scrollback window.
1022
1203
  // A message the live path wrote while the sync ran is newer than
1023
- // the fetch, so it stays. One shard's id takes out a body whole.
1024
- ctx.removeMessage(stored.id);
1204
+ // the fetch, so it stays. Every row of the message goes, and one
1205
+ // shard's id takes out a body whole.
1206
+ for (const id of oncePerBody(rows)) ctx.removeMessage(id);
1025
1207
  deletedMessages++;
1026
1208
  }
1027
1209
  }
@@ -1040,22 +1222,37 @@ export class DiscordModule implements Module {
1040
1222
  }
1041
1223
 
1042
1224
  /** A channel's stored Discord rows by their Discord message id, whole: an
1043
- * edit replaces a row's own text and keeps what the host added beside it. */
1044
- private storedByDiscordId(ctx: ModuleContext, channelId: string): Map<string, StoredMessage> {
1045
- const rows = new Map<string, StoredMessage>();
1225
+ * edit replaces a row's own text and keeps what the host added beside it.
1226
+ * Each id's rows are in store order. A message stored twice holds two,
1227
+ * of which a sync acts on the first, as the live path does (storedRow),
1228
+ * and a delete takes both. A write the framework holds is a row too
1229
+ * (storedRows). */
1230
+ private storedByDiscordId(ctx: ModuleContext, channelId: string): Map<string, StoredMessage[]> {
1231
+ const rows = new Map<string, StoredMessage[]>();
1046
1232
  for (const msg of ctx.queryMessages({ source: 'discord', metadata: { channelId } }).messages) {
1047
1233
  const external = msg.metadata?.external as { id?: string } | undefined;
1048
- if (external?.id) rows.set(external.id, msg);
1234
+ if (!external?.id) continue;
1235
+ const held = rows.get(external.id);
1236
+ if (held) held.push(msg);
1237
+ else rows.set(external.id, [msg]);
1049
1238
  }
1050
1239
  return rows;
1051
1240
  }
1052
1241
 
1053
- /** The stored row of a Discord message, by its id in the row's metadata.
1054
- * The module context's findMessageByExternalId holds only rows the module
1242
+ /** The stored rows of a Discord message, by its id in the row's metadata,
1243
+ * in store order. A write the framework holds comes after them as a held
1244
+ * row, which an edit or removal reaches as it does a stored one. The
1245
+ * module context's findMessageByExternalId holds only rows the module
1055
1246
  * stored through its context, not those a process response added, so it
1056
1247
  * misses what the live path stored. */
1248
+ private storedRows(discordMessageId: string): StoredMessage[] {
1249
+ return this.ctx?.queryMessages({ source: 'discord', externalIds: [discordMessageId] }).messages ?? [];
1250
+ }
1251
+
1252
+ /** The row an edit or a create acts on: a message's first row, a stored
1253
+ * one before a held one (storedRows). */
1057
1254
  private storedRow(discordMessageId: string): StoredMessage | undefined {
1058
- return this.ctx?.queryMessages({ source: 'discord', externalIds: [discordMessageId] }).messages[0];
1255
+ return this.storedRows(discordMessageId)[0];
1059
1256
  }
1060
1257
 
1061
1258
  /** Marks a Discord message as the live path's for every history sync running now. */
@@ -1064,7 +1261,8 @@ export class DiscordModule implements Module {
1064
1261
  }
1065
1262
 
1066
1263
  private async handleDiscordMessage(message: DiscordMessageData): Promise<void> {
1067
- if (!this.ctx) return;
1264
+ const ctx = this.ctx;
1265
+ if (!ctx) return;
1068
1266
 
1069
1267
  // ALWAYS ignore our own messages to prevent feedback loops
1070
1268
  if (this.state.botUserId && message.authorId === this.state.botUserId) {
@@ -1099,21 +1297,48 @@ export class DiscordModule implements Module {
1099
1297
  // Determine if this message should trigger inference
1100
1298
  const shouldTrigger = this.shouldTriggerInference(message, isDM);
1101
1299
 
1300
+ // From here the message is the live path's to store (creating). A second
1301
+ // delivery of a message whose create is already on its way is ignored.
1302
+ if (this.creating.has(message.id)) return;
1303
+ const live: LiveCreate = { phase: 'converting', deleted: false };
1304
+ this.creating.set(message.id, live);
1305
+ const drop = () => { if (this.creating.get(message.id) === live) this.creating.delete(message.id); };
1306
+
1102
1307
  // Convert message content including attachments
1103
- const contentBlocks = await this.convertMessageToContent(message);
1308
+ let contentBlocks: ContentBlock[];
1309
+ try {
1310
+ contentBlocks = await this.convertMessageToContent(message);
1311
+ } catch (error) {
1312
+ drop();
1313
+ throw error;
1314
+ }
1315
+ // A module stopped or started again meanwhile writes nothing.
1316
+ if (this.ctx !== ctx) {
1317
+ drop();
1318
+ return;
1319
+ }
1320
+ // A delete that came meanwhile drops the create: nothing is stored,
1321
+ // nobody is woken. Its entry stays until that delete is handled, so no
1322
+ // sync stores the message before then.
1323
+ if (live.deleted) return;
1324
+ // An edit that came meanwhile: its text, and the blocks of the
1325
+ // attachments it left.
1326
+ if (live.text !== undefined) contentBlocks = withOwnText(contentBlocks, live.text);
1327
+ if (live.attachments) contentBlocks = withoutGoneAttachments(contentBlocks, live.attachments);
1328
+ live.phase = 'queued';
1104
1329
 
1105
1330
  // Only queue the event - all state writes happen in onProcess()
1106
- this.ctx.pushEvent({
1331
+ ctx.pushEvent({
1107
1332
  type: 'discord:message',
1108
1333
  discordMessageId: message.id,
1109
1334
  channelId: message.channelId,
1110
1335
  guildId: message.guildId,
1111
1336
  authorId: message.authorId,
1112
1337
  authorName: message.authorName,
1113
- content: message.content,
1338
+ content: live.text ?? message.content,
1114
1339
  contentBlocks,
1115
1340
  timestamp: message.timestamp.getTime(),
1116
- attachments: message.attachments,
1341
+ attachments: live.attachments ?? message.attachments,
1117
1342
  shouldTriggerInference: shouldTrigger,
1118
1343
  } as DiscordMessageEvent);
1119
1344
  }
@@ -1148,7 +1373,15 @@ export class DiscordModule implements Module {
1148
1373
 
1149
1374
  // Check for text files - fetch and include content
1150
1375
  const isTextFile = TEXT_FILE_EXTENSIONS.some(ext => filename.endsWith(ext));
1151
- if (isTextFile && attachment.size && attachment.size < 100_000) { // < 100KB
1376
+ if (isTextFile && attachment.size >= TEXT_FILE_LIMIT_BYTES) {
1377
+ blocks.push({
1378
+ type: 'text',
1379
+ text: `[Attachment: ${attachment.filename} (${attachment.size.toLocaleString('en-US')} bytes): this host reads text files under ${TEXT_FILE_LIMIT_BYTES.toLocaleString('en-US')} bytes into your context, so its text isn't here. If you need it, ask the sender to paste the part that matters.]`,
1380
+ });
1381
+ continue;
1382
+ }
1383
+ // A text file of size 0 gets the note below, as before.
1384
+ if (isTextFile && attachment.size) {
1152
1385
  try {
1153
1386
  const response = await fetch(attachment.url);
1154
1387
  if (response.ok) {
@@ -1173,7 +1406,7 @@ export class DiscordModule implements Module {
1173
1406
  // For unsupported attachments, add a note
1174
1407
  blocks.push({
1175
1408
  type: 'text',
1176
- text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'})]`,
1409
+ text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'}): this host didn't read its contents in, so they aren't in your context. If you need them, ask the sender for them in a form you can read, such as pasted text.]`,
1177
1410
  });
1178
1411
  }
1179
1412
 
@@ -1220,20 +1453,36 @@ export class DiscordModule implements Module {
1220
1453
  }
1221
1454
  }
1222
1455
 
1223
- private handleDiscordMessageEdit(messageId: string, newContent: string): void {
1456
+ private handleDiscordMessageEdit(messageId: string, newContent: string, attachments?: DiscordAttachment[]): void {
1224
1457
  if (!this.ctx) return;
1225
1458
 
1459
+ // An edit while the message's create converts is folded into that
1460
+ // create, which would otherwise store the text it was sent with; one that
1461
+ // comes after follows the create through the queue. Its event still goes,
1462
+ // for a row a history sync stored first.
1463
+ const live = this.creating.get(messageId);
1464
+ if (live) {
1465
+ live.text = newContent;
1466
+ if (attachments) live.attachments = attachments;
1467
+ }
1468
+
1226
1469
  // Only queue the event - state writes happen in onProcess()
1227
1470
  this.ctx.pushEvent({
1228
1471
  type: 'discord:edit',
1229
1472
  discordMessageId: messageId,
1230
1473
  newContent,
1474
+ ...(attachments ? { attachments } : {}),
1231
1475
  });
1232
1476
  }
1233
1477
 
1234
1478
  private handleDiscordMessageDelete(messageId: string): void {
1235
1479
  if (!this.ctx) return;
1236
1480
 
1481
+ // A delete before the message's create is handled drops that create.
1482
+ // Its event still goes, for a row a history sync stored first.
1483
+ const live = this.creating.get(messageId);
1484
+ if (live) live.deleted = true;
1485
+
1237
1486
  // Only queue the event - state writes happen in onProcess()
1238
1487
  this.ctx.pushEvent({
1239
1488
  type: 'discord:delete',
@@ -1262,8 +1511,13 @@ export class DiscordModule implements Module {
1262
1511
  }
1263
1512
 
1264
1513
  private async handleSend(input: SendInput, call: ToolCall): Promise<ToolResult> {
1265
- if (!this.checkRateLimit(input.channelId)) {
1266
- return { success: false, error: 'Rate limited', isError: true };
1514
+ const wait = this.rateLimitWait(input.channelId);
1515
+ if (wait > 0) {
1516
+ return {
1517
+ success: false,
1518
+ error: `Nothing was sent: channel ${input.channelId} has reached ${this.rateLimitRule()}. Send it again in ${seconds(wait)}; other channels have their own limit.`,
1519
+ isError: true,
1520
+ };
1267
1521
  }
1268
1522
 
1269
1523
  const result = await this.client.sendMessage(input.channelId, input.content, {
@@ -1278,22 +1532,36 @@ export class DiscordModule implements Module {
1278
1532
  if (result.threadFailure !== undefined) {
1279
1533
  return { success: false, error: result.threadFailure, isError: true };
1280
1534
  }
1281
- return { success: true, data: { messageId: result.messageId } };
1535
+ return { success: true, data: { messageId: result.messageId, ...(result.note !== undefined ? { note: result.note } : {}) } };
1282
1536
  }
1283
1537
 
1284
1538
  private async handleDM(input: SendDMInput): Promise<ToolResult> {
1285
1539
  const result = await this.client.sendDM(input.userId, input.content);
1286
- return { success: true, data: { messageId: result.messageId } };
1540
+ // The direct message channel's id is what fetch_history reads it by, should a later dm need checking.
1541
+ return { success: true, data: { messageId: result.messageId, ...(result.channelId !== undefined ? { channelId: result.channelId } : {}) } };
1287
1542
  }
1288
1543
 
1289
1544
  private async handleReply(input: ReplyInput, call: ToolCall): Promise<ToolResult> {
1290
1545
  const replyCtx = this.replyContexts.get('default');
1291
1546
  if (!replyCtx) {
1292
- return { success: false, error: 'No conversation context', isError: true };
1547
+ return {
1548
+ success: false,
1549
+ error: 'Nothing was sent: there\'s no conversation to reply in. Use discord--send with a channelId to post somewhere, or discord--set_reply_context with a channelId to choose where replies go, then reply again.',
1550
+ isError: true,
1551
+ };
1293
1552
  }
1294
1553
 
1295
- if (!this.checkRateLimit(replyCtx.channelId)) {
1296
- return { success: false, error: 'Rate limited', isError: true };
1554
+ // The reply goes to the context's thread when it names one, an empty id
1555
+ // as well, as it always has; the words name an empty id as what it is.
1556
+ const thread = replyCtx.threadId;
1557
+ const wait = this.rateLimitWait(replyCtx.channelId);
1558
+ if (wait > 0) {
1559
+ return {
1560
+ success: false,
1561
+ error: `Nothing was sent: this conversation's channel (${replyCtx.channelId}) has reached ${this.rateLimitRule()}`
1562
+ + `${thread ? `, which counts its replies in thread ${thread} too` : ''}. Reply again in ${seconds(wait)}.`,
1563
+ isError: true,
1564
+ };
1297
1565
  }
1298
1566
 
1299
1567
  let content = input.content;
@@ -1301,10 +1569,28 @@ export class DiscordModule implements Module {
1301
1569
  content = `<@${replyCtx.userId}> ${content}`;
1302
1570
  }
1303
1571
 
1304
- const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
1305
- const result = await this.client.sendMessage(targetChannel, content, {
1306
- replyTo: replyCtx.replyToMessageId,
1307
- });
1572
+ // reply's call names no channel or message: its reply context does, as it
1573
+ // stands now, before the send, which an incoming message can change.
1574
+ const targetChannel = thread ?? replyCtx.channelId;
1575
+ const pointsAt = thread === undefined ? `channel ${targetChannel}` : thread === '' ? 'an empty thread id' : `thread ${targetChannel}`;
1576
+ const check = thread === ''
1577
+ ? `, and its reply context names an empty thread id: discord--set_reply_context with a threadId, or without one to reply in channel ${replyCtx.channelId}, puts that right`
1578
+ : `: discord--fetch_history with channelId ${targetChannel}, its reply context's ${thread !== undefined ? 'thread' : 'channel'}, shows it, so check before you reply again`;
1579
+ const replyTo = replyCtx.replyToMessageId;
1580
+ let result: { messageId: string };
1581
+ try {
1582
+ result = await this.client.sendMessage(targetChannel, content, { replyTo });
1583
+ } catch (error) {
1584
+ if (error instanceof DiscordRefusal) {
1585
+ throw new DiscordRefusal(`${error.message} A reply goes where its reply context points: ${pointsAt}`
1586
+ + `${replyTo ? `, answering message ${replyTo}` : ''}. discord--set_reply_context changes that.`, { cause: error });
1587
+ }
1588
+ return {
1589
+ success: false,
1590
+ error: `discord--reply stopped on an unexpected error: ${errorText(error)}. Whether the reply was sent is unknown${check}.`,
1591
+ isError: true,
1592
+ };
1593
+ }
1308
1594
 
1309
1595
  // Track for thought-editing
1310
1596
  this.notePost(call.callerAgentName, call.callerTurnId, targetChannel, result.messageId);
@@ -1370,7 +1656,7 @@ export class DiscordModule implements Module {
1370
1656
 
1371
1657
  private async handleActivate(input: { additive?: boolean }): Promise<ToolResult> {
1372
1658
  if (!this.ctx) {
1373
- return { success: false, error: 'Module not started', isError: true };
1659
+ return { success: false, error: NOT_RUNNING, isError: true };
1374
1660
  }
1375
1661
 
1376
1662
  this.ctx.registerSpeechHandler('*', { additive: input.additive });
@@ -1379,7 +1665,7 @@ export class DiscordModule implements Module {
1379
1665
 
1380
1666
  private async handleDeactivate(): Promise<ToolResult> {
1381
1667
  if (!this.ctx) {
1382
- return { success: false, error: 'Module not started', isError: true };
1668
+ return { success: false, error: NOT_RUNNING, isError: true };
1383
1669
  }
1384
1670
 
1385
1671
  this.ctx.unregisterSpeechHandler();
@@ -1464,8 +1750,25 @@ export class DiscordModule implements Module {
1464
1750
  .join('\n');
1465
1751
  }
1466
1752
 
1467
- private checkRateLimit(channelId: string): boolean {
1468
- const limit = this.config.rateLimitPerMinute ?? 30;
1753
+ private rateLimitPerMinute(): number {
1754
+ return this.config.rateLimitPerMinute ?? 30;
1755
+ }
1756
+
1757
+ /** The limit, as the rate-limit refusals word it. */
1758
+ private rateLimitRule(): string {
1759
+ const limit = this.rateLimitPerMinute();
1760
+ return `this host's limit of ${limit} send${limit === 1 ? '' : 's'} a minute from the bot`;
1761
+ }
1762
+
1763
+ /**
1764
+ * Counts a send to `channelId` against its limit and returns 0, or, when
1765
+ * the channel has had its sends for this window, returns the whole seconds
1766
+ * until the window ends, at least 1. A window is the minute from the first
1767
+ * send this lets through, and each send it lets through counts, whether or
1768
+ * not Discord then takes it.
1769
+ */
1770
+ private rateLimitWait(channelId: string): number {
1771
+ const limit = this.rateLimitPerMinute();
1469
1772
  const now = Date.now();
1470
1773
  const windowMs = 60 * 1000;
1471
1774
 
@@ -1477,11 +1780,11 @@ export class DiscordModule implements Module {
1477
1780
  }
1478
1781
 
1479
1782
  if (info.count >= limit) {
1480
- return false;
1783
+ return Math.max(1, Math.ceil((info.windowStart + windowMs - now) / 1000));
1481
1784
  }
1482
1785
 
1483
1786
  info.count++;
1484
- return true;
1787
+ return 0;
1485
1788
  }
1486
1789
 
1487
1790
  private cleanupOldConversations(): void {