pi-roundtable 0.10.0 → 0.11.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/CHANGELOG.md +17 -0
- package/README.md +3 -0
- package/README.zh-TW.md +3 -0
- package/docs/plugins.md +19 -6
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +11 -7
- package/src/core/attachments/image-prep.ts +32 -4
- package/src/core/builtin/discord.ts +22 -0
- package/src/core/config/adapters.ts +33 -0
- package/src/core/contract/channel-context.ts +83 -3
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/discord/card-copies.ts +192 -0
- package/src/core/discord/channel-context.ts +142 -15
- package/src/core/discord/context-images.ts +164 -0
- package/src/core/discord/owner-cards-fixture.ts +9 -2
- package/src/core/discord/owner-cards.ts +47 -1
- package/src/core/domain/attachment.ts +3 -0
- package/src/core/i18n/cards.ts +7 -0
- package/src/core/interactions/prompts.ts +10 -0
- package/src/discord/index.ts +6 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { AttachmentBuilder, MessageFlags } from "discord.js";
|
|
5
|
+
import { messages } from "../i18n/index.ts";
|
|
6
|
+
import type { Logger } from "../log.ts";
|
|
7
|
+
|
|
8
|
+
/** The most characters of one card's copy text; a longer one gets no copy button. */
|
|
9
|
+
export const COPY_TEXT_LIMIT = 20_000;
|
|
10
|
+
/** How many cards' copy texts are kept by default, the newest. */
|
|
11
|
+
const COPY_KEEP = 100;
|
|
12
|
+
/** How many days a copy text is kept by default. */
|
|
13
|
+
const COPY_KEEP_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
interface Entry {
|
|
16
|
+
text: string;
|
|
17
|
+
at: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CardCopiesOptions {
|
|
21
|
+
/** A JSON file that keeps the texts across restarts; without it they live in memory only. */
|
|
22
|
+
path?: string;
|
|
23
|
+
/** How many cards' texts are kept, the newest; default `COPY_KEEP`. */
|
|
24
|
+
keep?: number;
|
|
25
|
+
/** How many days a text is kept; default `COPY_KEEP_DAYS`. */
|
|
26
|
+
keepDays?: number;
|
|
27
|
+
logger: Logger;
|
|
28
|
+
/** Replaceable in tests. */
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The text behind an approval card's copy button, by the card's id: the button's custom id holds
|
|
34
|
+
* the id only, so the text is looked up when it is pressed, also after the process restarted when
|
|
35
|
+
* there is a file. Bounded by count and age. The file is written off the hot path, one write at a
|
|
36
|
+
* time and a burst of cards in one, through a temporary file that replaces it whole, readable by
|
|
37
|
+
* the host's user only; one that cannot be read or written is logged and the texts that remain
|
|
38
|
+
* are kept in memory. Discarding a conversation does not remove its cards' texts: they age out.
|
|
39
|
+
*/
|
|
40
|
+
export class CardCopies {
|
|
41
|
+
readonly #options: CardCopiesOptions;
|
|
42
|
+
readonly #entries = new Map<string, Entry>();
|
|
43
|
+
#writing: Promise<void> | undefined;
|
|
44
|
+
#dirty = false;
|
|
45
|
+
|
|
46
|
+
constructor(options: CardCopiesOptions) {
|
|
47
|
+
this.#options = options;
|
|
48
|
+
const { path, logger } = options;
|
|
49
|
+
if (!path || !existsSync(path)) return;
|
|
50
|
+
try {
|
|
51
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
53
|
+
throw new Error("not a JSON object");
|
|
54
|
+
for (const [id, entry] of Object.entries(parsed)) {
|
|
55
|
+
const { text, at } = entry as Partial<Entry>;
|
|
56
|
+
if (typeof text === "string" && typeof at === "number")
|
|
57
|
+
this.#entries.set(id, { text, at });
|
|
58
|
+
}
|
|
59
|
+
this.#prune();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logger.warn(
|
|
62
|
+
{ path, err: error },
|
|
63
|
+
"card copy texts not read; copy buttons of earlier cards will say they are gone",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keeps a card's copy text. */
|
|
69
|
+
remember(id: string, text: string): void {
|
|
70
|
+
this.#entries.set(id, { text, at: this.#now() });
|
|
71
|
+
this.#prune();
|
|
72
|
+
this.#schedule();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A card's copy text; undefined when it was never kept or has aged out. */
|
|
76
|
+
recall(id: string): string | undefined {
|
|
77
|
+
const entry = this.#entries.get(id);
|
|
78
|
+
if (!entry) return undefined;
|
|
79
|
+
if (this.#now() - entry.at > this.#keepMs()) {
|
|
80
|
+
this.#entries.delete(id);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
return entry.text;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolves once what was remembered so far is on disk; for a host that stops and for tests. */
|
|
87
|
+
async flush(): Promise<void> {
|
|
88
|
+
while (this.#writing) await this.#writing;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#now(): number {
|
|
92
|
+
return (this.#options.now ?? Date.now)();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#keepMs(): number {
|
|
96
|
+
return (this.#options.keepDays ?? COPY_KEEP_DAYS) * 24 * 60 * 60_000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#prune(): void {
|
|
100
|
+
const now = this.#now();
|
|
101
|
+
const keep = this.#options.keep ?? COPY_KEEP;
|
|
102
|
+
for (const [id, entry] of this.#entries)
|
|
103
|
+
if (now - entry.at > this.#keepMs()) this.#entries.delete(id);
|
|
104
|
+
for (const id of this.#entries.keys()) {
|
|
105
|
+
if (this.#entries.size <= keep) return;
|
|
106
|
+
this.#entries.delete(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Writes the file after the call that asked, once at a time; asks made meanwhile share the next write. */
|
|
111
|
+
#schedule(): void {
|
|
112
|
+
if (!this.#options.path) return;
|
|
113
|
+
this.#dirty = true;
|
|
114
|
+
if (this.#writing) return;
|
|
115
|
+
this.#writing = this.#drain().finally(() => {
|
|
116
|
+
this.#writing = undefined;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async #drain(): Promise<void> {
|
|
121
|
+
// Let the caller finish before the entries are serialized.
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
while (this.#dirty) {
|
|
124
|
+
this.#dirty = false;
|
|
125
|
+
await this.#write();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #write(): Promise<void> {
|
|
130
|
+
const { path, logger } = this.#options;
|
|
131
|
+
if (!path) return;
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(dirname(path), { recursive: true });
|
|
134
|
+
const temporary = `${path}.tmp`;
|
|
135
|
+
await writeFile(
|
|
136
|
+
temporary,
|
|
137
|
+
`${JSON.stringify(Object.fromEntries(this.#entries))}\n`,
|
|
138
|
+
{ mode: 0o600 },
|
|
139
|
+
);
|
|
140
|
+
// A temporary file left by an earlier run keeps its mode, so set it.
|
|
141
|
+
await chmod(temporary, 0o600);
|
|
142
|
+
await rename(temporary, path);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.warn({ path, err: error }, "card copy texts not saved");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Discord's limit for the text of a message. */
|
|
150
|
+
const MESSAGE_LIMIT = 2_000;
|
|
151
|
+
/** A button label's limit. */
|
|
152
|
+
const LABEL_LIMIT = 80;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The copy button an approval offers: its label and text; none when there is no text, and none,
|
|
156
|
+
* reported through `tooLong`, when the text is over `COPY_TEXT_LIMIT`.
|
|
157
|
+
*/
|
|
158
|
+
export function offeredCopy(
|
|
159
|
+
copy: { label?: string; text: string } | undefined,
|
|
160
|
+
tooLong: (chars: number) => void,
|
|
161
|
+
): { label: string; text: string } | undefined {
|
|
162
|
+
if (!copy || copy.text === "") return undefined;
|
|
163
|
+
if (copy.text.length > COPY_TEXT_LIMIT) {
|
|
164
|
+
tooLong(copy.text.length);
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
const label = copy.label?.trim();
|
|
168
|
+
return {
|
|
169
|
+
label: label ? label.slice(0, LABEL_LIMIT) : messages().cardCopyLabel,
|
|
170
|
+
text: copy.text,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The reply to a press of a copy button: the text in a code block, or a file when it does not fit one. */
|
|
175
|
+
export function copyReply(text: string) {
|
|
176
|
+
const block = `\`\`\`\n${text}\n\`\`\``;
|
|
177
|
+
const inline = !text.includes("```") && block.length <= MESSAGE_LIMIT;
|
|
178
|
+
return {
|
|
179
|
+
content: inline ? block : messages().cardCopyFile,
|
|
180
|
+
...(inline
|
|
181
|
+
? {}
|
|
182
|
+
: {
|
|
183
|
+
files: [
|
|
184
|
+
new AttachmentBuilder(Buffer.from(text, "utf8"), {
|
|
185
|
+
name: "copy.txt",
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
flags: MessageFlags.Ephemeral as const,
|
|
190
|
+
allowedMentions: { parse: [] as never[] },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -1,30 +1,64 @@
|
|
|
1
1
|
import type { Message } from "discord.js";
|
|
2
2
|
import type {
|
|
3
3
|
ChannelContext,
|
|
4
|
+
ChannelContextAttachmentOptions,
|
|
4
5
|
ChannelContextMessage,
|
|
5
6
|
ChannelContextOptions,
|
|
6
7
|
} from "../contract/channel-context.ts";
|
|
7
8
|
import type { InboundMessage } from "../contract/channels.ts";
|
|
8
9
|
import { parseChannelKey } from "../contract/surface.ts";
|
|
10
|
+
import { MAX_TURN_IMAGES } from "../domain/attachment.ts";
|
|
9
11
|
import type { Logger } from "../log.ts";
|
|
12
|
+
import { contextImages, type SkippedImage } from "./context-images.ts";
|
|
10
13
|
import type { BotIdentity } from "./inbound-message.ts";
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
/** The most bytes one image may be: the core's limit for any attachment. */
|
|
16
|
+
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** The options with every default filled in, the attachments' too. */
|
|
19
|
+
export type ChannelContextSettings = Required<
|
|
20
|
+
Omit<ChannelContextOptions, "attachments">
|
|
21
|
+
> & { attachments: Required<ChannelContextAttachmentOptions> };
|
|
13
22
|
|
|
14
23
|
/** The defaults of `ChannelContextOptions`. */
|
|
15
24
|
export const CHANNEL_CONTEXT_DEFAULTS: Readonly<
|
|
16
|
-
Required<ChannelContextOptions>
|
|
25
|
+
Omit<Required<ChannelContextOptions>, "attachments"> & {
|
|
26
|
+
attachments: Readonly<Required<ChannelContextAttachmentOptions>>;
|
|
27
|
+
}
|
|
17
28
|
> = Object.freeze({
|
|
18
29
|
fetch: 50,
|
|
19
30
|
keep: 15,
|
|
20
31
|
similarity: 0.8,
|
|
21
32
|
messageChars: 500,
|
|
22
33
|
botMessageChars: 80,
|
|
34
|
+
foreignWebhook: () => false,
|
|
35
|
+
attachments: Object.freeze({ images: 0, maxBytes: 10 * 1024 * 1024 }),
|
|
23
36
|
});
|
|
24
37
|
|
|
38
|
+
/** The host's options, then the call's, over the defaults; the attachments' members merge one by one. */
|
|
39
|
+
function resolvedSettings(
|
|
40
|
+
host: ChannelContextOptions,
|
|
41
|
+
call: ChannelContextOptions,
|
|
42
|
+
): ChannelContextSettings {
|
|
43
|
+
const merged = {
|
|
44
|
+
...CHANNEL_CONTEXT_DEFAULTS,
|
|
45
|
+
...defined(host),
|
|
46
|
+
...defined(call),
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
...merged,
|
|
50
|
+
attachments: {
|
|
51
|
+
...CHANNEL_CONTEXT_DEFAULTS.attachments,
|
|
52
|
+
...defined(host.attachments ?? {}),
|
|
53
|
+
...defined(call.attachments ?? {}),
|
|
54
|
+
} as Required<ChannelContextAttachmentOptions>,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
25
58
|
/** Throws on a value outside what the configuration accepts: a programming error of the caller. */
|
|
26
59
|
function assertChannelContextSettings(settings: ChannelContextSettings): void {
|
|
27
60
|
const { fetch, keep, similarity, messageChars, botMessageChars } = settings;
|
|
61
|
+
const { images, maxBytes } = settings.attachments;
|
|
28
62
|
const positive = { fetch, keep, messageChars, botMessageChars };
|
|
29
63
|
for (const [name, value] of Object.entries(positive))
|
|
30
64
|
if (!Number.isInteger(value) || value < 1)
|
|
@@ -35,6 +69,18 @@ function assertChannelContextSettings(settings: ChannelContextSettings): void {
|
|
|
35
69
|
throw new RangeError(
|
|
36
70
|
`channel context option similarity must be between 0 and 1, got ${similarity}`,
|
|
37
71
|
);
|
|
72
|
+
if (typeof settings.foreignWebhook !== "function")
|
|
73
|
+
throw new TypeError(
|
|
74
|
+
"channel context option foreignWebhook must be a function",
|
|
75
|
+
);
|
|
76
|
+
if (!Number.isInteger(images) || images < 0 || images > MAX_TURN_IMAGES)
|
|
77
|
+
throw new RangeError(
|
|
78
|
+
`channel context option attachments.images must be an integer from 0 to ${MAX_TURN_IMAGES}, got ${images}`,
|
|
79
|
+
);
|
|
80
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_IMAGE_BYTES)
|
|
81
|
+
throw new RangeError(
|
|
82
|
+
`channel context option attachments.maxBytes must be an integer from 1 to ${MAX_IMAGE_BYTES}, got ${maxBytes}`,
|
|
83
|
+
);
|
|
38
84
|
}
|
|
39
85
|
|
|
40
86
|
/** What channel context reads of one fetched message. */
|
|
@@ -45,16 +91,28 @@ export interface ContextSourceMessage {
|
|
|
45
91
|
bot: boolean;
|
|
46
92
|
/** The assistant posted it: its bot user, or a webhook of its application such as an agent's voice. */
|
|
47
93
|
own: boolean;
|
|
94
|
+
/** The webhook it was posted through, when that is a webhook of the assistant's application. */
|
|
95
|
+
webhookId?: string;
|
|
96
|
+
/** The files attached to it. */
|
|
97
|
+
attachments?: readonly ContextSourceAttachment[];
|
|
48
98
|
/** When it was posted, in epoch milliseconds. */
|
|
49
99
|
at: number;
|
|
50
100
|
text: string;
|
|
51
101
|
}
|
|
52
102
|
|
|
103
|
+
/** A file attached to a fetched message, as Discord describes it. */
|
|
104
|
+
export interface ContextSourceAttachment {
|
|
105
|
+
name: string;
|
|
106
|
+
contentType: string;
|
|
107
|
+
size: number;
|
|
108
|
+
url: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
53
111
|
/** The options that were given: a key set to undefined is the same as a missing one. */
|
|
54
|
-
function defined(options:
|
|
112
|
+
function defined<T extends object>(options: T): Partial<T> {
|
|
55
113
|
return Object.fromEntries(
|
|
56
114
|
Object.entries(options).filter(([, value]) => value !== undefined),
|
|
57
|
-
)
|
|
115
|
+
) as Partial<T>;
|
|
58
116
|
}
|
|
59
117
|
|
|
60
118
|
function attachmentLabel(contentType: string | null | undefined): string {
|
|
@@ -96,8 +154,17 @@ export function contextSourceOf(
|
|
|
96
154
|
message.author.username,
|
|
97
155
|
bot: message.author.bot,
|
|
98
156
|
own: message.author.id === botId || ownWebhook,
|
|
157
|
+
...(ownWebhook && message.webhookId
|
|
158
|
+
? { webhookId: message.webhookId }
|
|
159
|
+
: {}),
|
|
99
160
|
at: message.createdTimestamp,
|
|
100
161
|
text: parts.join(" "),
|
|
162
|
+
attachments: [...message.attachments.values()].map((file) => ({
|
|
163
|
+
name: file.name,
|
|
164
|
+
contentType: file.contentType ?? "",
|
|
165
|
+
size: file.size,
|
|
166
|
+
url: file.url,
|
|
167
|
+
})),
|
|
101
168
|
};
|
|
102
169
|
}
|
|
103
170
|
|
|
@@ -126,10 +193,14 @@ function similarity(a: string, b: string): number {
|
|
|
126
193
|
return longest / longer.length;
|
|
127
194
|
}
|
|
128
195
|
|
|
129
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Consecutive messages of one author at least `threshold` alike become the later one. A `foreign`
|
|
198
|
+
* message is one of a webhook that carries many people, so it also needs the same name.
|
|
199
|
+
*/
|
|
130
200
|
function merged(
|
|
131
201
|
messages: readonly ContextSourceMessage[],
|
|
132
202
|
threshold: number,
|
|
203
|
+
foreign: (message: ContextSourceMessage) => boolean,
|
|
133
204
|
): ContextSourceMessage[] {
|
|
134
205
|
const out: ContextSourceMessage[] = [];
|
|
135
206
|
for (const message of messages) {
|
|
@@ -137,6 +208,7 @@ function merged(
|
|
|
137
208
|
if (
|
|
138
209
|
last &&
|
|
139
210
|
last.authorId === message.authorId &&
|
|
211
|
+
(!foreign(message) || last.authorName === message.authorName) &&
|
|
140
212
|
similarity(last.text, message.text) >= threshold
|
|
141
213
|
)
|
|
142
214
|
out[out.length - 1] = message;
|
|
@@ -155,7 +227,9 @@ export interface ChannelContextSelection {
|
|
|
155
227
|
seen: ReadonlySet<string>;
|
|
156
228
|
/** The owners' user ids. */
|
|
157
229
|
owners: readonly string[];
|
|
158
|
-
settings
|
|
230
|
+
/** What the selection reads of the settings; without `foreignWebhook` every webhook of the application is the assistant's. */
|
|
231
|
+
settings: Omit<ChannelContextSettings, "foreignWebhook" | "attachments"> &
|
|
232
|
+
Partial<Pick<ChannelContextSettings, "foreignWebhook">>;
|
|
159
233
|
}
|
|
160
234
|
|
|
161
235
|
/**
|
|
@@ -168,12 +242,19 @@ export function selectChannelContext(
|
|
|
168
242
|
{ seen, owners, settings }: ChannelContextSelection,
|
|
169
243
|
): { messages: ChannelContextMessage[]; window: string[] } {
|
|
170
244
|
const ordered = fetched.toSorted((a, b) => a.at - b.at);
|
|
171
|
-
|
|
245
|
+
// A webhook of the application that the host calls foreign speaks for other people, not for the assistant.
|
|
246
|
+
const foreign = (message: ContextSourceMessage) =>
|
|
247
|
+
message.webhookId !== undefined &&
|
|
248
|
+
settings.foreignWebhook?.(message.webhookId) === true;
|
|
249
|
+
const assistants = (message: ContextSourceMessage) =>
|
|
250
|
+
message.own && !foreign(message);
|
|
251
|
+
const lastOwn = ordered.findLastIndex(assistants);
|
|
172
252
|
const window = ordered.slice(lastOwn + 1);
|
|
173
253
|
// Cut before comparing: the comparison is quadratic in the text lengths.
|
|
174
254
|
const fresh = window
|
|
175
255
|
.filter(
|
|
176
|
-
(message) =>
|
|
256
|
+
(message) =>
|
|
257
|
+
!assistants(message) && !seen.has(message.id) && message.text !== "",
|
|
177
258
|
)
|
|
178
259
|
.map((message) => ({
|
|
179
260
|
...message,
|
|
@@ -182,7 +263,9 @@ export function selectChannelContext(
|
|
|
182
263
|
message.bot ? settings.botMessageChars : settings.messageChars,
|
|
183
264
|
),
|
|
184
265
|
}));
|
|
185
|
-
const kept = merged(fresh, settings.similarity).slice(
|
|
266
|
+
const kept = merged(fresh, settings.similarity, foreign).slice(
|
|
267
|
+
-settings.keep,
|
|
268
|
+
);
|
|
186
269
|
return {
|
|
187
270
|
window: window.map((message) => message.id),
|
|
188
271
|
messages: kept.map((message) => ({
|
|
@@ -193,6 +276,10 @@ export function selectChannelContext(
|
|
|
193
276
|
owner: !message.bot && owners.includes(message.authorId),
|
|
194
277
|
text: message.text,
|
|
195
278
|
at: new Date(message.at),
|
|
279
|
+
attachments: (message.attachments ?? []).map((file) => ({
|
|
280
|
+
...file,
|
|
281
|
+
messageId: message.id,
|
|
282
|
+
})),
|
|
196
283
|
})),
|
|
197
284
|
};
|
|
198
285
|
}
|
|
@@ -236,6 +323,21 @@ export function formatChannelContext(
|
|
|
236
323
|
].join("\n");
|
|
237
324
|
}
|
|
238
325
|
|
|
326
|
+
/** The messages with a note in the text of each for an image that was asked for and not attached. */
|
|
327
|
+
function withSkipNotes(
|
|
328
|
+
messages: readonly ChannelContextMessage[],
|
|
329
|
+
skipped: readonly SkippedImage[],
|
|
330
|
+
): ChannelContextMessage[] {
|
|
331
|
+
return messages.map((message) => {
|
|
332
|
+
const notes = skipped
|
|
333
|
+
.filter((image) => image.messageId === message.id)
|
|
334
|
+
.map((image) => `[image not attached: ${image.name}: ${image.reason}]`);
|
|
335
|
+
return notes.length === 0
|
|
336
|
+
? { ...message }
|
|
337
|
+
: { ...message, text: [message.text, ...notes].join(" ") };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
239
341
|
/** How many delivered message ids are remembered per channel, and how many channels. */
|
|
240
342
|
const SEEN_PER_CHANNEL = 500;
|
|
241
343
|
const SEEN_CHANNELS = 1_000;
|
|
@@ -252,6 +354,8 @@ export interface ChannelContextReaderOptions {
|
|
|
252
354
|
/** The host's settings; `false` turns channel context off for every claim. */
|
|
253
355
|
settings: ChannelContextOptions | false;
|
|
254
356
|
logger: Logger;
|
|
357
|
+
/** Fetches the images of messages; replaceable in tests. */
|
|
358
|
+
fetchImpl?: typeof fetch;
|
|
255
359
|
}
|
|
256
360
|
|
|
257
361
|
/**
|
|
@@ -280,11 +384,7 @@ export class ChannelContextReader {
|
|
|
280
384
|
return undefined;
|
|
281
385
|
const { surface, id: channelId } = parseChannelKey(message.channel);
|
|
282
386
|
if (surface !== "discord") return undefined;
|
|
283
|
-
const settings =
|
|
284
|
-
...CHANNEL_CONTEXT_DEFAULTS,
|
|
285
|
-
...defined(host),
|
|
286
|
-
...defined(options),
|
|
287
|
-
};
|
|
387
|
+
const settings = resolvedSettings(host, options);
|
|
288
388
|
assertChannelContextSettings(settings);
|
|
289
389
|
const seen = this.#seenIn(channelId);
|
|
290
390
|
seen.add(message.messageId);
|
|
@@ -304,7 +404,13 @@ export class ChannelContextReader {
|
|
|
304
404
|
for (const id of window) seen.add(id);
|
|
305
405
|
this.#trim(seen);
|
|
306
406
|
if (messages.length === 0) return undefined;
|
|
307
|
-
|
|
407
|
+
const { images, skipped } = await this.#images(messages, settings);
|
|
408
|
+
const noted = withSkipNotes(messages, skipped);
|
|
409
|
+
return {
|
|
410
|
+
messages: noted,
|
|
411
|
+
text: formatChannelContext(noted),
|
|
412
|
+
...(images.length > 0 ? { images } : {}),
|
|
413
|
+
};
|
|
308
414
|
} catch (error) {
|
|
309
415
|
logger.warn(
|
|
310
416
|
{ channel: message.channel, err: error },
|
|
@@ -314,6 +420,27 @@ export class ChannelContextReader {
|
|
|
314
420
|
}
|
|
315
421
|
}
|
|
316
422
|
|
|
423
|
+
/** The images of the kept messages the settings ask for, the newest first; none when they ask for none. */
|
|
424
|
+
async #images(
|
|
425
|
+
messages: readonly ChannelContextMessage[],
|
|
426
|
+
settings: ChannelContextSettings,
|
|
427
|
+
) {
|
|
428
|
+
const { images: count, maxBytes } = settings.attachments;
|
|
429
|
+
if (count === 0) return { images: [], skipped: [] };
|
|
430
|
+
const candidates = messages
|
|
431
|
+
.toReversed()
|
|
432
|
+
.flatMap((message) => message.attachments ?? [])
|
|
433
|
+
.filter((file) => file.contentType.startsWith("image/"));
|
|
434
|
+
return contextImages(candidates, {
|
|
435
|
+
count,
|
|
436
|
+
maxBytes,
|
|
437
|
+
logger: this.#options.logger,
|
|
438
|
+
...(this.#options.fetchImpl
|
|
439
|
+
? { fetchImpl: this.#options.fetchImpl }
|
|
440
|
+
: {}),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
317
444
|
/** The owners' user ids; none, logged, when they cannot be read, since marking them is not worth failing for. */
|
|
318
445
|
async #owners(): Promise<readonly string[]> {
|
|
319
446
|
try {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isModelImage,
|
|
3
|
+
prepareImageBytes,
|
|
4
|
+
sniffImageType,
|
|
5
|
+
} from "../attachments/image-prep.ts";
|
|
6
|
+
import type { ChannelContextAttachment } from "../contract/channel-context.ts";
|
|
7
|
+
import type { ModelImage } from "../domain/attachment.ts";
|
|
8
|
+
import type { Logger } from "../log.ts";
|
|
9
|
+
|
|
10
|
+
/** Why an image of a channel message was not attached to the turn; fixed words, never an error's text. */
|
|
11
|
+
export type SkippedReason =
|
|
12
|
+
| "unsupported type"
|
|
13
|
+
| "too large"
|
|
14
|
+
| "download failed"
|
|
15
|
+
| "could not be decoded";
|
|
16
|
+
|
|
17
|
+
export interface SkippedImage {
|
|
18
|
+
messageId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
reason: SkippedReason;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How long one image may take to download. */
|
|
24
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
25
|
+
/** How long all the images of one call may take to download, whatever their number. */
|
|
26
|
+
const DEADLINE_MS = 20_000;
|
|
27
|
+
/** How many images are downloaded at once. */
|
|
28
|
+
const CONCURRENCY = 2;
|
|
29
|
+
|
|
30
|
+
/** The bytes of a response, or undefined once they pass `max`. */
|
|
31
|
+
async function boundedBody(
|
|
32
|
+
response: Response,
|
|
33
|
+
max: number,
|
|
34
|
+
): Promise<Uint8Array | undefined> {
|
|
35
|
+
if (!response.body) return new Uint8Array();
|
|
36
|
+
const reader = response.body.getReader();
|
|
37
|
+
const chunks: Uint8Array[] = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const { done, value } = await reader.read();
|
|
42
|
+
if (done) break;
|
|
43
|
+
size += value.byteLength;
|
|
44
|
+
if (size > max) {
|
|
45
|
+
await reader.cancel();
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
reader.releaseLock();
|
|
52
|
+
}
|
|
53
|
+
const data = new Uint8Array(size);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
data.set(chunk, offset);
|
|
57
|
+
offset += chunk.byteLength;
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fetches up to `count` of the candidates' images, in the order given, and prepares them as a
|
|
64
|
+
* turn's own images are (see `prepareImageBytes`). A candidate that is not an image the model
|
|
65
|
+
* accepts (by its declared type, and by its bytes, which give the type it is sent as), is over
|
|
66
|
+
* `maxBytes` (by Discord's size or by what arrives), cannot be fetched, or cannot be decoded is
|
|
67
|
+
* skipped with its reason; a candidate of a type the model does not accept does not count toward
|
|
68
|
+
* `count`, and at most twice `count` are tried. Two are downloaded at once, and all of them share
|
|
69
|
+
* one deadline: the candidates that are still to be fetched when it passes are skipped as
|
|
70
|
+
* undownloadable. Bytes stay in memory and nothing about them is logged.
|
|
71
|
+
*/
|
|
72
|
+
export async function contextImages(
|
|
73
|
+
candidates: readonly ChannelContextAttachment[],
|
|
74
|
+
options: {
|
|
75
|
+
count: number;
|
|
76
|
+
maxBytes: number;
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
logger: Logger;
|
|
79
|
+
/** Replaceable in tests; default 20 seconds. */
|
|
80
|
+
deadlineMs?: number;
|
|
81
|
+
},
|
|
82
|
+
): Promise<{ images: ModelImage[]; skipped: SkippedImage[] }> {
|
|
83
|
+
const { count } = options;
|
|
84
|
+
const deadline = AbortSignal.timeout(options.deadlineMs ?? DEADLINE_MS);
|
|
85
|
+
const images: ModelImage[] = [];
|
|
86
|
+
const skipped: SkippedImage[] = [];
|
|
87
|
+
const skip = (file: ChannelContextAttachment, reason: SkippedReason) =>
|
|
88
|
+
skipped.push({ messageId: file.messageId, name: file.name, reason });
|
|
89
|
+
let tried = 0;
|
|
90
|
+
let next = 0;
|
|
91
|
+
while (images.length < count && next < candidates.length) {
|
|
92
|
+
// Never start more downloads than there are images still wanted, so none is wasted.
|
|
93
|
+
const batch: ChannelContextAttachment[] = [];
|
|
94
|
+
const room = Math.min(CONCURRENCY, count - images.length);
|
|
95
|
+
while (
|
|
96
|
+
batch.length < room &&
|
|
97
|
+
next < candidates.length &&
|
|
98
|
+
tried < count * 2
|
|
99
|
+
) {
|
|
100
|
+
const file = candidates[next++] as ChannelContextAttachment;
|
|
101
|
+
if (
|
|
102
|
+
!isModelImage({ contentType: file.contentType }) ||
|
|
103
|
+
!file.url.startsWith("https://")
|
|
104
|
+
) {
|
|
105
|
+
skip(file, "unsupported type");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
tried++;
|
|
109
|
+
batch.push(file);
|
|
110
|
+
}
|
|
111
|
+
if (batch.length === 0) break;
|
|
112
|
+
const fetched = await Promise.all(
|
|
113
|
+
batch.map((file) => oneImage(file, options, deadline)),
|
|
114
|
+
);
|
|
115
|
+
for (const [index, result] of fetched.entries()) {
|
|
116
|
+
const file = batch[index] as ChannelContextAttachment;
|
|
117
|
+
if ("image" in result) images.push(result.image);
|
|
118
|
+
else skip(file, result.skipped);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { images, skipped };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One candidate's image, or why it is skipped. */
|
|
125
|
+
async function oneImage(
|
|
126
|
+
file: ChannelContextAttachment,
|
|
127
|
+
options: {
|
|
128
|
+
maxBytes: number;
|
|
129
|
+
fetchImpl?: typeof fetch;
|
|
130
|
+
logger: Logger;
|
|
131
|
+
},
|
|
132
|
+
deadline: AbortSignal,
|
|
133
|
+
): Promise<{ image: ModelImage } | { skipped: SkippedReason }> {
|
|
134
|
+
const { maxBytes, logger, fetchImpl = fetch } = options;
|
|
135
|
+
if (file.size > maxBytes) return { skipped: "too large" };
|
|
136
|
+
if (deadline.aborted) return { skipped: "download failed" };
|
|
137
|
+
let data: Uint8Array | undefined;
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetchImpl(file.url, {
|
|
140
|
+
signal: AbortSignal.any([
|
|
141
|
+
deadline,
|
|
142
|
+
AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
143
|
+
]),
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
await response.body?.cancel();
|
|
147
|
+
return { skipped: "download failed" };
|
|
148
|
+
}
|
|
149
|
+
data = await boundedBody(response, maxBytes);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logger.warn({ err: error }, "channel context image not fetched");
|
|
152
|
+
return { skipped: "download failed" };
|
|
153
|
+
}
|
|
154
|
+
if (!data) return { skipped: "too large" };
|
|
155
|
+
// The bytes say what it is; Discord's type is a guess from the file's name.
|
|
156
|
+
const type = sniffImageType(data);
|
|
157
|
+
if (!type) return { skipped: "unsupported type" };
|
|
158
|
+
try {
|
|
159
|
+
return { image: await prepareImageBytes(data, type) };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.warn({ err: error }, "channel context image not decoded");
|
|
162
|
+
return { skipped: "could not be decoded" };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -47,6 +47,8 @@ export function press(
|
|
|
47
47
|
} = {},
|
|
48
48
|
) {
|
|
49
49
|
const replies: string[] = [];
|
|
50
|
+
/** Everything a reply carried besides its text. */
|
|
51
|
+
const replied: { files?: unknown[]; flags?: unknown }[] = [];
|
|
50
52
|
const updates: CardPayload[] = [];
|
|
51
53
|
const modals: unknown[] = [];
|
|
52
54
|
// SAFETY: the cards read only these members of an interaction.
|
|
@@ -65,8 +67,13 @@ export function press(
|
|
|
65
67
|
},
|
|
66
68
|
values: options.values ?? [],
|
|
67
69
|
fields: { getTextInputValue: () => options.text ?? "" },
|
|
68
|
-
reply: async (answer: {
|
|
70
|
+
reply: async (answer: {
|
|
71
|
+
content: string;
|
|
72
|
+
files?: unknown[];
|
|
73
|
+
flags?: unknown;
|
|
74
|
+
}) => {
|
|
69
75
|
replies.push(answer.content);
|
|
76
|
+
replied.push(answer);
|
|
70
77
|
},
|
|
71
78
|
update: async (payload: CardPayload) => {
|
|
72
79
|
updates.push(payload);
|
|
@@ -76,7 +83,7 @@ export function press(
|
|
|
76
83
|
},
|
|
77
84
|
deferUpdate: async () => undefined,
|
|
78
85
|
} as unknown as Interaction;
|
|
79
|
-
return { interaction, replies, updates, modals };
|
|
86
|
+
return { interaction, replies, replied, updates, modals };
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/** Lets a posted card settle. */
|