pi-roundtable 0.22.5 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +351 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -1,16 +1,18 @@
1
+ import { channelKey, parseChannelKey } from "../contract/surface.ts";
1
2
  import type { TurnAttachments } from "../domain/attachment.ts";
2
3
  import type {
3
4
  ChannelKey,
4
5
  PendingConfirmation,
6
+ PostAuthor,
5
7
  ReplyFile,
6
8
  TurnResult,
7
9
  } from "../domain/conversation.ts";
8
10
  import { AgentError } from "../domain/errors.ts";
9
11
  import type { InterimPosts } from "../domain/interim.ts";
10
12
  import type { AgentTurnScope } from "../domain/ports.ts";
11
- import { failureNotice } from "../presentation/failure-notice.ts";
13
+ import type { TurnProgress } from "../domain/progress.ts";
14
+ import { replyBody } from "../presentation/reply-body.ts";
12
15
  import { splitReply } from "../presentation/reply-splitter.ts";
13
- import { thinkingLine } from "../presentation/thinking-line.ts";
14
16
  import { withReplyFiles } from "../reply-files.ts";
15
17
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
16
18
  import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
@@ -20,10 +22,15 @@ import { AgentMessages } from "./agent-messages.ts";
20
22
  import type { Agent } from "./agent-store.ts";
21
23
  import { GroupTurns } from "./group-turns.ts";
22
24
  import {
25
+ agentOfChannel,
23
26
  channelIdOf,
24
27
  channelOwner,
28
+ coordinatorOf,
29
+ discordIdOf,
25
30
  discordKey,
26
31
  homeScope,
32
+ keysOf,
33
+ roomScope,
27
34
  } from "./team-keys.ts";
