pi-roundtable 0.7.0 → 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 +38 -0
- package/docs/plugins.md +56 -3
- package/package.json +10 -3
- package/src/core/attachments/image-budget.ts +158 -0
- package/src/core/attachments/image-prep.ts +35 -6
- package/src/core/attachments/image-preparation-error.ts +14 -0
- package/src/index.ts +1 -0
- package/src/kit/channels.ts +2 -0
- package/src/kit/index.ts +2 -0
- package/templates/biome.json.tmpl +1 -1
- package/templates/package.json.tmpl +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ 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
|
+
|
|
22
|
+
## [0.7.2] - 2026-10-02
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- The release preflight reads npm 12's `npm pack --json` report (an object keyed by package name) and `npm view --json` name (a one-element array) as well as npm 11's, so the `v0.7.1` tag stopped before publishing anything.
|
|
27
|
+
|
|
28
|
+
## [0.7.1] - 2026-10-02
|
|
29
|
+
|
|
30
|
+
Tagged but not published: the release preflight failed under npm 12 before any package was published. Its changes ship in 0.7.2.
|
|
31
|
+
|
|
32
|
+
### Docs
|
|
33
|
+
|
|
34
|
+
- Document five official packages on the site and link their guides from `docs/plugins.md`, including installation, configuration, platform requirements, and security models.
|
|
35
|
+
Add a Traditional Chinese `release-notice` guide and reply-attachment guide.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Official drawing, coding, web, sandbox, and MCP plugins now live under `packages/` as Bun workspaces with their original Git history.
|
|
40
|
+
They remain separate npm packages, checked alongside the core in CI and released in lockstep from one `v*` tag through `publish.yml`.
|
|
41
|
+
The core's npm file list and release tags are unchanged.
|
|
42
|
+
- `pi-roundtable-mcp` moves from its standalone repository to `packages/mcp` and jumps from 0.4.1 to lockstep 0.7.0, retaining its public API.
|
|
43
|
+
Both MCP and sandbox now peer on `>=0.7.0 <0.8.0` and test against the live core 0.7.0.
|
|
44
|
+
Shared CI runs MCP's PostgreSQL tests and explicitly opts into sandbox's native Linux Docker integration.
|
|
45
|
+
|
|
8
46
|
## [0.7.0] - 2026-10-02
|
|
9
47
|
|
|
10
48
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -52,9 +52,14 @@ Import fixtures and fake threads from `pi-roundtable/testing` for tests.
|
|
|
52
52
|
|
|
53
53
|
#### Helpers for a Pi session of your own
|
|
54
54
|
|
|
55
|
-
Use the kit's building blocks for a plugin that runs Pi itself, such as a coding worker
|
|
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.
|
|
@@ -63,6 +68,9 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
63
68
|
- Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
|
|
64
69
|
- Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
|
|
65
70
|
|
|
71
|
+
The [coding package source][coding-source] shows these helpers in an out-of-process Pi worker.
|
|
72
|
+
The [MCP package source][mcp-source] is a full example of connectors and remote MCP endpoints.
|
|
73
|
+
|
|
66
74
|
`roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
|
|
67
75
|
The name is lowercase words joined by dashes, such as `my-notes`.
|
|
68
76
|
Three names are reserved for the [official plugins](#official-plugins): `codex-images`, `dice`, and `release-notice` copy a ready-made plugin into the project.
|
|
@@ -385,6 +393,7 @@ The core copies the bytes when the call succeeds, so the tool may reuse its buff
|
|
|
385
393
|
The tool does not send a message itself; the successful turn returns `TurnResult.files` and the reply path hands them to the surface as `OutboundReply.files`.
|
|
386
394
|
On Discord, files follow the text as one file per message to avoid image grids, all through the same agent webhook name and avatar as the text.
|
|
387
395
|
Other surfaces may send the text and files in one message or a message group.
|
|
396
|
+
The [drawing package source][drawing-source] uses `turn.attachFile` to deliver images produced by its tools.
|
|
388
397
|
|
|
389
398
|
<!-- example: examples/reply-files.ts -->
|
|
390
399
|
```ts
|
|
@@ -425,6 +434,9 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
|
|
|
425
434
|
Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
|
|
426
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.
|
|
427
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.
|
|
428
440
|
|
|
429
441
|
| Case | Behavior |
|
|
430
442
|
|---|---|
|
|
@@ -448,6 +460,7 @@ Test an attachment tool with an injected file-capable surface and inspect `harne
|
|
|
448
460
|
A held call is described to the owner, who approves or refuses it in Discord before the call runs.
|
|
449
461
|
A tool's `hold` returns the description for its own calls, and `holdRules` are rules over every tool call, asked in order until one describes the call.
|
|
450
462
|
Each rule needs a name, unique across plugins.
|
|
463
|
+
The [coding package source][coding-source] uses an owner hold for `repo_push`.
|
|
451
464
|
|
|
452
465
|
<!-- example: examples/holds.ts -->
|
|
453
466
|
```ts
|
|
@@ -867,6 +880,7 @@ When no `images` provider is configured, `agent_create` has no `avatar_prompt` p
|
|
|
867
880
|
The owner's profile panel reports the missing provider and offers no redraw option.
|
|
868
881
|
Each agent gets a picture generated from its display name and the assistant's icon, so you can tell agents apart.
|
|
869
882
|
`bunx roundtable doctor` reports whether the slot is filled.
|
|
883
|
+
The copy-in [`codex-images` provider template][codex-images-template] is a complete implementation of the `images` slot.
|
|
870
884
|
|
|
871
885
|
<!-- example: examples/providers.ts -->
|
|
872
886
|
```ts
|
|
@@ -1259,6 +1273,7 @@ The configuration's `http` block opens a listener named `public`, which serves t
|
|
|
1259
1273
|
A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
|
|
1260
1274
|
Two routes that could take the same request are refused, so a route cannot shadow the avatars.
|
|
1261
1275
|
This listener is reachable from the internet, so check a secret in the handler before taking action.
|
|
1276
|
+
The [web package source][web-source] is a complete owner-console implementation built on these HTTP routes.
|
|
1262
1277
|
A handler that throws, or returns a rejected promise, answers `500 Internal Server Error` with that fixed body, and the listener keeps serving.
|
|
1263
1278
|
The host logs one error line with the route's `name` and its `listener`; it never logs the request URL, since a path may hold a secret.
|
|
1264
1279
|
|
|
@@ -1455,6 +1470,8 @@ Give `stop` to a claim whose conversations run turns that can be interrupted.
|
|
|
1455
1470
|
|
|
1456
1471
|
For a claim with its own conversations, return the conversation's kind from `startFresh`.
|
|
1457
1472
|
Run turns with [`context.turns`](#personas-and-contextturns-conversations-of-a-kind-of-your-own), which handles typing, the stop control, events, and replies.
|
|
1473
|
+
The [sandbox package source][sandbox-source] is a complete guest-only channel claim backed by a no-network Docker agent and a host-side broker.
|
|
1474
|
+
Its container runs a minimal Chat Completions loop rather than a Pi session.
|
|
1458
1475
|
|
|
1459
1476
|
<!-- example: examples/channels.ts -->
|
|
1460
1477
|
```ts
|
|
@@ -1881,10 +1898,43 @@ The package ships three plugins you can copy into a project and change.
|
|
|
1881
1898
|
`roundtable add plugin codex-images`, `roundtable add plugin dice`, and `roundtable add plugin release-notice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`.
|
|
1882
1899
|
You can edit the copied files; `add plugin` refuses to overwrite existing ones.
|
|
1883
1900
|
The three names are reserved for these copies.
|
|
1901
|
+
Inspect the ready-made implementations in the [codex-images][codex-images-template], [dice][dice-template], and [release-notice][release-notice-template] templates.
|
|
1884
1902
|
|
|
1885
|
-
|
|
1903
|
+
The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, adds two more plugins, `mcpConnectors` and `remoteMcp`.
|
|
1886
1904
|
The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
|
|
1887
|
-
Install it with `bun add pi-roundtable-mcp
|
|
1905
|
+
Install it with `bun add pi-roundtable-mcp` after the next lockstep release publishes the migrated package.
|
|
1906
|
+
Until then, npm's MCP 0.4.1 requires core below 0.6.0 and is not compatible with core 0.7.x.
|
|
1907
|
+
|
|
1908
|
+
Five official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
|
|
1909
|
+
The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
|
|
1910
|
+
|
|
1911
|
+
- [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
|
|
1912
|
+
- [pi-roundtable-coding][coding-package]: [site guide source][coding-guide] · repository shelves and owner-approved Pi coding workers.
|
|
1913
|
+
- [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
|
|
1914
|
+
- [pi-roundtable-sandbox][sandbox-package]: [site guide source][sandbox-guide] · sealed guest channels on native Linux Docker with a host-only credential broker and allow-listed tools.
|
|
1915
|
+
- [pi-roundtable-mcp][mcp-package]: [site guide source][mcp-guide] · MCP connectors through a gateway and remote MCP endpoints for agent turns and owner-granted Discord channel tools.
|
|
1916
|
+
|
|
1917
|
+
[drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
|
|
1918
|
+
[coding-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/coding/README.md
|
|
1919
|
+
[web-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/web/README.md
|
|
1920
|
+
[sandbox-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/sandbox/README.md
|
|
1921
|
+
[mcp-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/mcp/README.md
|
|
1922
|
+
[package-guides]: https://github.com/wayne930242/pi-roundtable/tree/master/site/src/content/docs/plugins/
|
|
1923
|
+
[drawing-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/drawing-package.mdx
|
|
1924
|
+
[coding-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/coding-package.mdx
|
|
1925
|
+
[web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
|
|
1926
|
+
[sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
|
|
1927
|
+
[mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
|
|
1928
|
+
[coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
|
|
1929
|
+
[sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
|
|
1930
|
+
[web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
|
|
1931
|
+
[drawing-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/drawing
|
|
1932
|
+
[mcp-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/mcp
|
|
1933
|
+
[codex-images-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/codex-images/plugin.ts
|
|
1934
|
+
[dice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/dice/plugin.ts
|
|
1935
|
+
[release-notice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/release-notice/plugin.ts
|
|
1936
|
+
|
|
1937
|
+
Install only the packages your host uses; the core does not depend on these workspaces.
|
|
1888
1938
|
|
|
1889
1939
|
| Plugin | What it does | What it needs |
|
|
1890
1940
|
|---|---|---|
|
|
@@ -2437,6 +2487,9 @@ Import from the entries listed below; source area files are internal.
|
|
|
2437
2487
|
| `TurnSelection` | `pi-roundtable` | type |
|
|
2438
2488
|
| `Weekday` | `pi-roundtable` | type |
|
|
2439
2489
|
| `attachReplyFile` | `pi-roundtable` | value |
|
|
2490
|
+
| `withReplyFiles` | `pi-roundtable` | value |
|
|
2491
|
+
| `prepareImageBytes` | `pi-roundtable/kit` | value |
|
|
2492
|
+
| `ImagePreparationError` | `pi-roundtable/kit` | value |
|
|
2440
2493
|
| `channelKey` | `pi-roundtable` | value |
|
|
2441
2494
|
| `definePlugin` | `pi-roundtable` | value |
|
|
2442
2495
|
| `defineRoundtable` | `pi-roundtable` | value |
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "A plugin-driven Pi agent server for Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"workspaces": [
|
|
8
|
+
"packages/*"
|
|
9
|
+
],
|
|
10
|
+
"overrides": {
|
|
11
|
+
"pi-roundtable": "link:."
|
|
12
|
+
},
|
|
7
13
|
"engines": {
|
|
8
14
|
"bun": ">=1.3.0"
|
|
9
15
|
},
|
|
@@ -45,7 +51,8 @@
|
|
|
45
51
|
"scripts": {
|
|
46
52
|
"typecheck": "tsc --noEmit",
|
|
47
53
|
"lint": "biome check .",
|
|
48
|
-
"test": "bun test"
|
|
54
|
+
"test": "bun test ./src ./scripts ./examples",
|
|
55
|
+
"check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces test"
|
|
49
56
|
},
|
|
50
57
|
"dependencies": {
|
|
51
58
|
"@babel/parser": "7.29.9",
|
|
@@ -63,7 +70,7 @@
|
|
|
63
70
|
},
|
|
64
71
|
"devDependencies": {
|
|
65
72
|
"typescript": "7.0.2",
|
|
66
|
-
"@biomejs/biome": "2.5.
|
|
73
|
+
"@biomejs/biome": "2.5.15",
|
|
67
74
|
"@types/bun": "1.4.2",
|
|
68
75
|
"pi-web-access": "0.35.0"
|
|
69
76
|
}
|
|
@@ -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
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
package/src/kit/channels.ts
CHANGED
|
@@ -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://biomejs.dev/schemas/2.5.
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
|
3
3
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
|
4
4
|
"formatter": { "indentStyle": "tab" },
|
|
5
5
|
"linter": { "rules": { "preset": "recommended" } },
|