@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
@@ -8,8 +8,11 @@
8
8
  */
9
9
  import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
10
10
  import { logValue } from '../../mcpl/log-value.js';
11
+ import { unknownToolMessage } from '../unknown-tool.js';
12
+ import { DiscordRefusal } from './refusal.js';
11
13
  export * from './types.js';
12
14
  export { DiscordJsClient } from './discord-js-client.js';
15
+ export { DiscordRefusal } from './refusal.js';
13
16
  const DEFAULT_CONFIG = {
14
17
  handleDMs: true,
15
18
  ignoreBots: true,
@@ -56,10 +59,52 @@ const TEXT_FILE_EXTENSIONS = [
56
59
  '.html', '.css', '.xml', '.csv', '.log', '.sh', '.bash',
57
60
  ];
58
61
  const CONVERSATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
62
+ /** A text attachment this many bytes or larger isn't read into the agent's context. */
63
+ const TEXT_FILE_LIMIT_BYTES = 100_000;
64
+ /** activate's and deactivate's answer before the module has started, or once it has stopped. */
65
+ 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.";
59
66
  /** What the agent reads for a text attachment whose contents couldn't be
60
67
  * fetched: by an error, or by an answer that wasn't OK. */
61
68
  function attachmentNotFetched(filename) {
62
- return { type: 'text', text: `[Attachment: ${filename} (failed to fetch)]` };
69
+ return {
70
+ type: 'text',
71
+ 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.]`,
72
+ };
73
+ }
74
+ /**
75
+ * What the agent reads when a tool stops on an error that isn't a refusal
76
+ * (DiscordRefusal), such as a network failure: its outcome is unknown. A send
77
+ * repeated blindly can post twice, so those say how to check; the other tools
78
+ * are safe to repeat, since a second call changes nothing more.
79
+ */
80
+ function unexpectedFailure(call, error) {
81
+ const stopped = `discord--${call.name} stopped on an unexpected error: ${errorText(error)}.`;
82
+ const channelId = call.input?.channelId;
83
+ switch (call.name) {
84
+ case 'send':
85
+ 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.`;
86
+ case 'dm':
87
+ 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.`;
88
+ case 'edit':
89
+ case 'react':
90
+ case 'delete':
91
+ case 'create_thread':
92
+ return `${stopped} Whether it took effect is unknown. Calling it again is safe: if it did, the second call changes nothing more.`;
93
+ case 'list_guilds':
94
+ case 'list_channels':
95
+ case 'fetch_history':
96
+ return `${stopped} It changes nothing on Discord, so you can call it again.`;
97
+ default:
98
+ return `${stopped} Calling it again is safe.`;
99
+ }
100
+ }
101
+ /** An error's own words, for a sentence about it. */
102
+ function errorText(error) {
103
+ return error instanceof Error ? error.message : String(error);
104
+ }
105
+ /** A count of seconds, for the agent's eyes. */
106
+ function seconds(n) {
107
+ return `${n} second${n === 1 ? '' : 's'}`;
63
108
  }
64
109
  /** What a stored message reads when it has no text of its own and nothing the
65
110
  * host added, so the agent still sees that a message was there. */
@@ -172,6 +217,65 @@ function holdsNotFetched(content, message) {
172
217
  const notes = new Set((message.attachments ?? []).map((attachment) => attachmentNotFetched(attachment.filename).text));
173
218
  return content.slice(1).some((block) => block.type === 'text' && notes.has(block.text));
174
219
  }
220
+ /** A URL without its query or fragment: Discord's CDN signs an attachment's
221
+ * URL afresh in each payload, so only its path names the attachment. */
222
+ function urlPath(url) {
223
+ return url.split(/[?#]/)[0] ?? url;
224
+ }
225
+ /**
226
+ * Whether a block the host added for an attachment stands for none of
227
+ * `attachments`, matched as the live conversion writes it: an image by its
228
+ * URL, a file's text by its header, and a note by the filename it opens
229
+ * with, as every note for an attachment does, whatever its words after it.
230
+ * A block that stands for no attachment, such as EMPTY_MESSAGE, never does.
231
+ */
232
+ function forGoneAttachment(block, attachments) {
233
+ if (block.type === 'image') {
234
+ const source = block.source;
235
+ if (source?.type !== 'url' || typeof source.url !== 'string')
236
+ return false;
237
+ const path = urlPath(source.url);
238
+ return !attachments.some((attachment) => urlPath(attachment.url) === path);
239
+ }
240
+ if (block.type !== 'text')
241
+ return false;
242
+ const { text } = block;
243
+ const opening = text.startsWith('\n--- File: ') ? (filename) => `\n--- File: ${filename} ---\n`
244
+ : text.startsWith('[Attachment: ') ? (filename) => `[Attachment: ${filename} `
245
+ : null;
246
+ return opening !== null && !attachments.some((attachment) => text.startsWith(opening(attachment.filename)));
247
+ }
248
+ /**
249
+ * A text-first row's content without the blocks the host added for
250
+ * attachments its message no longer has, or `content` itself when none is
251
+ * gone. A block stays while an attachment it may stand for remains, so of two
252
+ * files of one name, removing one keeps both blocks, and a block that names
253
+ * no attachment stays. EMPTY_MESSAGE comes back once nothing is left.
254
+ */
255
+ function withoutGoneAttachments(content, attachments) {
256
+ const [first, ...rest] = content;
257
+ if (first?.type !== 'text')
258
+ return content;
259
+ const kept = rest.filter((block) => !forGoneAttachment(block, attachments));
260
+ if (kept.length === rest.length)
261
+ return content;
262
+ return textFirst(first.text, kept);
263
+ }
264
+ /** The ids to remove for a message's rows: each row once, and a body stored
265
+ * in shards once, since removing one shard takes out its whole body. */
266
+ function oncePerBody(rows) {
267
+ const bodies = new Set();
268
+ const ids = [];
269
+ for (const row of rows) {
270
+ if (row.bodyGroupId) {
271
+ if (bodies.has(row.bodyGroupId))
272
+ continue;
273
+ bodies.add(row.bodyGroupId);
274
+ }
275
+ ids.push(row.id);
276
+ }
277
+ return ids;
278
+ }
175
279
  /**
176
280
  * Discord module for the agent framework.
177
281
  */
@@ -207,6 +311,21 @@ export class DiscordModule {
207
311
  // live path saw is newer than what the sync fetched, so the sync leaves
208
312
  // those messages alone.
209
313
  liveDuringSync = new Set();
314
+ // The Discord messages whose live create is on its way to the store: from
315
+ // the moment the live path takes one up, through its conversion and the
316
+ // event queue, until a query shows the row onProcess answered with, stored
317
+ // or held. The framework holds a module's writes while the agent's turn is
318
+ // alive or the host is quiesced, and queryMessages shows a held write's
319
+ // row, marked held, so the next Discord event finds it either way and ends
320
+ // its entry. A history sync stores none of these messages, so no message is
321
+ // stored twice. A delete before the create is handled drops it: nothing is
322
+ // stored and nobody is woken. Any message's entry ends when its delete is
323
+ // handled, since a sync running then skips the message (noteLive) and a
324
+ // later one doesn't fetch it. An edit while it converts is folded into it.
325
+ // A create whose answer the framework fails to write or hold shows no row,
326
+ // and keeps its entry, and the sync off its message, until its delete is
327
+ // handled or the module stops.
328
+ creating = new Map();
210
329
  // Typing keepalive: agentName -> { interval, deadline }. Started on
211
330
  // inference:started, stopped on the turn's terminal trace event. Discord's
212
331
  // typing indicator auto-expires after ~10s, so we re-trigger on an interval
@@ -249,7 +368,7 @@ export class DiscordModule {
249
368
  }
250
369
  // Set up Discord event handlers
251
370
  this.client.onMessage((message) => this.handleDiscordMessage(message));
252
- this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
371
+ this.client.onMessageEdit((id, content, attachments) => this.handleDiscordMessageEdit(id, content, attachments));
253
372
  this.client.onMessageDelete((id) => this.handleDiscordMessageDelete(id));
254
373
  // Set up ready handler for history sync on (re)connect
255
374
  if (this.client.onReady) {
@@ -283,8 +402,13 @@ export class DiscordModule {
283
402
  clearInterval(interval);
284
403
  }
285
404
  this.typingLoops.clear();
286
- await this.client.disconnect();
405
+ // From here the module writes nothing, though the framework may dispatch
406
+ // to it until stop() resolves: the live path takes nothing up, a create
407
+ // still converting finds the context gone, and onProcess answers nothing.
408
+ // The record of creates on their way goes with the context.
287
409
  this.ctx = null;
410
+ this.creating.clear();
411
+ await this.client.disconnect();
288
412
  }
289
413
  /**
290
414
  * Start (or refresh) the typing keepalive for an agent's reply channel.
@@ -468,7 +592,7 @@ export class DiscordModule {
468
592
  },
469
593
  {
470
594
  name: 'list_channels',
471
- description: 'List all channels in a Discord server',
595
+ description: 'List the channels in a Discord server, with their ids. Threads aren\'t listed.',
472
596
  inputSchema: {
473
597
  type: 'object',
474
598
  properties: {
@@ -512,7 +636,8 @@ export class DiscordModule {
512
636
  case 'set_reply_context':
513
637
  return await this.handleSetReplyContext(call.input);
514
638
  case 'activate':
515
- return await this.handleActivate(call.input);
639
+ // A call with no input at all activates with the defaults.
640
+ return await this.handleActivate((call.input ?? {}));
516
641
  case 'deactivate':
517
642
  return await this.handleDeactivate();
518
643
  case 'list_guilds':
@@ -522,21 +647,38 @@ export class DiscordModule {
522
647
  case 'fetch_history':
523
648
  return await this.handleFetchHistory(call.input);
524
649
  default:
525
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
650
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
526
651
  }
527
652
  }
528
653
  catch (error) {
654
+ // A refusal says what was refused and what to do instead; anything else
655
+ // is unexpected, and its outcome is worded by the tool.
529
656
  return {
530
657
  success: false,
531
- error: error instanceof Error ? error.message : String(error),
658
+ error: error instanceof DiscordRefusal ? error.message : unexpectedFailure(call, error),
532
659
  isError: true,
533
660
  };
534
661
  }
535
662
  }
536
663
  async onProcess(event, state) {
664
+ // A stopped module answers nothing (stop()).
665
+ if (!this.ctx)
666
+ return {};
667
+ // Each Discord event ends the entries whose row a query shows now, stored
668
+ // or held.
669
+ if (event.type.startsWith('discord:')) {
670
+ for (const [id, live] of this.creating)
671
+ if (live.phase === 'writing' && this.storedRow(id))
672
+ this.creating.delete(id);
673
+ }
537
674
  // Handle new Discord message
538
675
  if (event.type === 'discord:message') {
539
676
  const msg = event;
677
+ const live = this.creating.get(msg.discordMessageId);
678
+ // Deleted while its create waited in the queue: nothing is stored, and
679
+ // nobody is woken by a message its author took back.
680
+ if (live?.deleted)
681
+ return {};
540
682
  const currentState = state.getState() ?? {
541
683
  connectedGuilds: [],
542
684
  conversations: {},
@@ -579,20 +721,29 @@ export class DiscordModule {
579
721
  // when an event carries no blocks; text first, as TEXT_FIRST marks.
580
722
  const contentBlocks = msg.contentBlocks ?? textFirst(ownText(msg.content));
581
723
  this.noteLive(msg.discordMessageId);
724
+ // A message a history sync wrote first, stored or held, keeps that row:
725
+ // the create adds none, and still sets the reply context and asks for
726
+ // inference, since the message arrived live. Either way the entry waits
727
+ // until a Discord event finds the message's row.
728
+ const stored = this.storedRow(msg.discordMessageId);
729
+ if (live)
730
+ live.phase = 'writing';
582
731
  // Return declarative response - framework applies all writes atomically
583
732
  return {
584
- addMessages: [
585
- {
586
- participant: msg.authorName,
587
- content: contentBlocks,
588
- metadata: {
589
- external: { source: 'discord', id: msg.discordMessageId },
590
- channelId: msg.channelId,
591
- timestamp: msg.timestamp,
592
- [TEXT_FIRST]: true,
733
+ ...(stored ? {} : {
734
+ addMessages: [
735
+ {
736
+ participant: msg.authorName,
737
+ content: contentBlocks,
738
+ metadata: {
739
+ external: { source: 'discord', id: msg.discordMessageId },
740
+ channelId: msg.channelId,
741
+ timestamp: msg.timestamp,
742
+ [TEXT_FIRST]: true,
743
+ },
593
744
  },
594
- },
595
- ],
745
+ ],
746
+ }),
596
747
  stateUpdate: newState,
597
748
  requestInference: msg.shouldTriggerInference ?? true,
598
749
  };
@@ -600,15 +751,18 @@ export class DiscordModule {
600
751
  // Handle Discord message edit
601
752
  if (event.type === 'discord:edit') {
602
753
  const edit = event;
603
- // The stored row, not only its id: an edit changes the message's own
604
- // text and keeps what the host added beside it.
605
- // An edit to a message nobody has stored yet is dropped: whatever
606
- // stores the message, its live add or a history sync running now,
607
- // stores the text it got, and the next sync's text rule changes it.
608
- // A row stored in shards can't be edited one shard at a time, so it
609
- // keeps its text, and the host's log says so. Only an edit that writes
610
- // marks the message live, so an update that changes nothing leaves a
611
- // running sync free to rewrite the row.
754
+ // The message's row, not only its id: an edit changes the message's own
755
+ // text and keeps what the host added beside it, but for the blocks of
756
+ // attachments the edit took away, when the client passed the edit's
757
+ // attachments and the row is text-first.
758
+ // An edit that comes while the live path converts the message's create
759
+ // is folded into that create (handleDiscordMessageEdit). Any other edit
760
+ // to a message with no row, stored or held, is dropped: a history sync
761
+ // running now stores the text it fetched, and the next sync's text rule
762
+ // changes it. A row stored in shards can't be edited one shard at a
763
+ // time, so it keeps its text, and the host's log says so. Only an edit
764
+ // that writes marks the message live, so an update that changes nothing
765
+ // leaves a running sync free to rewrite the row.
612
766
  const stored = this.storedRow(edit.discordMessageId);
613
767
  if (!stored)
614
768
  return {};
@@ -616,8 +770,11 @@ export class DiscordModule {
616
770
  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`);
617
771
  return {};
618
772
  }
619
- const content = contentForText(stored, edit.newContent);
620
- if (!content)
773
+ const texted = contentForText(stored, edit.newContent);
774
+ const content = edit.attachments && stored.metadata?.[TEXT_FIRST] === true
775
+ ? withoutGoneAttachments(texted ?? stored.content, edit.attachments)
776
+ : texted;
777
+ if (!content || content === stored.content)
621
778
  return {};
622
779
  this.noteLive(edit.discordMessageId);
623
780
  return { editMessages: [{ messageId: stored.id, content }] };
@@ -625,17 +782,19 @@ export class DiscordModule {
625
782
  // Handle Discord message delete
626
783
  if (event.type === 'discord:delete') {
627
784
  const del = event;
628
- // A row stored in shards is removed whole: the framework takes out the
629
- // body a shard belongs to. The message is the live path's even when no
630
- // row is found, so a running sync doesn't store what its fetch may
631
- // still hold.
785
+ // Every row of the message goes, so one stored twice doesn't outlive
786
+ // its delete as a copy. A row stored in shards is removed whole: the
787
+ // framework takes out the body a shard belongs to. The message is the
788
+ // live path's even when no row is found, so a running sync doesn't
789
+ // store what its fetch may still hold, and that ends its entry.
632
790
  this.noteLive(del.discordMessageId);
633
- const stored = this.storedRow(del.discordMessageId);
634
- if (!stored) {
791
+ this.creating.delete(del.discordMessageId);
792
+ const rows = this.storedRows(del.discordMessageId);
793
+ if (rows.length === 0) {
635
794
  return {};
636
795
  }
637
796
  return {
638
- removeMessages: [stored.id],
797
+ removeMessages: oncePerBody(rows),
639
798
  };
640
799
  }
641
800
  return {};
@@ -679,7 +838,7 @@ export class DiscordModule {
679
838
  return;
680
839
  }
681
840
  // Check rate limit
682
- if (!this.checkRateLimit(replyCtx.channelId)) {
841
+ if (this.rateLimitWait(replyCtx.channelId) > 0) {
683
842
  console.warn(`Discord: Rate limited for channel ${replyCtx.channelId}`);
684
843
  return;
685
844
  }
@@ -770,7 +929,8 @@ export class DiscordModule {
770
929
  }
771
930
  /**
772
931
  * Sync history for a specific channel - compare with Context Manager and update.
773
- * Uses queryMessages() to enumerate stored messages for proper edit/delete detection.
932
+ * Uses queryMessages() to enumerate the channel's rows, a held write's among
933
+ * them, for proper edit/delete detection.
774
934
  *
775
935
  * The live path can store, edit or remove a row whenever the sync awaits.
776
936
  * So the sync fetches history and runs the live conversion it needs first,
@@ -813,7 +973,7 @@ export class DiscordModule {
813
973
  // edited one shard at a time, so it's left as it is.
814
974
  const converted = new Map();
815
975
  for (const discordMsg of chronologicalMessages) {
816
- const stored = before.get(discordMsg.id);
976
+ const stored = before.get(discordMsg.id)?.[0];
817
977
  if (stored && (stored.bodyGroupId || !needsRebuild(stored, discordMsg)))
818
978
  continue;
819
979
  const content = await this.convertMessageToContent(discordMsg);
@@ -834,13 +994,14 @@ export class DiscordModule {
834
994
  for (const discordMsg of chronologicalMessages) {
835
995
  if (live.has(discordMsg.id))
836
996
  continue;
837
- const stored = storedMessageMap.get(discordMsg.id);
997
+ const stored = storedMessageMap.get(discordMsg.id)?.[0];
838
998
  if (!stored) {
839
999
  // A message we haven't stored. One that was stored when the sync
840
1000
  // began, and isn't now, was removed meanwhile: not the sync's to
841
- // bring back.
1001
+ // bring back. One the live path is storing (creating) is the live
1002
+ // path's, so it isn't stored twice.
842
1003
  const content = converted.get(discordMsg.id);
843
- if (!content || before.has(discordMsg.id))
1004
+ if (!content || before.has(discordMsg.id) || this.creating.has(discordMsg.id))
844
1005
  continue;
845
1006
  // For bot's own messages (from previous sessions), use the agent name
846
1007
  // This ensures continuity of conversation history
@@ -876,7 +1037,8 @@ export class DiscordModule {
876
1037
  const oldestFetchedTimestamp = discordMessages.length > 0
877
1038
  ? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
878
1039
  : undefined;
879
- for (const [externalId, stored] of storedMessageMap) {
1040
+ for (const [externalId, rows] of storedMessageMap) {
1041
+ const stored = rows[0];
880
1042
  const sourceTimestamp = typeof stored.metadata?.timestamp === 'number'
881
1043
  ? stored.metadata.timestamp
882
1044
  : undefined;
@@ -890,8 +1052,10 @@ export class DiscordModule {
890
1052
  // Message exists in our store but not in Discord - it was deleted
891
1053
  // Note: This only catches deletes within the scrollback window.
892
1054
  // A message the live path wrote while the sync ran is newer than
893
- // the fetch, so it stays. One shard's id takes out a body whole.
894
- ctx.removeMessage(stored.id);
1055
+ // the fetch, so it stays. Every row of the message goes, and one
1056
+ // shard's id takes out a body whole.
1057
+ for (const id of oncePerBody(rows))
1058
+ ctx.removeMessage(id);
895
1059
  deletedMessages++;
896
1060
  }
897
1061
  }
@@ -908,22 +1072,38 @@ export class DiscordModule {
908
1072
  }
909
1073
  }
910
1074
  /** A channel's stored Discord rows by their Discord message id, whole: an
911
- * edit replaces a row's own text and keeps what the host added beside it. */
1075
+ * edit replaces a row's own text and keeps what the host added beside it.
1076
+ * Each id's rows are in store order. A message stored twice holds two,
1077
+ * of which a sync acts on the first, as the live path does (storedRow),
1078
+ * and a delete takes both. A write the framework holds is a row too
1079
+ * (storedRows). */
912
1080
  storedByDiscordId(ctx, channelId) {
913
1081
  const rows = new Map();
914
1082
  for (const msg of ctx.queryMessages({ source: 'discord', metadata: { channelId } }).messages) {
915
1083
  const external = msg.metadata?.external;
916
- if (external?.id)
917
- rows.set(external.id, msg);
1084
+ if (!external?.id)
1085
+ continue;
1086
+ const held = rows.get(external.id);
1087
+ if (held)
1088
+ held.push(msg);
1089
+ else
1090
+ rows.set(external.id, [msg]);
918
1091
  }
919
1092
  return rows;
920
1093
  }
921
- /** The stored row of a Discord message, by its id in the row's metadata.
922
- * The module context's findMessageByExternalId holds only rows the module
1094
+ /** The stored rows of a Discord message, by its id in the row's metadata,
1095
+ * in store order. A write the framework holds comes after them as a held
1096
+ * row, which an edit or removal reaches as it does a stored one. The
1097
+ * module context's findMessageByExternalId holds only rows the module
923
1098
  * stored through its context, not those a process response added, so it
924
1099
  * misses what the live path stored. */
1100
+ storedRows(discordMessageId) {
1101
+ return this.ctx?.queryMessages({ source: 'discord', externalIds: [discordMessageId] }).messages ?? [];
1102
+ }
1103
+ /** The row an edit or a create acts on: a message's first row, a stored
1104
+ * one before a held one (storedRows). */
925
1105
  storedRow(discordMessageId) {
926
- return this.ctx?.queryMessages({ source: 'discord', externalIds: [discordMessageId] }).messages[0];
1106
+ return this.storedRows(discordMessageId)[0];
927
1107
  }
928
1108
  /** Marks a Discord message as the live path's for every history sync running now. */
929
1109
  noteLive(discordMessageId) {
@@ -931,7 +1111,8 @@ export class DiscordModule {
931
1111
  live.add(discordMessageId);
932
1112
  }
933
1113
  async handleDiscordMessage(message) {
934
- if (!this.ctx)
1114
+ const ctx = this.ctx;
1115
+ if (!ctx)
935
1116
  return;
936
1117
  // ALWAYS ignore our own messages to prevent feedback loops
937
1118
  if (this.state.botUserId && message.authorId === this.state.botUserId) {
@@ -958,20 +1139,52 @@ export class DiscordModule {
958
1139
  }
959
1140
  // Determine if this message should trigger inference
960
1141
  const shouldTrigger = this.shouldTriggerInference(message, isDM);
1142
+ // From here the message is the live path's to store (creating). A second
1143
+ // delivery of a message whose create is already on its way is ignored.
1144
+ if (this.creating.has(message.id))
1145
+ return;
1146
+ const live = { phase: 'converting', deleted: false };
1147
+ this.creating.set(message.id, live);
1148
+ const drop = () => { if (this.creating.get(message.id) === live)
1149
+ this.creating.delete(message.id); };
961
1150
  // Convert message content including attachments
962
- const contentBlocks = await this.convertMessageToContent(message);
1151
+ let contentBlocks;
1152
+ try {
1153
+ contentBlocks = await this.convertMessageToContent(message);
1154
+ }
1155
+ catch (error) {
1156
+ drop();
1157
+ throw error;
1158
+ }
1159
+ // A module stopped or started again meanwhile writes nothing.
1160
+ if (this.ctx !== ctx) {
1161
+ drop();
1162
+ return;
1163
+ }
1164
+ // A delete that came meanwhile drops the create: nothing is stored,
1165
+ // nobody is woken. Its entry stays until that delete is handled, so no
1166
+ // sync stores the message before then.
1167
+ if (live.deleted)
1168
+ return;
1169
+ // An edit that came meanwhile: its text, and the blocks of the
1170
+ // attachments it left.
1171
+ if (live.text !== undefined)
1172
+ contentBlocks = withOwnText(contentBlocks, live.text);
1173
+ if (live.attachments)
1174
+ contentBlocks = withoutGoneAttachments(contentBlocks, live.attachments);
1175
+ live.phase = 'queued';
963
1176
  // Only queue the event - all state writes happen in onProcess()
964
- this.ctx.pushEvent({
1177
+ ctx.pushEvent({
965
1178
  type: 'discord:message',
966
1179
  discordMessageId: message.id,
967
1180
  channelId: message.channelId,
968
1181
  guildId: message.guildId,
969
1182
  authorId: message.authorId,
970
1183
  authorName: message.authorName,
971
- content: message.content,
1184
+ content: live.text ?? message.content,
972
1185
  contentBlocks,
973
1186
  timestamp: message.timestamp.getTime(),
974
- attachments: message.attachments,
1187
+ attachments: live.attachments ?? message.attachments,
975
1188
  shouldTriggerInference: shouldTrigger,
976
1189
  });
977
1190
  }
@@ -1000,7 +1213,15 @@ export class DiscordModule {
1000
1213
  }
1001
1214
  // Check for text files - fetch and include content
1002
1215
  const isTextFile = TEXT_FILE_EXTENSIONS.some(ext => filename.endsWith(ext));
1003
- if (isTextFile && attachment.size && attachment.size < 100_000) { // < 100KB
1216
+ if (isTextFile && attachment.size >= TEXT_FILE_LIMIT_BYTES) {
1217
+ blocks.push({
1218
+ type: 'text',
1219
+ 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.]`,
1220
+ });
1221
+ continue;
1222
+ }
1223
+ // A text file of size 0 gets the note below, as before.
1224
+ if (isTextFile && attachment.size) {
1004
1225
  try {
1005
1226
  const response = await fetch(attachment.url);
1006
1227
  if (response.ok) {
@@ -1026,7 +1247,7 @@ export class DiscordModule {
1026
1247
  // For unsupported attachments, add a note
1027
1248
  blocks.push({
1028
1249
  type: 'text',
1029
- text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'})]`,
1250
+ 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.]`,
1030
1251
  });
1031
1252
  }
1032
1253
  return textFirst(ownText(message.content), blocks);
@@ -1063,19 +1284,35 @@ export class DiscordModule {
1063
1284
  return isMentioned || isReplyToBot;
1064
1285
  }
1065
1286
  }
1066
- handleDiscordMessageEdit(messageId, newContent) {
1287
+ handleDiscordMessageEdit(messageId, newContent, attachments) {
1067
1288
  if (!this.ctx)
1068
1289
  return;
1290
+ // An edit while the message's create converts is folded into that
1291
+ // create, which would otherwise store the text it was sent with; one that
1292
+ // comes after follows the create through the queue. Its event still goes,
1293
+ // for a row a history sync stored first.
1294
+ const live = this.creating.get(messageId);
1295
+ if (live) {
1296
+ live.text = newContent;
1297
+ if (attachments)
1298
+ live.attachments = attachments;
1299
+ }
1069
1300
  // Only queue the event - state writes happen in onProcess()
1070
1301
  this.ctx.pushEvent({
1071
1302
  type: 'discord:edit',
1072
1303
  discordMessageId: messageId,
1073
1304
  newContent,
1305
+ ...(attachments ? { attachments } : {}),
1074
1306
  });
1075
1307
  }
1076
1308
  handleDiscordMessageDelete(messageId) {
1077
1309
  if (!this.ctx)
1078
1310
  return;
1311
+ // A delete before the message's create is handled drops that create.
1312
+ // Its event still goes, for a row a history sync stored first.
1313
+ const live = this.creating.get(messageId);
1314
+ if (live)
1315
+ live.deleted = true;
1079
1316
  // Only queue the event - state writes happen in onProcess()
1080
1317
  this.ctx.pushEvent({
1081
1318
  type: 'discord:delete',
@@ -1104,8 +1341,13 @@ export class DiscordModule {
1104
1341
  }
1105
1342
  }
1106
1343
  async handleSend(input, call) {
1107
- if (!this.checkRateLimit(input.channelId)) {
1108
- return { success: false, error: 'Rate limited', isError: true };
1344
+ const wait = this.rateLimitWait(input.channelId);
1345
+ if (wait > 0) {
1346
+ return {
1347
+ success: false,
1348
+ error: `Nothing was sent: channel ${input.channelId} has reached ${this.rateLimitRule()}. Send it again in ${seconds(wait)}; other channels have their own limit.`,
1349
+ isError: true,
1350
+ };
1109
1351
  }
1110
1352
  const result = await this.client.sendMessage(input.channelId, input.content, {
1111
1353
  replyTo: input.replyTo,
@@ -1117,28 +1359,61 @@ export class DiscordModule {
1117
1359
  if (result.threadFailure !== undefined) {
1118
1360
  return { success: false, error: result.threadFailure, isError: true };
1119
1361
  }
1120
- return { success: true, data: { messageId: result.messageId } };
1362
+ return { success: true, data: { messageId: result.messageId, ...(result.note !== undefined ? { note: result.note } : {}) } };
1121
1363
  }
1122
1364
  async handleDM(input) {
1123
1365
  const result = await this.client.sendDM(input.userId, input.content);
1124
- return { success: true, data: { messageId: result.messageId } };
1366
+ // The direct message channel's id is what fetch_history reads it by, should a later dm need checking.
1367
+ return { success: true, data: { messageId: result.messageId, ...(result.channelId !== undefined ? { channelId: result.channelId } : {}) } };
1125
1368
  }
1126
1369
  async handleReply(input, call) {
1127
1370
  const replyCtx = this.replyContexts.get('default');
1128
1371
  if (!replyCtx) {
1129
- return { success: false, error: 'No conversation context', isError: true };
1372
+ return {
1373
+ success: false,
1374
+ 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.',
1375
+ isError: true,
1376
+ };
1130
1377
  }
1131
- if (!this.checkRateLimit(replyCtx.channelId)) {
1132
- return { success: false, error: 'Rate limited', isError: true };
1378
+ // The reply goes to the context's thread when it names one, an empty id
1379
+ // as well, as it always has; the words name an empty id as what it is.
1380
+ const thread = replyCtx.threadId;
1381
+ const wait = this.rateLimitWait(replyCtx.channelId);
1382
+ if (wait > 0) {
1383
+ return {
1384
+ success: false,
1385
+ error: `Nothing was sent: this conversation's channel (${replyCtx.channelId}) has reached ${this.rateLimitRule()}`
1386
+ + `${thread ? `, which counts its replies in thread ${thread} too` : ''}. Reply again in ${seconds(wait)}.`,
1387
+ isError: true,
1388
+ };
1133
1389
  }
1134
1390
  let content = input.content;
1135
1391
  if (input.mention && replyCtx.userId) {
1136
1392
  content = `<@${replyCtx.userId}> ${content}`;
1137
1393
  }
1138
- const targetChannel = replyCtx.threadId ?? replyCtx.channelId;
1139
- const result = await this.client.sendMessage(targetChannel, content, {
1140
- replyTo: replyCtx.replyToMessageId,
1141
- });
1394
+ // reply's call names no channel or message: its reply context does, as it
1395
+ // stands now, before the send, which an incoming message can change.
1396
+ const targetChannel = thread ?? replyCtx.channelId;
1397
+ const pointsAt = thread === undefined ? `channel ${targetChannel}` : thread === '' ? 'an empty thread id' : `thread ${targetChannel}`;
1398
+ const check = thread === ''
1399
+ ? `, 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`
1400
+ : `: discord--fetch_history with channelId ${targetChannel}, its reply context's ${thread !== undefined ? 'thread' : 'channel'}, shows it, so check before you reply again`;
1401
+ const replyTo = replyCtx.replyToMessageId;
1402
+ let result;
1403
+ try {
1404
+ result = await this.client.sendMessage(targetChannel, content, { replyTo });
1405
+ }
1406
+ catch (error) {
1407
+ if (error instanceof DiscordRefusal) {
1408
+ throw new DiscordRefusal(`${error.message} A reply goes where its reply context points: ${pointsAt}`
1409
+ + `${replyTo ? `, answering message ${replyTo}` : ''}. discord--set_reply_context changes that.`, { cause: error });
1410
+ }
1411
+ return {
1412
+ success: false,
1413
+ error: `discord--reply stopped on an unexpected error: ${errorText(error)}. Whether the reply was sent is unknown${check}.`,
1414
+ isError: true,
1415
+ };
1416
+ }
1142
1417
  // Track for thought-editing
1143
1418
  this.notePost(call.callerAgentName, call.callerTurnId, targetChannel, result.messageId);
1144
1419
  // Clear reply-to after sending
@@ -1188,14 +1463,14 @@ export class DiscordModule {
1188
1463
  }
1189
1464
  async handleActivate(input) {
1190
1465
  if (!this.ctx) {
1191
- return { success: false, error: 'Module not started', isError: true };
1466
+ return { success: false, error: NOT_RUNNING, isError: true };
1192
1467
  }
1193
1468
  this.ctx.registerSpeechHandler('*', { additive: input.additive });
1194
1469
  return { success: true, data: { message: 'Discord is now handling speech' } };
1195
1470
  }
1196
1471
  async handleDeactivate() {
1197
1472
  if (!this.ctx) {
1198
- return { success: false, error: 'Module not started', isError: true };
1473
+ return { success: false, error: NOT_RUNNING, isError: true };
1199
1474
  }
1200
1475
  this.ctx.unregisterSpeechHandler();
1201
1476
  return { success: true, data: { message: 'Discord is no longer handling speech' } };
@@ -1271,8 +1546,23 @@ export class DiscordModule {
1271
1546
  .map((block) => block.text)
1272
1547
  .join('\n');
1273
1548
  }
1274
- checkRateLimit(channelId) {
1275
- const limit = this.config.rateLimitPerMinute ?? 30;
1549
+ rateLimitPerMinute() {
1550
+ return this.config.rateLimitPerMinute ?? 30;
1551
+ }
1552
+ /** The limit, as the rate-limit refusals word it. */
1553
+ rateLimitRule() {
1554
+ const limit = this.rateLimitPerMinute();
1555
+ return `this host's limit of ${limit} send${limit === 1 ? '' : 's'} a minute from the bot`;
1556
+ }
1557
+ /**
1558
+ * Counts a send to `channelId` against its limit and returns 0, or, when
1559
+ * the channel has had its sends for this window, returns the whole seconds
1560
+ * until the window ends, at least 1. A window is the minute from the first
1561
+ * send this lets through, and each send it lets through counts, whether or
1562
+ * not Discord then takes it.
1563
+ */
1564
+ rateLimitWait(channelId) {
1565
+ const limit = this.rateLimitPerMinute();
1276
1566
  const now = Date.now();
1277
1567
  const windowMs = 60 * 1000;
1278
1568
  let info = this.state.rateLimits[channelId];
@@ -1282,10 +1572,10 @@ export class DiscordModule {
1282
1572
  this.state.rateLimits[channelId] = info;
1283
1573
  }
1284
1574
  if (info.count >= limit) {
1285
- return false;
1575
+ return Math.max(1, Math.ceil((info.windowStart + windowMs - now) / 1000));
1286
1576
  }
1287
1577
  info.count++;
1288
- return true;
1578
+ return 0;
1289
1579
  }
1290
1580
  cleanupOldConversations() {
1291
1581
  const now = Date.now();