28
35
  import {
29
36
  type Chain,
@@ -45,6 +52,8 @@ export class TeamTurns {
45
52
  readonly #chains = new Map<ChannelKey, Chain>();
46
53
  /** The channel each working agent's turn posts in. */
47
54
  readonly #working = new Map<string, ChannelKey>();
55
+ /** The channel each running turn posts in, by session key; a seat's turns run in its group's room. */
56
+ readonly #posting = new Map<ChannelKey, ChannelKey>();
48
57
  /** When each agent or group (by `group:<name>`) last started or ended a turn. */
49
58
  readonly #lastActive = new Map<string, Date>();
50
59
 
@@ -97,7 +106,7 @@ export class TeamTurns {
97
106
  attachments: TurnAttachments,
98
107
  ): Promise<TurnResult> {
99
108
  const agent = this.#agentOf(channel);
100
- const scope = homeScope(agent);
109
+ const scope = this.#scopeOf(agent, channel);
101
110
  const pending = await this.#options.runtime().heldActions(scope.session);
102
111
  const confirmed =
103
112
  pending !== undefined &&
@@ -131,7 +140,7 @@ export class TeamTurns {
131
140
  const agent = this.#agentOf(channel);
132
141
  return this.#turn(
133
142
  agent,
134
- homeScope(agent),
143
+ this.#scopeOf(agent, channel),
135
144
  channel,
136
145
  text,
137
146
  { messages: 0, speaker },
@@ -164,12 +173,30 @@ export class TeamTurns {
164
173
  );
165
174
  }
166
175
 
176
+ /** The surfaces that host rooms; none for a team without a surface port. */
177
+ #rooms(channel: ChannelKey): readonly string[] {
178
+ // Only a room key needs the list, which a surface port gives once the hosts are linked.
179
+ return discordIdOf(channel) === undefined
180
+ ? (this.#options.surfaces?.roomSurfaces() ?? [])
181
+ : [];
182
+ }
183
+
184
+ /** The agent whose channel or room it is. */
167
185
  #agentOf(channel: ChannelKey): Agent {
168
- const agent = this.#options.store.agentByChannel(channelIdOf(channel));
169
- if (!agent) throw new AgentError(`no agent owns ${channel}`);
186
+ const { store, surfaces } = this.#options;
187
+ const agent = agentOfChannel(store, surfaces, channel);
188
+ if (agent?.status !== "active")
189
+ throw new AgentError(`no agent owns ${channel}`);
170
190
  return agent;
171
191
  }
172
192
 
193
+ /** The agent's own conversation in the channel: its Discord channel's, or its room's. */
194
+ #scopeOf(agent: Agent, channel: ChannelKey): AgentTurnScope {
195
+ return discordIdOf(channel) === undefined
196
+ ? roomScope(agent, channel)
197
+ : homeScope(agent);
198
+ }
199
+
173
200
  /**
174
201
  * Runs one agent turn and posts its reply, or a failure notice, in `postTo` under the
175
202
  * agent's name. Never rejects.
@@ -187,6 +214,7 @@ export class TeamTurns {
187
214
  // A group round has several sessions; only an agent's own channel maps to one.
188
215
  const hideStop = scope.group ? undefined : showStop(postTo);
189
216
  this.#chains.set(scope.session, chain);
217
+ this.#posting.set(scope.session, postTo);
190
218
  this.#working.set(agent.name, postTo);
191
219
  this.#active(agent.name, scope.group);
192
220
  const turn = {
@@ -198,11 +226,12 @@ export class TeamTurns {
198
226
  };
199
227
  this.#options.events?.turnStarted(turn);
200
228
  const interim = this.#interim(postTo, agent);
229
+ const progress = this.#progress(postTo);
201
230
  let result: TurnResult;
202
231
  try {
203
232
  result = await settleTurn(
204
233
  () =>
205
- withReplyFiles(true, () =>
234
+ withReplyFiles(this.#carriesFiles(postTo), () =>
206
235
  this.#options.runtime().runTurn({
207
236
  channel: postTo,
208
237
  selection: this.#options.selection(scope),
@@ -214,6 +243,7 @@ export class TeamTurns {
214
243
  agent: scope,
215
244
  speaker: chain.speaker,
216
245
  ...(interim ? { interim } : {}),
246
+ ...(progress ? { progress } : {}),
217
247
  }),
218
248
  ),
219
249
  "agent turn",
@@ -221,6 +251,7 @@ export class TeamTurns {
221
251
  } finally {
222
252
  hideStop?.();
223
253
  this.#chains.delete(scope.session);
254
+ this.#posting.delete(scope.session);
224
255
  this.#working.delete(agent.name);
225
256
  this.#active(agent.name, scope.group);
226
257
  }
@@ -235,15 +266,7 @@ export class TeamTurns {
235
266
  "agent turn failed",
236
267
  );
237
268
  }
238
- const thinking =
239
- result.ok && result.thinking
240
- ? thinkingLine(result.thinking)
241
- : undefined;
242
- await this.#post(postTo, this.#current(agent), {
243
- ...(thinking ? { thinking } : {}),
244
- chunks: result.ok ? splitReply(result.text) : [failureNotice(result)],
245
- ...(result.ok && result.files?.length ? { files: result.files } : {}),
246
- });
269
+ await this.#post(postTo, this.#current(agent), replyBody(result));
247
270
  } catch (error) {
248
271
  logger.error({ agent: agent.name, err: error }, "agent reply not posted");
249
272
  } finally {
@@ -252,6 +275,17 @@ export class TeamTurns {
252
275
  return result;
253
276
  }
254
277
 
278
+ /**
279
+ * Whether a reply in the channel may carry files: Discord's webhook posts do; a room only when
280
+ * its surface declares `supportsFiles`, since a reply with files that surface refuses is lost whole.
281
+ */
282
+ #carriesFiles(channel: ChannelKey): boolean {
283
+ return (
284
+ discordIdOf(channel) !== undefined ||
285
+ this.#options.surfaces?.of(channel)?.supportsFiles === true
286
+ );
287
+ }
288
+
255
289
  #active(agent: string, group: string | undefined): void {
