pi-roundtable 0.17.3 → 0.19.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.
@@ -0,0 +1,475 @@
1
+ import {
2
+ DISCORD_LIMITS,
3
+ type DiscordCard,
4
+ type DiscordMessage,
5
+ type DiscordPoster,
6
+ type PostedMessage,
7
+ type RecentMessage,
8
+ } from "../discord/poster/poster.ts";
9
+ import {
10
+ cardBody,
11
+ checkAvatar,
12
+ checkedPost,
13
+ checkedText,
14
+ checkSnowflake,
15
+ checkWebhookName,
16
+ invalid,
17
+ } from "../discord/poster/poster-checks.ts";
18
+ import { DiscordPosterError } from "../discord/poster/poster-errors.ts";
19
+ import {
20
+ cardText,
21
+ type FakePosterChannel,
22
+ FakePosterGuild,
23
+ type FakePosterMessage,
24
+ } from "./fake-poster-guild.ts";
25
+
26
+ export type {
27
+ FakePosterChannel,
28
+ FakePosterMessage,
29
+ } from "./fake-poster-guild.ts";
30
+ export { cardText } from "./fake-poster-guild.ts";
31
+
32
+ function blobOf(file: { data: Uint8Array; contentType?: string }) {
33
+ return {
34
+ data: file.data,
35
+ ...(file.contentType ? { contentType: file.contentType } : {}),
36
+ };
37
+ }
38
+
39
+ /** What a test reads and sets on the fake poster, besides the `DiscordPoster` calls. */
40
+ export interface FakeDiscordPoster extends DiscordPoster {
41
+ readonly channels: Map<string, FakePosterChannel>;
42
+ readonly directs: { userId: string; content: string }[];
43
+ /** People who take no direct message from the bot. */
44
+ readonly closedDirects: Set<string>;
45
+ /** Channels that do not exist (any more). */
46
+ readonly goneChannels: Set<string>;
47
+ /** The categories that exist; creating a channel under any other fails as `channel_gone`. */
48
+ readonly categories: Set<string>;
49
+ /** How many channels a category holds (Discord: 50) and a guild holds (Discord: 500). */
50
+ categoryLimit: number;
51
+ guildLimit: number;
52
+ /** What the guild shows for a member, by user id; a user not listed has no name. */
53
+ readonly memberNames: Map<string, string>;
54
+ readonly memberLookups: { guildId: string; userId: string }[];
55
+ /** The guild every channel is in unless `channelGuilds` says another. */
56
+ guildId: string;
57
+ readonly channelGuilds: Map<string, string>;
58
+ /** `<channel id>:<user id>` pairs that cannot see the channel. */
59
+ readonly hiddenFrom: Set<string>;
60
+ /** People who are not members of the guild: they see no channel. */
61
+ readonly nonMembers: Set<string>;
62
+ /** Whether a pin posts the system notice Discord posts (type 6); off by default. */
63
+ pinNotices: boolean;
64
+ /** Whether the bot may read the audit log; off makes `threadCloser` fail as `forbidden`. */
65
+ canReadAuditLog: boolean;
66
+ /** Every rename, in order, as `[channel id, new name]`. */
67
+ readonly renames: [string, string][];
68
+ /** The bot user's id: the author of what `say` posts. */
69
+ readonly botId: string;
70
+ /** How many calls got past the argument checks, as the requests Discord would have seen. */
71
+ readonly requests: number;
72
+ /** Makes the next call of `operation` throw `error`. */
73
+ failNext(operation: keyof DiscordPoster, error: DiscordPosterError): void;
74
+ /** The channel's messages that still exist, oldest first. */
75
+ messagesIn(channelId: string): FakePosterMessage[];
76
+ /** A person writes in the channel (not through a webhook); the message's id. */
77
+ speak(
78
+ channelId: string,
79
+ author: { id: string; name: string },
80
+ content: string,
81
+ files?: { name: string; data: Uint8Array; contentType?: string }[],
82
+ urls?: string[],
83
+ ): string;
84
+ /** A person archives (or locks) the thread; `auditSkew` moves the audit entry's time away from the archive's. */
85
+ personCloses(
86
+ threadId: string,
87
+ userId: string,
88
+ options?: { lock?: boolean; auditSkew?: number },
89
+ ): void;
90
+ /** Discord archives a quiet thread on its own: no one is logged. */
91
+ autoArchives(threadId: string): void;
92
+ /** A person with Manage Threads opens a thread again. */
93
+ personOpens(threadId: string): void;
94
+ /** The threads started in a channel, oldest first. */
95
+ threadsIn(
96
+ channelId: string,
97
+ ): { id: string; name: string; startedOn: string }[];
98
+ /** The named webhook was deleted by hand; the next post makes it again, with the default picture. */
99
+ deleteWebhook(channelId: string, webhook: string): void;
100
+ /** The named webhook was deleted by hand and is made again at once; `onWebhookMade` hears it. */
101
+ recreateWebhook(channelId: string, webhook: string): Promise<void>;
102
+ }
103
+
104
+ /**
105
+ * A `DiscordPoster` in memory for tests, made by `fakeDiscordPoster()`: channels with their messages, pins and webhook pictures,
106
+ * and the direct messages sent. It keeps the real poster's argument checks (lengths, names,
107
+ * mentions, avatar), so a consumer that breaks a limit fails here too, and it can be told to fail
108
+ * the next call or to refuse direct messages from a person. The same scenarios run against it and
109
+ * against the real poster over an emulated Discord, so the two answer alike.
110
+ */
111
+ class FakePosterImpl extends FakePosterGuild implements FakeDiscordPoster {
112
+ readonly #webhooks = new Map<string, string>();
113
+ readonly #madeListeners = new Set<
114
+ (channelId: string, webhook: string) => Promise<void>
115
+ >();
116
+
117
+ constructor(options: { idOffset?: bigint; now?: () => number } = {}) {
118
+ super(options.idOffset ?? 0n, options.now ?? Date.now);
119
+ }
120
+
121
+ /** The channel's messages that still exist, oldest first. */
122
+ messagesIn(channelId: string): FakePosterMessage[] {
123
+ return [...(this.channels.get(channelId)?.messages ?? [])];
124
+ }
125
+
126
+ /** The id of the channel's webhook of that name, made on first use as Discord would (and announced). */
127
+ async #webhook(channelId: string, name: string): Promise<string> {
128
+ const slot = `${channelId}:${name}`;
129
+ let id = this.#webhooks.get(slot);
130
+ if (!id) {
131
+ this.nextWebhook += 1n;
132
+ id = this.nextWebhook.toString();
133
+ this.#webhooks.set(slot, id);
134
+ for (const listener of this.#madeListeners)
135
+ await listener(channelId, name).catch(() => undefined);
136
+ }
137
+ return id;
138
+ }
139
+
140
+ /** Tests: the named webhook was deleted by hand. The next post makes it again, with the default picture. */
141
+ deleteWebhook(channelId: string, webhook: string): void {
142
+ this.#webhooks.delete(`${channelId}:${webhook}`);
143
+ this.channels.get(channelId)?.avatars.delete(webhook);
144
+ }
145
+
146
+ /** Tests: the named webhook was deleted by hand and is made again at once; `onWebhookMade` hears it. */
147
+ async recreateWebhook(channelId: string, webhook: string): Promise<void> {
148
+ this.deleteWebhook(channelId, webhook);
149
+ await this.#webhook(channelId, webhook);
150
+ }
151
+
152
+ async post(
153
+ channelId: string,
154
+ post: Parameters<DiscordPoster["post"]>[1],
155
+ ): Promise<PostedMessage> {
156
+ checkSnowflake("a channel", channelId);
157
+ const { webhook, body, files } = checkedPost(post);
158
+ this.sent("post");
159
+ const channel = this.channel(channelId);
160
+ const webhookId = await this.#webhook(channelId, webhook);
161
+ this.next += 1n;
162
+ const message: FakePosterMessage = {
163
+ id: this.next.toString(),
164
+ username: body.username,
165
+ content: body.content ?? "",
166
+ files: files.map((file) => ({
167
+ name: file.name,
168
+ bytes: file.data.byteLength,
169
+ })),
170
+ blobs: files.map(blobOf),
171
+ mentions: body.allowed_mentions.users,
172
+ ...("avatar_url" in body ? { avatarUrl: body.avatar_url } : {}),
173
+ webhook,
174
+ webhookId,
175
+ at: this.tick(),
176
+ };
177
+ channel.messages.push(message);
178
+ return { channelId, messageId: message.id };
179
+ }
180
+
181
+ /** A person writes in the channel (not through a webhook); the message's id. */
182
+ speak(
183
+ channelId: string,
184
+ author: { id: string; name: string },
185
+ content: string,
186
+ files: { name: string; data: Uint8Array; contentType?: string }[] = [],
187
+ urls?: string[],
188
+ ): string {
189
+ const channel = this.channel(channelId);
190
+ this.next += 1n;
191
+ channel.messages.push({
192
+ id: this.next.toString(),
193
+ username: author.name,
194
+ content,
195
+ files: files.map((file) => ({
196
+ name: file.name,
197
+ bytes: file.data.byteLength,
198
+ })),
199
+ blobs: files.map(blobOf),
200
+ mentions: [],
201
+ authorId: author.id,
202
+ ...(urls ? { urls } : {}),
203
+ at: this.tick(),
204
+ });
205
+ return this.next.toString();
206
+ }
207
+
208
+ /** Whether the message was posted by this webhook as it exists now. */
209
+ #ownedBy(message: FakePosterMessage, channelId: string, name: string) {
210
+ return (
211
+ message.webhook === name &&
212
+ message.webhookId === this.#webhooks.get(`${channelId}:${name}`)
213
+ );
214
+ }
215
+
216
+ async edit(
217
+ channelId: string,
218
+ messageId: string,
219
+ edit: { content: string; mentions?: readonly string[] },
220
+ webhooks: readonly string[],
221
+ ): Promise<void> {
222
+ checkSnowflake("a channel", channelId);
223
+ checkSnowflake("a message", messageId);
224
+ const body = checkedText(edit.content, edit.mentions);
225
+ for (const name of webhooks) checkWebhookName(name);
226
+ this.sent("edit");
227
+ const message = this.found(channelId, messageId);
228
+ if (!webhooks.some((name) => this.#ownedBy(message, channelId, name)))
229
+ throw new DiscordPosterError(
230
+ "message_gone",
231
+ "the message does not exist",
232
+ );
233
+ message.content = body.content ?? "";
234
+ message.mentions = body.allowed_mentions.users;
235
+ }
236
+
237
+ async remove(
238
+ channelId: string,
239
+ messageId: string,
240
+ webhooks: readonly string[],
241
+ ): Promise<void> {
242
+ checkSnowflake("a channel", channelId);
243
+ checkSnowflake("a message", messageId);
244
+ for (const name of webhooks) checkWebhookName(name);
245
+ this.sent("remove");
246
+ this.#drop(channelId, messageId);
247
+ }
248
+
249
+ #drop(channelId: string, messageId: string): void {
250
+ const channel = this.channel(channelId);
251
+ this.found(channelId, messageId);
252
+ channel.messages = channel.messages.filter(
253
+ (message) => message.id !== messageId,
254
+ );
255
+ channel.pins = channel.pins.filter((id) => id !== messageId);
256
+ }
257
+
258
+ async webhookIds(
259
+ channelId: string,
260
+ names: readonly string[],
261
+ ): Promise<Readonly<Record<string, readonly string[]>>> {
262
+ checkSnowflake("a channel", channelId);
263
+ for (const name of names) checkWebhookName(name);
264
+ this.sent("webhookIds");
265
+ const ids: Record<string, string[]> = Object.create(null);
266
+ for (const name of names) {
267
+ const id = this.#webhooks.get(`${channelId}:${name}`);
268
+ ids[name] = id ? [id] : [];
269
+ }
270
+ return ids;
271
+ }
272
+
273
+ async setAvatar(
274
+ channelId: string,
275
+ webhook: string,
276
+ png: Uint8Array,
277
+ ): Promise<void> {
278
+ checkSnowflake("a channel", channelId);
279
+ checkWebhookName(webhook);
280
+ checkAvatar(png);
281
+ this.sent("setAvatar");
282
+ const channel = this.channel(channelId);
283
+ await this.#webhook(channelId, webhook);
284
+ channel.avatars.set(webhook, png);
285
+ }
286
+
287
+ onWebhookMade(
288
+ listener: (channelId: string, webhook: string) => Promise<void>,
289
+ ): () => void {
290
+ this.#madeListeners.add(listener);
291
+ return () => void this.#madeListeners.delete(listener);
292
+ }
293
+
294
+ async say(
295
+ channelId: string,
296
+ post: { content: string; mentions?: readonly string[] },
297
+ ): Promise<PostedMessage> {
298
+ checkSnowflake("a channel", channelId);
299
+ if (!post.content) throw invalid("a message needs text");
300
+ const body = checkedText(post.content, post.mentions);
301
+ this.sent("say");
302
+ const channel = this.channel(channelId);
303
+ this.wake(channel);
304
+ this.next += 1n;
305
+ channel.messages.push({
306
+ id: this.next.toString(),
307
+ username: "bot",
308
+ content: body.content ?? "",
309
+ files: [],
310
+ blobs: [],
311
+ mentions: body.allowed_mentions.users,
312
+ authorId: this.botId,
313
+ at: this.tick(),
314
+ });
315
+ return { channelId, messageId: this.next.toString() };
316
+ }
317
+
318
+ async sayCard(channelId: string, card: DiscordCard): Promise<PostedMessage> {
319
+ checkSnowflake("a channel", channelId);
320
+ cardBody(card);
321
+ this.sent("sayCard");
322
+ const channel = this.channel(channelId);
323
+ this.wake(channel);
324
+ this.next += 1n;
325
+ channel.messages.push({
326
+ id: this.next.toString(),
327
+ username: "bot",
328
+ content: cardText(card),
329
+ files: [],
330
+ blobs: [],
331
+ mentions: [],
332
+ authorId: this.botId,
333
+ card,
334
+ at: this.tick(),
335
+ });
336
+ return { channelId, messageId: this.next.toString() };
337
+ }
338
+
339
+ async editCard(
340
+ channelId: string,
341
+ messageId: string,
342
+ card: DiscordCard,
343
+ ): Promise<void> {
344
+ checkSnowflake("a channel", channelId);
345
+ checkSnowflake("a message", messageId);
346
+ cardBody(card);
347
+ this.sent("editCard");
348
+ const message = this.found(channelId, messageId);
349
+ // A message posted without the Components V2 flag cannot become a card.
350
+ if (!message.card)
351
+ throw new DiscordPosterError(
352
+ "rejected",
353
+ "editing a card failed: that message is not a card",
354
+ { discordCode: 50035, status: 400 },
355
+ );
356
+ message.card = card;
357
+ message.content = cardText(card);
358
+ }
359
+
360
+ async deleteAsBot(channelId: string, messageId: string): Promise<void> {
361
+ checkSnowflake("a channel", channelId);
362
+ checkSnowflake("a message", messageId);
363
+ this.sent("deleteAsBot");
364
+ this.#drop(channelId, messageId);
365
+ }
366
+
367
+ async sendDirect(
368
+ userId: string,
369
+ content: string,
370
+ ): Promise<"sent" | "closed"> {
371
+ checkSnowflake("a user", userId);
372
+ checkedText(content, []);
373
+ this.sent("sendDirect");
374
+ if (this.closedDirects.has(userId)) return "closed";
375
+ this.directs.push({ userId, content });
376
+ return "sent";
377
+ }
378
+
379
+ async pin(channelId: string, messageId: string): Promise<void> {
380
+ checkSnowflake("a channel", channelId);
381
+ checkSnowflake("a message", messageId);
382
+ this.sent("pin");
383
+ const channel = this.channel(channelId);
384
+ this.found(channelId, messageId);
385
+ if (channel.pins.includes(messageId)) return;
386
+ if (channel.pins.length >= DISCORD_LIMITS.pins)
387
+ throw new DiscordPosterError(
388
+ "rejected",
389
+ `a channel keeps at most ${DISCORD_LIMITS.pins} pins`,
390
+ { discordCode: 30003, status: 400 },
391
+ );
392
+ channel.pins.push(messageId);
393
+ if (this.pinNotices) {
394
+ this.next += 1n;
395
+ channel.messages.push({
396
+ id: this.next.toString(),
397
+ username: "bot",
398
+ content: "",
399
+ files: [],
400
+ blobs: [],
401
+ mentions: [],
402
+ authorId: this.botId,
403
+ type: 6,
404
+ referencedMessageId: messageId,
405
+ at: this.tick(),
406
+ });
407
+ }
408
+ }
409
+
410
+ async unpin(channelId: string, messageId: string): Promise<void> {
411
+ checkSnowflake("a channel", channelId);
412
+ checkSnowflake("a message", messageId);
413
+ this.sent("unpin");
414
+ const channel = this.channel(channelId);
415
+ channel.pins = channel.pins.filter((id) => id !== messageId);
416
+ }
417
+
418
+ async pinnedIds(channelId: string): Promise<string[]> {
419
+ checkSnowflake("a channel", channelId);
420
+ this.sent("pinnedIds");
421
+ return [...this.channel(channelId).pins].reverse();
422
+ }
423
+
424
+ async message(channelId: string, messageId: string): Promise<DiscordMessage> {
425
+ checkSnowflake("a channel", channelId);
426
+ checkSnowflake("a message", messageId);
427
+ this.sent("message");
428
+ const found = this.found(channelId, messageId);
429
+ return {
430
+ id: found.id,
431
+ authorId: found.webhookId ?? found.authorId ?? "0",
432
+ ...(found.webhookId ? { webhookId: found.webhookId } : {}),
433
+ content: found.content,
434
+ files: found.files.map((file, index) => ({
435
+ name: file.name,
436
+ contentType: found.blobs[index]?.contentType ?? "",
437
+ size: file.bytes,
438
+ url:
439
+ found.urls?.[index] ??
440
+ `https://cdn.test/${found.id}/${encodeURIComponent(file.name)}`,
441
+ })),
442
+ };
443
+ }
444
+
445
+ async recentMessages(
446
+ channelId: string,
447
+ limit: number,
448
+ ): Promise<RecentMessage[]> {
449
+ checkSnowflake("a channel", channelId);
450
+ this.sent("recentMessages");
451
+ return [...this.channel(channelId).messages]
452
+ .reverse()
453
+ .slice(0, Math.min(50, Math.max(1, Math.trunc(limit))))
454
+ .map((message) => ({
455
+ id: message.id,
456
+ type: message.type ?? 0,
457
+ authorId: message.webhookId ?? message.authorId ?? "0",
458
+ ...(message.referencedMessageId
459
+ ? { referencedMessageId: message.referencedMessageId }
460
+ : {}),
461
+ }));
462
+ }
463
+ }
464
+
465
+ /** A `DiscordPoster` in memory, to give a plugin under test as `DISCORD.poster`. */
466
+ export function fakeDiscordPoster(
467
+ options: {
468
+ /** Moves every id it makes, so test files that share a database never meet the same id. */
469
+ idOffset?: bigint;
470
+ /** The clock of the rename limit; default `Date.now`. */
471
+ now?: () => number;
472
+ } = {},
473
+ ): FakeDiscordPoster {
474
+ return new FakePosterImpl(options);
475
+ }
@@ -21,6 +21,7 @@ import type {
21
21
  InteractionContribution,
22
22
  } from "../discord/interaction-module.ts";
