pi-roundtable 0.7.2 → 0.7.3

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 CHANGED
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.3] - 2026-10-03
9
+
10
+ ### Fixed
11
+
12
+ - The sandbox's rich broker streams model responses instead of buffering them and no longer cuts calls at 60 or 120 seconds; the turn deadline is the bound. The worker can no longer raise effort or thinking budget above the host-judged level. `startFresh` and start replace guest-planted symlinks instead of following them, and only the two reserved blocks of the 192.0 range are refused, not all of it.
13
+
14
+ ### Added
15
+
16
+ - Sandbox `assertPublicUrl`, `safeFetch` options `followRedirects` and `headers`, and a research-worker `fetchContent` hook, so a host can keep a library's own extractors while its fetches of a model-supplied URL stay pinned and vetted per hop. Broker upstream errors keep their status, body and back-off headers.
17
+ - Coding accepts `pushHoldText` for trusted wording of the held `repo_push` approval card.
18
+ - Export `withReplyFiles` for standalone workers to collect reply attachments with the same turn-wide limits, success/failure semantics, and asynchronous scope as host conversation turns.
19
+ - Export `prepareImageBytes` and `ImagePreparationError` from the kit for raster bytes without reopening guest-writable paths; reject encoded images over 25 MiB or headers/aggregate frames over 64 MP before native decode, while preserving ordinary 48 MP images and 9000×1 downscaling. File-based preparation is byte-bounded too and decodes once instead of twice.
20
+ - Drawing accepts `CardPresentation` through `DrawingOptions.cardPresentation` for trusted operator headings and reversal suffixes without changing tool arguments or default captions.
21
+
8
22
  ## [0.7.2] - 2026-10-02
9
23
 
10
24
  ### Fixed
package/docs/plugins.md CHANGED
@@ -55,6 +55,11 @@ Import fixtures and fake threads from `pi-roundtable/testing` for tests.
55
55
  Use the kit's building blocks for a plugin that runs Pi itself, such as a coding worker:
56
56
 
57
57
  - MCP: `mcpExtension` and `VirtualServer` expose MCP servers to a session, and `mcpAdapterExtension` and `readAttachmentExtension` do the same inside an out-of-process worker.
