@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.
- package/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
691
|
-
// text and keeps what the host added beside it
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
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
|
|
706
|
-
|
|
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
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
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
|
-
|
|
720
|
-
|
|
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:
|
|
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 (
|
|
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
|
|
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,
|
|
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.
|
|
1024
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
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)
|
|
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
|
|
1054
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1468
|
-
|
|
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
|
|
1783
|
+
return Math.max(1, Math.ceil((info.windowStart + windowMs - now) / 1000));
|
|
1481
1784
|
}
|
|
1482
1785
|
|
|
1483
1786
|
info.count++;
|
|
1484
|
-
return
|
|
1787
|
+
return 0;
|
|
1485
1788
|
}
|
|
1486
1789
|
|
|
1487
1790
|
private cleanupOldConversations(): void {
|