256
290
  const now = new Date();
257
291
  this.#lastActive.set(agent, now);
@@ -264,13 +298,47 @@ export class TeamTurns {
264
298
  return this.#options.store.agent(agent.name) ?? agent;
265
299
  }
266
300
 
301
+ /**
302
+ * What a turn in a room shows as it goes, through the room's surface; none in a Discord
303
+ * channel, which shows only the final reply, or on a surface that shows no progress.
304
+ */
305
+ #progress(channel: ChannelKey): ((event: TurnProgress) => void) | undefined {
306
+ const { surfaces, logger } = this.#options;
307
+ if (discordIdOf(channel) !== undefined || !surfaces?.progress)
308
+ return undefined;
309
+ return (event) => {
310
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
311
+ surfaces
312
+ .progress?.(channel, event)
313
+ .catch((error: unknown) =>
314
+ logger.warn({ channel, err: error }, "progress not shown"),
315
+ );
316
+ };
317
+ }
318
+
267
319
  /**
268
320
  * The turn's interim posts under the agent's name, read as each goes out so a name changed
269
321
  * during the turn shows; none in a channel no agent or group owns any more.
270
322
  */
271
323
  #interim(channel: ChannelKey, agent: Agent): InterimPosts | undefined {
272
- const { store, channels, studio } = this.#options;
273
- if (!channels.interim) return undefined;
324
+ const { store, channels, studio, surfaces } = this.#options;
325
+ if (discordIdOf(channel) === undefined) {
326
+ // A room's interim posts are its surface's, under the agent as author.
327
+ if (!surfaces?.interim(channel, this.#author(agent))) return undefined;
328
+ return {
329
+ post: async (text) => {
330
+ if (!channelOwner(store, channel, this.#rooms(channel)))
331
+ throw new AgentError(`${channel} is archived`);
332
+ const posts = surfaces.interim(
333
+ channel,
334
+ this.#author(this.#current(agent)),
335
+ );
336
+ if (!posts) throw new AgentError(`${channel} shows no interim posts`);
337
+ return posts.post(text);
338
+ },
339
+ };
340
+ }
341
+ if (!channels?.interim) return undefined;
274
342
  const interim = channels.interim.bind(channels);
275
343
  return {
276
344
  post: async (text) => {
@@ -297,23 +365,50 @@ export class TeamTurns {
297
365
  chunks: string[];
298
366
  files?: ReplyFile[];
299
367
  threadId?: string;
368
+ failed?: true;
300
369
  },
301
370
  ): Promise<void> {
302
- const { store, channels, studio, logger } = this.#options;
303
- if (!channelOwner(store, channel)) {
371
+ const { store, channels, studio, surfaces, logger } = this.#options;
372
+ if (!channelOwner(store, channel, this.#rooms(channel))) {
304
373
  logger.info(
305
374
  { channel, agent: as.name },
306
375
  "post to an archived channel dropped",
307
376
  );
308
377
  return;
309
378
  }
379
+ // Discord ignores `failed`.
380
+ const { failed, ...rest } = body;
381
+ if (discordIdOf(channel) === undefined) {
382
+ if (!surfaces)
383
+ throw new AgentError(`no surface serves the room ${channel}`);
384
+ const { thinking, chunks, files, threadId } = rest;
385
+ // A thread of the room's surface is a channel of that surface, and speaks there.
386
+ return surfaces.sendReply(
387
+ threadId === undefined
388
+ ? channel
389
+ : channelKey(parseChannelKey(channel).surface, threadId),
390
+ {
391
+ ...(thinking ? { thinking } : {}),
392
+ chunks,
393
+ ...(files?.length ? { files } : {}),
394
+ author: this.#author(as),
395
+ ...(failed ? { failed } : {}),
396
+ },
397
+ );
398
+ }
399
+ if (!channels) throw new AgentError(`no Discord channels serve ${channel}`);
310
400
  return channels.post(channelIdOf(channel), {
311
401
  name: as.displayName,
312
402
  avatarUrl: studio.url(as.avatarHash),
313
- ...body,
403
+ ...rest,
314
404
  });
315
405
  }
316
406
 
407
+ /** Who speaks for the agent on a surface that shows authors. */
408
+ #author(agent: Agent): PostAuthor {
409
+ return { name: agent.displayName, agent: agent.name };
410
+ }
411
+
317
412
  /**
318
413
  * Posts text under the caller's name in the channel of its turn: the group's in a group
319
414
  * round, otherwise its own, as a change report is.
@@ -330,18 +425,38 @@ export class TeamTurns {
330
425
  const group = scope.group
331
426
  ? this.#options.store.group(scope.group)
332
427
  : undefined;
333
- return group ? discordKey(group.channelId) : scope.home;
428
+ if (!group) return scope.home;
429
+ // A seat of a room's group runs in that room; its running turn knows which.
430
+ return (
431
+ this.#posting.get(scope.session) ??
432
+ (group.channelId ? discordKey(group.channelId) : scope.home)
433
+ );
334
434
  }
335
435
 
336
- /** The assistant's own notice, such as a new release, posted in the coordinator's channel under its name. */
436
+ /**
437
+ * The assistant's own notice, such as a new release, posted in the coordinator's channel under
438
+ * its name: the entry channel, or, without Discord, its room on the first surface that hosts rooms.
439
+ */
337
440
  async announce(text: string): Promise<void> {
338
441
  const { store, entryChannelId } = this.#options;
339
- const coordinator = store.agentByChannel(entryChannelId);
442
+ const coordinator = coordinatorOf(store, this.#options);
340
443
  if (!coordinator)
341
- throw new AgentError("no active agent in the entry channel");
342
- await this.#post(discordKey(entryChannelId), coordinator, {
343
- chunks: splitReply(text),
344
- });
444
+ throw new AgentError(
445
+ entryChannelId === undefined
446
+ ? "no active coordinator"
447
+ : "no active agent in the entry channel",
448
+ );
449
+ const home =
450
+ entryChannelId !== undefined
451
+ ? discordKey(entryChannelId)
452
+ : keysOf(
453
+ store,
454
+ coordinator.name,
455
+ this.#options.surfaces?.roomSurfaces() ?? [],
456
+ )[0];
457
+ if (!home)
458
+ throw new AgentError(`${coordinator.name} has no room to post in`);
459
+ await this.#post(home, coordinator, { chunks: splitReply(text) });
345
460
  }
346
461
 
347
462
  message(caller: AgentTurnScope, to: string, text: string): string {
@@ -1,10 +1,13 @@
1
- import { mkdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { constants, mkdirSync } from "node:fs";
2
+ import { lstat, open, rm } from "node:fs/promises";
3
+ import { isAbsolute, join } from "node:path";
4
+ import type { AttachmentContent } from "../contract/channels.ts";
3
5
  import type {
4
6
  AttachmentFailure,
5
7
  AttachmentRef,
6
8
  StoredAttachment,
7
9
  } from "../domain/attachment.ts";
10
+ import { PluginError } from "../errors.ts";
8
11
  import { plainText } from "./attachment-name.ts";
9
12
 
10
13
  export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
@@ -23,6 +26,16 @@ export interface FetchRequest {
23
26
  signal?: AbortSignal;
24
27
  /** Gives up on a file that sends no byte for this long, and goes on to the next; default 10 s. */
25
28
  idleMs?: number;
29
+ /** The most bytes of one file; default `MAX_ATTACHMENT_BYTES`. Holds a ref, a file, a stream and a download alike. */
30
+ maxBytes?: number;
31
+ /**
32
+ * Reads a ref through the surface that made it. A function that answers a promise is the ref's
33
+ * only source: the ref is never fetched over the network, and a rejection fails that file.
34
+ * The answer is the bytes, the path of a file to copy or a stream to store; the last two are
35
+ * never held whole in memory. `undefined` = the surface has no reader, and the ref's URL is
36
+ * downloaded.
37
+ */
38
+ read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
26
39
  }
27
40
 
28
41
  export interface FetchResult {
@@ -127,6 +140,113 @@ function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
127
140
  });
128
141
  }
129
142
 
143
+ /** The refusal for a file over the limit, such as `larger than 25 MB (26214401 bytes)`. */
144
+ function tooLarge(limit: number, size: number): string {
145
+ return `larger than ${Number((limit / 1024 / 1024).toFixed(1))} MB (${size} bytes)`;
146
+ }
147
+
148
+ /**
149
+ * Writes a stream to `path` as it comes, never holding more than a chunk. Returns the bytes
150
+ * written, or the observed size once the stream passes `limit`, in which case nothing is left
151
+ * behind; a failure or an abort removes the partial file too.
152
+ */
153
+ async function storeStream(
154
+ stream: ReadableStream<Uint8Array>,
155
+ path: string,
156
+ limit: number,
157
+ watch: IdleWatch,
158
+ ): Promise<number | { tooLarge: number }> {
159
+ const { signal } = watch;
160
+ const reader = stream.getReader();
161
+ // A body that stalls ends with the signal, also when the fetch itself ignores it.
162
+ const stop = () => void reader.cancel(signal.reason).catch(() => {});
163
+ signal.addEventListener("abort", stop, { once: true });
164
+ let size = 0;
165
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
166
+ let kept = false;
167
+ try {
168
+ if (signal.aborted) throw signal.reason ?? new Error("aborted");
169
+ handle = await open(path, "w");
170
+ for (;;) {
171
+ const { done, value } = await reader.read();
172
+ if (done) break;
173
+ watch.touch();
174
+ size += value.byteLength;
175
+ if (size > limit) {
176
+ await reader.cancel();
177
+ return { tooLarge: size };
178
+ }
179
+ await handle.write(value);
180
+ }
181
+ // A cancelled reader reads as the end of the body, so a cut-short body is not a whole file.
182
+ if (signal.aborted) throw signal.reason ?? new Error("aborted");
183
+ await handle.close();
184
+ handle = undefined;
185
+ kept = true;
186
+ return size;
187
+ } finally {
188
+ signal.removeEventListener("abort", stop);
189
+ reader.releaseLock();
190
+ await handle?.close().catch(() => {});
191
+ if (!kept) await rm(path, { force: true });
192
+ }
193
+ }
194
+
195
+ /** Stores what a surface handed over at `path`; the size, or the observed size once it is over `limit`. */
196
+ async function storeContent(
197
+ content: AttachmentContent,
198
+ path: string,
199
+ limit: number,
200
+ watch: IdleWatch,
201
+ ): Promise<number | { tooLarge: number }> {
202
+ if (content instanceof Uint8Array) {
203
+ if (content.byteLength > limit) return { tooLarge: content.byteLength };
204
+ await Bun.write(path, content);
205
+ return content.byteLength;
206
+ }
207
+ if (content instanceof ReadableStream)
208
+ return storeStream(content, path, limit, watch);
209
+ return storeFile(content.path, path, limit, watch);
210
+ }
211
+
212
+ /** An error that carries a code, so the failure reports the code and never a message that may quote a path. */
213
+ function codedError(code: string, message: string): Error {
214
+ return Object.assign(new Error(message), { code });
215
+ }
216
+
217
+ /** Copies a regular file the surface owns: absolute, not a symlink, opened once and read from that handle. */
218
+ async function storeFile(
219
+ source: string,
220
+ path: string,
221
+ limit: number,
222
+ watch: IdleWatch,
223
+ ): Promise<number | { tooLarge: number }> {
224
+ if (!isAbsolute(source))
225
+ throw codedError("EINVAL", "attachment path is not absolute");
226
+ if ((await lstat(source)).isSymbolicLink())
227
+ throw codedError("EINVAL", "attachment path is a symlink");
228
+ // O_NOFOLLOW closes the gap between the check above and the open.
229
+ const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
230
+ try {
231
+ const info = await handle.stat();
232
+ if (!info.isFile())
233
+ throw codedError("EINVAL", "attachment path is not a regular file");
234
+ if (info.size > limit) return { tooLarge: info.size };
235
+ // The count in `storeStream` also holds a file that grows while it is copied.
236
+ const stream = new ReadableStream<Uint8Array>({
237
+ async pull(controller) {
238
+ const chunk = new Uint8Array(64 * 1024);
239
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
240
+ if (bytesRead === 0) controller.close();
241
+ else controller.enqueue(chunk.subarray(0, bytesRead));
242
+ },
243
+ });
244
+ return await storeStream(stream, path, limit, watch);
245
+ } finally {
246
+ await handle.close().catch(() => {});
247
+ }
248
+ }
249
+
130
250
  /** Downloads attachments one by one; a failed file is reported without stopping the others. */
131
251
  export async function fetchAttachments(
132
252
  request: FetchRequest,
@@ -136,6 +256,11 @@ export async function fetchAttachments(
136
256
  const failures: AttachmentFailure[] = [];
137
257
  const { refs, dir, prefix, fromReference, signal } = request;
138
258
  const idleMs = request.idleMs ?? DEFAULT_IDLE_MS;
259
+ const limit = request.maxBytes ?? MAX_ATTACHMENT_BYTES;
260
+ if (!Number.isSafeInteger(limit) || limit <= 0)
261
+ throw new PluginError(
262
+ `the attachment size limit (maxAttachmentBytes) must be a positive integer of bytes, not ${limit}`,
263
+ );
139
264
  if (refs.length === 0) return { files, failures };
140
265
 
141
266
  for (const [index, ref] of refs.entries()) {
@@ -145,40 +270,54 @@ export async function fetchAttachments(
145
270
  fail("download failed: deadline passed or download aborted");
146
271
  continue;
147
272
  }
148
- if (ref.size > MAX_ATTACHMENT_BYTES) {
149
- fail(`larger than 25 MB (${ref.size} bytes)`);
273
+ if (ref.size > limit) {
274
+ fail(tooLarge(limit, ref.size));
150
275
  continue;
151
276
  }
152
277
  const watch = new IdleWatch(idleMs, signal);
153
278
  try {
154
279
  // A directory that cannot be made fails the file, not the turn.
155
280
  mkdirSync(dir, { recursive: true, mode: 0o700 });
156
- const response = await untilAborted(
157
- fetchImpl(ref.url, { signal: watch.signal }),
158
- watch.signal,
159
- );
160
- if (!response.ok) {
161
- await response.body?.cancel();
162
- fail(`download failed with HTTP ${response.status}`);
163
- continue;
281
+ const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
282
+ const path = join(dir, file);
283
+ let contentType = ref.contentType || "application/octet-stream";
284
+ let stored: number | { tooLarge: number };
285
+ const read = request.read?.(ref);
286
+ if (read) {
287
+ stored = await storeContent(
288
+ await untilAborted(read, watch.signal),
289
+ path,
290
+ limit,
291
+ watch,
292
+ );
293
+ } else {
294
+ const response = await untilAborted(
295
+ fetchImpl(ref.url, { signal: watch.signal }),
296
+ watch.signal,
297
+ );
298
+ if (!response.ok) {
299
+ await response.body?.cancel();
300
+ fail(`download failed with HTTP ${response.status}`);
301
+ continue;
302
+ }
303
+ contentType =
304
+ ref.contentType ||
305
+ response.headers.get("content-type") ||
306
+ "application/octet-stream";
307
+ stored = response.body
308
+ ? await storeStream(response.body, path, limit, watch)
309
+ : await Bun.write(path, new Uint8Array());
164
310
  }
165
- const data = await readAttachmentBody(response, watch);
166
- if (typeof data === "number") {
167
- fail(`larger than 25 MB (${data} bytes)`);
311
+ if (typeof stored === "object") {
312
+ fail(tooLarge(limit, stored.tooLarge));
168
313
  continue;
169
314
  }
170
- const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
171
- const path = join(dir, file);
172
- await Bun.write(path, data);
173
315
  files.push({
174
316
  name: ref.name,
175
317
  file,
176
318
  path,
177
- contentType:
178
- ref.contentType ||
179
- response.headers.get("content-type") ||
180
- "application/octet-stream",
181
- size: data.byteLength,
319
+ contentType,
320
+ size: stored,
182
321
  fromReference,
183
322
  });
184
323
  } catch (error) {
@@ -191,44 +330,3 @@ export async function fetchAttachments(
191
330
  }
192
331
  return { files, failures };
193
332
  }
194
-
195
- /** Returns the bytes, or the observed size once the stream exceeds the limit. */
196
- async function readAttachmentBody(
197
- response: Response,
198
- watch: IdleWatch,
199
- ): Promise<Uint8Array | number> {
200
- if (!response.body) return new Uint8Array();
201
- const { signal } = watch;
202
- const reader = response.body.getReader();
203
- const chunks: Uint8Array[] = [];
204
- let size = 0;
205
- // A body that stalls ends with the signal, also when the fetch itself ignores it.
206
- const stop = () => void reader.cancel(signal.reason).catch(() => {});
207
- signal.addEventListener("abort", stop, { once: true });
208
- try {
209
- if (signal.aborted) throw signal.reason ?? new Error("aborted");
210
- for (;;) {
211
- const { done, value } = await reader.read();
212
- if (done) break;
213
- watch.touch();
214
- size += value.byteLength;
215
- if (size > MAX_ATTACHMENT_BYTES) {
216
- await reader.cancel();
217
- return size;
218
- }
219
- chunks.push(value);
220
- }
221
- // A cancelled reader reads as the end of the body, so a cut-short body is not a whole file.
222
- if (signal.aborted) throw signal.reason ?? new Error("aborted");
223
- } finally {
224
- signal.removeEventListener("abort", stop);
225
- reader.releaseLock();
226
- }
227
- const data = new Uint8Array(size);
228
- let offset = 0;
229
- for (const chunk of chunks) {
230
- data.set(chunk, offset);
231
- offset += chunk.byteLength;
232
- }
233
- return data;
234
- }
@@ -1,4 +1,5 @@
1
1
  import type { TurnAttachments } from "../domain/attachment.ts";
2
+ import { MAX_ATTACHMENT_BYTES } from "./attachment-fetcher.ts";
2
3
  import { plainContentType, plainText } from "./attachment-name.ts";
3
4
 
4
5
  /** A name a prompt can quote: no control or bidi characters, and no quote to close it early. */
@@ -22,8 +23,13 @@ export function withAttachmentsBlock(
22
23
  const lines: string[] = [];
23
24
  for (const file of attachments.files) {
24
25
  const origin = file.fromReference ? ", from the replied-to message" : "";
26
+ // read_attachment reads no file over the size every surface takes; tools that take files can.
27
+ const large =
28
+ file.size > MAX_ATTACHMENT_BYTES
29
+ ? ", too large for read_attachment; kept as a file for tools"
30
+ : "";
25
31
  lines.push(
26
- `- ${plainText(file.file)} (original name "${quotable(file.name)}", ${plainContentType(file.contentType)}, ${formatSize(file.size)}${origin})`,
32
+ `- ${plainText(file.file)} (original name "${quotable(file.name)}", ${plainContentType(file.contentType)}, ${formatSize(file.size)}${origin}${large})`,
27
33
  );
28
34
  }
29
35
  for (const failure of attachments.failures) {
@@ -12,8 +12,10 @@ export interface ToolAttachment {
12
12
  name: string;
13
13
  contentType: string;
14
14
  size: number;
15
- /** The file's whole content. */
15
+ /** The file's whole content, in memory; for a large file prefer `stream()`. */
16
16
  bytes(): Promise<Uint8Array>;
17
+ /** The file's content as a stream, for a file too large to hold in memory, such as a video. */
18
+ stream(): ReadableStream<Uint8Array>;
17
19
  }
18
20
 
19
21
  /** A file someone attached to the conversation, as a tool lists it. */
@@ -83,6 +85,23 @@ export async function openAttachment(
83
85
  throw new AttachmentLookupError(`"${shown}" could not be read`);
84
86
  }
85
87
  },
88
+ stream: () => {
89
+ const source = handle.stream().getReader();
90
+ return new ReadableStream<Uint8Array>({
91
+ async pull(controller) {
92
+ try {
93
+ const next = await source.read();
94
+ if (next.done) controller.close();
95
+ else controller.enqueue(next.value);
96
+ } catch {
97
+ controller.error(
98
+ new AttachmentLookupError(`"${shown}" could not be read`),
99
+ );
100
+ }
101
+ },
102
+ cancel: (reason) => source.cancel(reason),
103
+ });
104
+ },
86
105
  };
87
106
  }
88
107
 
@@ -1,4 +1,5 @@
1
- import type { TurnAttachments } from "../domain/attachment.ts";
1
+ import type { AttachmentContent } from "../contract/channels.ts";
2
+ import type { AttachmentRef, TurnAttachments } from "../domain/attachment.ts";
2
3
  import type { InboundMessage } from "../domain/conversation.ts";
3
4
  import type { Logger } from "../log.ts";
4
5
  import { fetchAttachments } from "./attachment-fetcher.ts";
@@ -29,6 +30,15 @@ export async function collectAttachments(
29
30
  * the files on to something that prepares them itself.
30
31
  */
31
32
  prepareImages?: boolean;
33
+ /** The most bytes of one file; default 25 MiB (`MAX_ATTACHMENT_BYTES`). */
34
+ maxBytes?: number;
35
+ /**
36
+ * Reads a ref through the surface that made it. A function that answers a promise is the
37
+ * ref's only source: it is never fetched, and a rejection fails that file. The answer is the
38
+ * bytes, `{ path }` of a file or a stream, the last two stored without holding them whole.
39
+ * `undefined` = the surface has no reader, and the ref's URL is downloaded.
40
+ */
41
+ read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
32
42
  },
33
43
  ): Promise<TurnAttachments> {
34
44
  const { fetchImpl, logger } = options;
@@ -38,7 +48,11 @@ export async function collectAttachments(
38
48
  const signal = options.signal
39
49
  ? AbortSignal.any([deadline, options.signal])
40
50
  : deadline;
41
- const idle = options.idleMs === undefined ? {} : { idleMs: options.idleMs };
51
+ const idle = {
52
+ ...(options.idleMs === undefined ? {} : { idleMs: options.idleMs }),
53
+ ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
54
+ ...(options.read ? { read: options.read } : {}),
55
+ };
42
56
  const own = await fetchAttachments(
43
57
  {
44
58
  refs: message.attachments,
@@ -97,6 +111,8 @@ export function attachmentsOf(
97
111
  idleMs?: number;
98
112
  signal?: AbortSignal;
99
113
  prepareImages?: boolean;
114
+ maxBytes?: number;
115
+ read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
100
116
  } = {},
101
117
  ): Promise<TurnAttachments> {
102
118
  return collectAttachments(message, dir, {