23
23
  import { commandGuard, ownerRootCommand } from "../discord/owner-command.ts";
24
+ import type { DiscordPoster } from "../discord/poster/poster.ts";
24
25
  import type { HoldDescription } from "../holds.ts";
25
26
  import { Roundtable } from "../host.ts";
26
27
  import { silentLogger } from "../log.ts";
@@ -37,6 +38,7 @@ import type {
37
38
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
38
39
  import { toolTurn } from "../tool-turn.ts";
39
40
  import { testDatabaseUrl } from "./database.ts";
41
+ import { fakeDiscordPoster } from "./fake-poster.ts";
40
42
  import { callRegisteredTool, registeredTools, toolNamed } from "./run-tool.ts";
41
43
 
42
44
  export interface TestHostOptions {
@@ -61,7 +63,15 @@ export interface TestHostOptions {
61
63
  */
62
64
  discord?:
63
65
  | false
64
- | Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
66
+ | (Partial<
67
+ Pick<
68
+ DiscordConnection,
69
+ "agentChannels" | "ownerChannel" | "channelInfo"
70
+ >
71
+ > & {
72
+ /** What `DISCORD.poster` is; by default a `fakeDiscordPoster()`. */
73
+ poster?: DiscordPoster;
74
+ });
65
75
  }
66
76
 
67
77
  export interface TestHost {
@@ -168,7 +178,7 @@ function quietRuntime(): AgentRuntime {
168
178
  export function standInDiscord(
169
179
  commands: CommandCollection,
170
180
  guard: CommandGuard,
171
- given: TestHostOptions["discord"],
181
+ given: Exclude<TestHostOptions["discord"], false>,
172
182
  /** The primary owner's Discord user id, whose direct messages are the owner channel. */
173
183
  ownerId: string,
174
184
  /** What `DISCORD.channelContext` answers; by default no context, as on a host with it off. */
@@ -185,9 +195,9 @@ export function standInDiscord(
185
195
  replaces: [DISCORD],
186
196
  setup: ({ services }) => {
187
197
  const identity = services.find(IDENTITY);
198
+ const { poster, ...overrides } = given ?? {};
188
199
  const ownerChannel =
189
- (given ? given.ownerChannel : undefined) ??
190
- (async () => "discord:owner-dm" as const);
200
+ overrides.ownerChannel ?? (async () => "discord:owner-dm" as const);
191
201
  // Each person's direct messages, as the Discord plugin's provider reaches them; the owner's is the owner channel.
192
202
  const direct = discordDirectChannel({
193
203
  directChannel: async (userId) =>
@@ -204,11 +214,12 @@ export function standInDiscord(
204
214
  onChannelDeleted: () => undefined,
205
215
  ownerChannel: async () => "discord:owner-dm",
206
216
  ownerOperations: () => ({}),
207
- ...given,
217
+ ...overrides,
208
218
  },
209
219
  commands: commands.registrar,
210
220
  guard,
211
221
  threads: { open: async () => undefined, sweep: async () => undefined },
222
+ poster: poster ?? fakeDiscordPoster(),
212
223
  channelContext,
213
224
  } as unknown as DiscordServices);
214
225
  return { surfaces: [surface], directChannels: [direct] };
@@ -78,5 +78,26 @@ export {
78
78
  plain,
79
79
  replyWithPanels,
80
80
  } from "../core/discord/owner-panel.ts";
81
+ export type {
82
+ CardButton,
83
+ CardLink,
84
+ CardSelect,
85
+ CategoryChannel,
86
+ DiscordCard,
87
+ DiscordFailure,
88
+ DiscordMessage,
89
+ DiscordPoster,
90
+ DiscordReason,
91
+ PostedMessage,
92
+ PosterFile,
93
+ RecentMessage,
94
+ ThreadCloser,
95
+ ThreadState,
96
+ WebhookPost,
97
+ } from "../core/discord/poster/poster.ts";
98
+ export {
99
+ DISCORD_LIMITS,
100
+ DiscordPosterError,
101
+ } from "../core/discord/poster/poster.ts";
81
102
  export type { OwnerOperations } from "../core/modules/discord-admin/discord-admin.ts";
82
103
  export { DISCORD_ADMIN_TOOLS } from "../core/modules/discord-admin/discord-admin.ts";
@@ -0,0 +1,15 @@
1
+ // Plugin helpers, versioned like the main entry; see the plugin guide.
2
+
3
+ export type {
4
+ CodexFetch,
5
+ CodexImage,
6
+ CodexImageRequest,
7
+ } from "../core/images/codex-image.ts";
8
+ export {
9
+ CODEX_IMAGE_MODEL,
10
+ CODEX_IMAGES_PROVIDER,
11
+ drawCodexImage,
12
+ ImageNotGeneratedError,
13
+ } from "../core/images/codex-image.ts";
14
+ export type { CodexImagesOptions } from "../core/images/codex-images-plugin.ts";
15
+ export { createCodexImages } from "../core/images/codex-images-plugin.ts";
package/src/kit/index.ts CHANGED
@@ -56,6 +56,19 @@ export {
56
56
  thinkingLabel,
57
57
  } from "./domain.ts";
58
58
  export { HOLD_DESCRIBE_TIMEOUT_MS, holdChain } from "./holds.ts";
59
+ export type {
60
+ CodexFetch,
61
+ CodexImage,
62
+ CodexImageRequest,
63
+ CodexImagesOptions,
64
+ } from "./images.ts";
65
+ export {
66
+ CODEX_IMAGE_MODEL,
67
+ CODEX_IMAGES_PROVIDER,
68
+ createCodexImages,
69
+ drawCodexImage,
70
+ ImageNotGeneratedError,
71
+ } from "./images.ts";
59
72
  export type {
60
73
  JevCompactInput,
61
74
  JevCompactOptions,
@@ -74,6 +87,8 @@ export {
74
87
  jevCompactionExtension,
75
88
  jevCompactor,
76
89
  } from "./jev.ts";
90
+ export type { JevJudgeOptions } from "./jev-judge.ts";
91
+ export { jevJudge } from "./jev-judge.ts";
77
92
  export type {
78
93
  EffortBrief,
79
94
  EffortJudgeOptions,
@@ -100,6 +115,12 @@ export {
100
115
  SCHEDULE_TOOLS,
101
116
  scheduleToolSpecs,
102
117
  } from "./mirror.ts";
118
+ export type {
119
+ OpsAlertKind,
120
+ OpsAlertsOptions,
121
+ OpsRaiseOptions,
122
+ } from "./ops-alerts.ts";
123
+ export { OpsAlerts } from "./ops-alerts.ts";
103
124
  export {
104
125
  headline,
105
126
  quietLinks,