58
+ - Admitted media: `prepareImageBytes(data, contentType)` prepares raster bytes without reopening a guest-writable path; supported small images remain unchanged, and larger sides/encoded images are downsized to JPEG at a 2,000 px long side.
59
+ Before any native decoder, it refuses more than 25 MiB of encoded bytes or 64 million decoded pixels, including aggregate GIF/WebP frame dimensions.
60
+ `ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
61
+ Ordinary 48 MP images and thin 9000×1 images remain admitted.
62
+ This helper is not a downloader or filesystem validator.
58
63
  - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
59
64
  - Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
60
65
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
@@ -429,6 +434,9 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
429
434
  Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
430
435
  Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
431
436
  Await work that produces files before returning from the tool.
437
+ A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
438
+ `run` returns `Promise<TurnResult>`; the collector validates any returned files, includes accepted attachments only on success, and deactivates on success, failure, or throw.
439
+ The caller must deliver the returned `files` and must not open a fresh collector per tool to evade turn-wide limits.
432
440
 
433
441
  | Case | Behavior |
434
442
  |---|---|
@@ -2479,6 +2487,9 @@ Import from the entries listed below; source area files are internal.
2479
2487
  | `TurnSelection` | `pi-roundtable` | type |
2480
2488
  | `Weekday` | `pi-roundtable` | type |
2481
2489
  | `attachReplyFile` | `pi-roundtable` | value |
2490
+ | `withReplyFiles` | `pi-roundtable` | value |
2491
+ | `prepareImageBytes` | `pi-roundtable/kit` | value |
2492
+ | `ImagePreparationError` | `pi-roundtable/kit` | value |
2482
2493
  | `channelKey` | `pi-roundtable` | value |
2483
2494
  | `definePlugin` | `pi-roundtable` | value |
2484
2495
  | `defineRoundtable` | `pi-roundtable` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,158 @@
1
+ import { ImagePreparationError } from "./image-preparation-error.ts";
2
+
3
+ const invalid = () => new ImagePreparationError("invalid-image");
4
+
5
+ /** Inspect only encoded headers/blocks. Never allocate a raster or follow a media URL. */
6
+ export function assertImagePixelBudget(bytes: Buffer, budget: number): void {
7
+ const pixels = (width: number, height: number): number => {
8
+ if (width < 1 || height < 1) throw invalid();
9
+ const area = width * height;
10
+ check(area);
11
+ return area;
12
+ };
13
+ const check = (area: number) => {
14
+ if (area > budget) throw new ImagePreparationError("pixel-limit");
15
+ };
16
+ const need = (end: number, limit = bytes.length) => {
17
+ if (end > limit) throw invalid();
18
+ };
19
+ const u24 = (at: number) => bytes.readUIntLE(at, 3);
20
+ const blockEnd = (start: number): number => {
21
+ let at = start;
22
+ for (;;) {
23
+ need(at + 1);
24
+ const length = bytes[at++] ?? 0;
25
+ if (!length) return at;
26
+ at += length;
27
+ need(at);
28
+ }
29
+ };
30
+
31
+ if (
32
+ bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
33
+ ) {
34
+ need(24);
35
+ if (
36
+ bytes.readUInt32BE(8) !== 13 ||
37
+ bytes.toString("ascii", 12, 16) !== "IHDR"
38
+ )
39
+ throw invalid();
40
+ pixels(bytes.readUInt32BE(16), bytes.readUInt32BE(20));
41
+ return;
42
+ }
43
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
44
+ let at = 2;
45
+ while (at < bytes.length) {
46
+ if (bytes[at++] !== 0xff) throw invalid();
47
+ while (bytes[at] === 0xff) at++;
48
+ need(at + 1);
49
+ const marker = bytes[at++] ?? 0;
50
+ if (marker === 0xda || marker === 0xd9) break;
51
+ if (marker === 1 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7))
52
+ continue;
53
+ need(at + 2);
54
+ const length = bytes.readUInt16BE(at);
55
+ if (length < 2) throw invalid();
56
+ need(at + length);
57
+ if (
58
+ marker >= 0xc0 &&
59
+ marker <= 0xcf &&
60
+ ![0xc4, 0xc8, 0xcc].includes(marker)
61
+ ) {
62
+ if (length < 8) throw invalid();
63
+ pixels(bytes.readUInt16BE(at + 5), bytes.readUInt16BE(at + 3));
64
+ return;
65
+ }
66
+ at += length;
67
+ }
68
+ throw invalid();
69
+ }
70
+ if (["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) {
71
+ need(13);
72
+ pixels(bytes.readUInt16LE(6), bytes.readUInt16LE(8));
73
+ let at =
74
+ 13 + ((bytes[10] ?? 0) & 0x80 ? 3 * (2 << ((bytes[10] ?? 0) & 7)) : 0);
75
+ let total = 0;
76
+ need(at);
77
+ while (at < bytes.length) {
78
+ const marker = bytes[at++];
79
+ if (marker === 0x3b) {
80
+ if (!total) throw invalid();
81
+ return;
82
+ }
83
+ if (marker === 0x21) {
84
+ need(at + 1);
85
+ at = blockEnd(at + 1);
86
+ continue;
87
+ }
88
+ if (marker !== 0x2c) throw invalid();
89
+ need(at + 9);
90
+ total += pixels(bytes.readUInt16LE(at + 4), bytes.readUInt16LE(at + 6));
91
+ check(total);
92
+ const flags = bytes[at + 8] ?? 0;
93
+ at += 9 + (flags & 0x80 ? 3 * (2 << (flags & 7)) : 0);
94
+ need(at + 1);
95
+ at = blockEnd(at + 1);
96
+ }
97
+ throw invalid();
98
+ }
99
+ if (
100
+ bytes.toString("ascii", 0, 4) === "RIFF" &&
101
+ bytes.toString("ascii", 8, 12) === "WEBP"
102
+ ) {
103
+ need(12);
104
+ const end = bytes.readUInt32LE(4) + 8;
105
+ need(end);
106
+ if (end < 12) throw invalid();
107
+ const chunks = (start: number, limit: number, frames: boolean): number => {
108
+ let at = start,
109
+ canvas = 0,
110
+ total = 0;
111
+ while (at < limit) {
112
+ need(at + 8, limit);
113
+ const type = bytes.toString("ascii", at, at + 4);
114
+ const size = bytes.readUInt32LE(at + 4);
115
+ const data = at + 8,
116
+ next = data + size;
117
+ need(next, limit);
118
+ let area = 0;
119
+ if (type === "VP8X") {
120
+ need(data + 10, next);
121
+ canvas = pixels(u24(data + 4) + 1, u24(data + 7) + 1);
122
+ } else if (type === "VP8 ") {
123
+ need(data + 10, next);
124
+ if (
125
+ !bytes
126
+ .subarray(data + 3, data + 6)
127
+ .equals(Buffer.from([0x9d, 0x01, 0x2a]))
128
+ )
129
+ throw invalid();
130
+ area = pixels(
131
+ bytes.readUInt16LE(data + 6) & 0x3fff,
132
+ bytes.readUInt16LE(data + 8) & 0x3fff,
133
+ );
134
+ } else if (type === "VP8L") {
135
+ need(data + 5, next);
136
+ if (bytes[data] !== 0x2f) throw invalid();
137
+ const bits = bytes.readUInt32LE(data + 1);
138
+ area = pixels((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
139
+ } else if (type === "ANMF") {
140
+ if (!frames) throw invalid();
141
+ need(data + 16, next);
142
+ const declared = pixels(u24(data + 6) + 1, u24(data + 9) + 1);
143
+ area = Math.max(declared, chunks(data + 16, next, false));
144
+ }
145
+ total += area;
146
+ check(Math.max(total, canvas));
147
+ at = next + (size & 1);
148
+ need(at, limit);
149
+ }
150
+ const result = Math.max(total, canvas);
151
+ if (!result) throw invalid();
152
+ return result;
153
+ };
154
+ chunks(12, end, true);
155
+ return;
156
+ }
157
+ throw invalid();
158
+ }
@@ -1,5 +1,8 @@
1
1
  import { createCanvas, loadImage } from "canvas";
2
2
  import type { ModelImage, StoredAttachment } from "../domain/attachment.ts";
3
+ import { MAX_ATTACHMENT_BYTES } from "./attachment-fetcher.ts";
4
+ import { assertImagePixelBudget } from "./image-budget.ts";
5
+ import { ImagePreparationError } from "./image-preparation-error.ts";
3
6
 
4
7
  /** Images the model accepts as images; other files stay files. */
5
8
  const IMAGE_TYPES = new Set([
@@ -29,15 +32,41 @@ export function isModelImage(
29
32
  export async function prepareImage(
30
33
  file: Pick<StoredAttachment, "path" | "contentType">,
31
34
  ): Promise<ModelImage> {
32
- const bytes = Buffer.from(await Bun.file(file.path).arrayBuffer());
33
- const mimeType = file.contentType.split(";")[0]?.trim().toLowerCase() ?? "";
34
- if (bytes.byteLength <= MAX_RAW_BYTES) {
35
- const image = await loadImage(bytes);
36
- if (Math.max(image.width, image.height) <= MAX_SIDE) {
37
- return { data: bytes.toString("base64"), mimeType };
35
+ const reader = Bun.file(file.path).stream().getReader();
36
+ const chunks: Uint8Array[] = [];
37
+ let size = 0;
38
+ try {
39
+ for (;;) {
40
+ const { value, done } = await reader.read();
41
+ if (done) break;
42
+ size += value.byteLength;
43
+ if (size > MAX_ATTACHMENT_BYTES)
44
+ throw new ImagePreparationError("byte-limit");
45
+ chunks.push(value);
38
46
  }
47
+ } finally {
48
+ await reader.cancel();
39
49
  }
50
+ return prepareImageBytes(Buffer.concat(chunks, size), file.contentType);
51
+ }
52
+
53
+ /** Prepare bounded raster bytes without reopening a guest-writable filesystem path.
54
+ * Headers and all GIF/WebP frames must fit 64 MP; encoded bytes must fit 25 MiB. */
55
+ export async function prepareImageBytes(
56
+ data: Uint8Array,
57
+ contentType: string,
58
+ ): Promise<ModelImage> {
59
+ if (data.byteLength > MAX_ATTACHMENT_BYTES)
60
+ throw new ImagePreparationError("byte-limit");
61
+ const bytes = Buffer.from(data);
62
+ assertImagePixelBudget(bytes, MAX_SIDE * MAX_SIDE);
63
+ const mimeType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
40
64
  const image = await loadImage(bytes);
65
+ if (
66
+ bytes.byteLength <= MAX_RAW_BYTES &&
67
+ Math.max(image.width, image.height) <= MAX_SIDE
68
+ )
69
+ return { data: bytes.toString("base64"), mimeType };
41
70
  const scale = DOWNSCALED_SIDE / Math.max(image.width, image.height);
42
71
  const width = Math.max(1, Math.round(image.width * Math.min(1, scale)));
43
72
  const height = Math.max(1, Math.round(image.height * Math.min(1, scale)));
@@ -0,0 +1,14 @@
1
+ /** Image admission failed before native decoding or allocating a bitmap. */
2
+ export class ImagePreparationError extends Error {
3
+ override readonly name = "ImagePreparationError";
4
+
5
+ constructor(readonly reason: "byte-limit" | "pixel-limit" | "invalid-image") {
6
+ super(
7
+ {
8
+ "byte-limit": "Image is larger than 25 MiB.",
9
+ "pixel-limit": "Image exceeds the 64 MP decoded-pixel budget.",
10
+ "invalid-image": "Image has an invalid or unsupported raster header.",
11
+ }[reason],
12
+ );
13
+ }
14
+ }
package/src/index.ts CHANGED
@@ -162,6 +162,7 @@ export {
162
162
  attachReplyFile,
163
163
  REPLY_FILE_LIMITS,
164
164
  ReplyFileError,
165
+ withReplyFiles,
165
166
  } from "./core/reply-files.ts";
166
167
  export type {
167
168
  ConversationTurnInput,
@@ -6,6 +6,8 @@ export {
6
6
  channelSegment,
7
7
  ownerAttachmentDir,
8
8
  } from "../core/attachments/attachment-dir.ts";
9
+ export { prepareImageBytes } from "../core/attachments/image-prep.ts";
10
+ export { ImagePreparationError } from "../core/attachments/image-preparation-error.ts";
9
11
  export { withAttachmentsBlock } from "../core/attachments/prompt-block.ts";
10
12
  export { attachmentsOf } from "../core/attachments/turn-attachments.ts";
11
13
  export type { ChannelQueue } from "../core/routing/channel-queue.ts";
package/src/kit/index.ts CHANGED
@@ -6,8 +6,10 @@ export {
6
6
  channelQueue,
7
7
  channelSegment,
8
8
  discordKey,
9
+ ImagePreparationError,
9
10
  outcome,
10
11
  ownerAttachmentDir,
12
+ prepareImageBytes,
11
13
  settleTurn,
12
14
  withAttachmentsBlock,
13
15
  withReference,