pi-roundtable 0.6.1 → 0.7.2

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,50 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.2] - 2026-10-02
9
+
10
+ ### Fixed
11
+
12
+ - 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.
13
+
14
+ ## [0.7.1] - 2026-10-02
15
+
16
+ Tagged but not published: the release preflight failed under npm 12 before any package was published. Its changes ship in 0.7.2.
17
+
18
+ ### Docs
19
+
20
+ - Document five official packages on the site and link their guides from `docs/plugins.md`, including installation, configuration, platform requirements, and security models.
21
+ Add a Traditional Chinese `release-notice` guide and reply-attachment guide.
22
+
23
+ ### Changed
24
+
25
+ - Official drawing, coding, web, sandbox, and MCP plugins now live under `packages/` as Bun workspaces with their original Git history.
26
+ They remain separate npm packages, checked alongside the core in CI and released in lockstep from one `v*` tag through `publish.yml`.
27
+ The core's npm file list and release tags are unchanged.
28
+ - `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.
29
+ Both MCP and sandbox now peer on `>=0.7.0 <0.8.0` and test against the live core 0.7.0.
30
+ Shared CI runs MCP's PostgreSQL tests and explicitly opts into sandbox's native Linux Docker integration.
31
+
32
+ ## [0.7.0] - 2026-10-02
33
+
34
+ ### Added
35
+
36
+ - Turn reply attachments: `ToolTurn.attachFile(file)` queues raw bytes with the agent's reply instead of posting as the bot ahead of it.
37
+ The main entry exports `ReplyFile` (`{ name, data: Uint8Array }`), `attachReplyFile` (`(file): void`) for raw session and Pi package tools, `ReplyFileError`, and frozen `REPLY_FILE_LIMITS` (10 files, 10 MiB each, 50 MiB total per turn).
38
+ Successful `TurnResult` may carry `files`; a successful textless Pi answer with files is posted, while stopped or failed turns discard them.
39
+ Discord sends files through the same agent webhook identity as the text.
40
+ Transient tasks and calls outside a conversation turn refuse attachments.
41
+ - `testPlugin` records files attached by `runTool` as `files: { channel, file }[]`; inject a file-capable surface for attachment tests.
42
+ The guide includes the tested `examples/reply-files.ts` plugin.
43
+ - `roundtable add plugin release-notice` copies a third official plugin. After the agent server is up it posts once in the coordinator's channel when the running release differs from the one last announced, with the commits the release added since the release last announced, read from a `release.json` that the deploy writes (`{ "sha": "...", "commits": ["subject", ...] }`); it also names the channels whose work the previous shutdown cut short, recorded from the `shutdown` event. It remembers the announced `sha` only after the post succeeds. `createReleaseNotice` takes `releaseFile`, `dataDir`, and `announce` to change where the release is read, where the state is kept, and where the post goes.
44
+
45
+ ### Changed
46
+
47
+ - Chat surfaces explicitly opt in with `ChatSurface.supportsFiles: true` and must deliver every reply file or reject.
48
+ Existing custom surfaces that handle files must add this flag; absent or false refuses attachment calls and direct `SurfacePort.sendReply` file sends instead of silently losing them.
49
+ Replacement-runtime result files pass the same capability and size checks; custom turn reply callbacks receive the files and own delivery.
50
+ - `release-notice` joins `codex-images` and `dice` as a reserved name for `add plugin`, and the usage text lists all three.
51
+
8
52
  ## [0.6.1] - 2026-10-02
9
53
 
10
54
  ### Fixed
package/docs/plugins.md CHANGED
@@ -52,7 +52,7 @@ 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 or a container with no network:
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
58
  - 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.
@@ -63,9 +63,12 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
63
63
  - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
64
64
  - 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
65
 
66
+ The [coding package source][coding-source] shows these helpers in an out-of-process Pi worker.
67
+ The [MCP package source][mcp-source] is a full example of connectors and remote MCP endpoints.
68
+
66
69
  `roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
67
70
  The name is lowercase words joined by dashes, such as `my-notes`.
68
- Two names are reserved for the [official plugins](#official-plugins): `codex-images` and `dice` copy a ready-made plugin into the project.
71
+ Three names are reserved for the [official plugins](#official-plugins): `codex-images`, `dice`, and `release-notice` copy a ready-made plugin into the project.
69
72
 
70
73
  `roundtable add package <spec>` adds a Pi package from npm: it runs `bun add <spec>`, loads the package's extensions to find the tools they register, and writes `plugins/<name>.ts` and its test, named after the package without its scope, as [`piPackages`](#pipackages-pi-extensions-every-session-loads) describes.
71
74
 
@@ -338,7 +341,7 @@ A key the contract does not have stops the start and names the closest one.
338
341
  ### `tools`: what agents can call
339
342
 
340
343
  `defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
341
- `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, and an abort signal.
344
+ `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, and `attachFile`.
342
345
  It returns the text the model reads.
343
346
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
344
347
 
@@ -377,11 +380,79 @@ export const notes = definePlugin({
377
380
 
378
381
  The tool names `bash`, `read`, `edit`, and `write` belong to the agents already.
379
382
 
383
+ #### Attach files to the agent's reply
384
+
385
+ Call `turn.attachFile(file)` from a tool's `run` to queue a file for the turn's reply, and still return the text the model reads.
386
+ A `ReplyFile` is `{ name: string; data: Uint8Array }`: raw bytes, including images, not a path or base64 string.
387
+ The core copies the bytes when the call succeeds, so the tool may reuse its buffer afterwards.
388
+ 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`.
389
+ 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.
390
+ Other surfaces may send the text and files in one message or a message group.
391
+ The [drawing package source][drawing-source] uses `turn.attachFile` to deliver images produced by its tools.
392
+
393
+ <!-- example: examples/reply-files.ts -->
394
+ ```ts
395
+ import { definePlugin, defineTool } from "pi-roundtable";
396
+ import { Type } from "typebox";
397
+
398
+ /** A small PNG keeps this example runnable without an image provider. */
399
+ export const imageReply = definePlugin({
400
+ name: "image-reply",
401
+ setup: () => ({
402
+ tools: [
403
+ defineTool({
404
+ name: "reply_image",
405
+ description: "Attach a sample image to your reply.",
406
+ parameters: Type.Object({}),
407
+ minTier: "member",
408
+ run: (_args, turn) => {
409
+ turn.attachFile({
410
+ name: "sample.png",
411
+ data: Buffer.from(
412
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
413
+ "base64",
414
+ ),
415
+ });
416
+ return "The sample image is attached to this turn's reply.";
417
+ },
418
+ }),
419
+ ],
420
+ }),
421
+ });
422
+ ```
423
+ <!-- /example -->
424
+
425
+ `attachReplyFile(file): void`, `ReplyFile`, `ReplyFileError`, and the frozen `REPLY_FILE_LIMITS` are exported from `pi-roundtable`.
426
+ Raw `sessionTools` and Pi package tools may import and call `attachReplyFile` during their awaited tool execution; it is the same function as `turn.attachFile`.
427
+ It uses the current asynchronous turn, not a global channel queue, so simultaneous turns and nested conversation turns keep separate files.
428
+ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundle another copy of it.
429
+ Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
430
+ 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
+ Await work that produces files before returning from the tool.
432
+
433
+ | Case | Behavior |
434
+ |---|---|
435
+ | Successful final answer with no text | Files are posted without a placeholder text; a final answer with neither text nor files still fails in the Pi runtime |
436
+ | Stopped, timed-out, or failed turn | Accepted files are discarded; only the usual stopped or failure notice is posted |
437
+ | A tool fails but the model recovers and completes the turn | Files already accepted remain queued for the successful reply; a tool failure does not roll back earlier attachment calls |
438
+ | Unsupported surface | `ChatSurface.supportsFiles` must be `true`; absent or false makes attachment calls throw `ReplyFileError`, which Pi reports to the model as a tool error |
439
+ | Too many or too large files | At most 10 files per turn, 10 MiB (10,485,760 bytes) per file, and 50 MiB (52,428,800 bytes) in total across all tools; the exceeding call throws `ReplyFileError` without queuing that file |
440
+ | Invalid file | Empty bytes, non-`Uint8Array` data, empty filenames, names over 255 characters, paths, `.`/`..`, or control characters throw `ReplyFileError` |
441
+ | A surface rejects delivery | The reply path logs `reply not posted` or `agent reply not posted`; delivery may be partial, is not retried, and does not change the completed runtime result |
442
+
443
+ A surface declaring `supportsFiles: true` must deliver every `OutboundReply.files` entry with the reply's speaker identity, or reject with an error; its transport may impose stricter limits.
444
+ `SurfacePort.sendReply` also refuses direct file sends to a surface without that declaration instead of silently dropping files.
445
+ A replacement runtime may return `files` in its successful `TurnResult`; the turn path applies the same limits and capability check before posting.
446
+ Use either the helper or the result for each file: returning an already attached file queues a second copy, counted against the same limits.
447
+ A custom `ConversationTurnInput.reply(result)` receives `result.files` and owns their delivery instead of the default surface reply.
448
+ Test an attachment tool with an injected file-capable surface and inspect `harness.files` (see [`reply-files.test.ts`](../examples/reply-files.test.ts)); `runTool` itself posts nothing.
449
+
380
450
  ### `holdRules`, and a tool's `hold`: calls that wait for the owner
381
451
 
382
452
  A held call is described to the owner, who approves or refuses it in Discord before the call runs.
383
453
  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.
384
454
  Each rule needs a name, unique across plugins.
455
+ The [coding package source][coding-source] uses an owner hold for `repo_push`.
385
456
 
386
457
  <!-- example: examples/holds.ts -->
387
458
  ```ts
@@ -801,6 +872,7 @@ When no `images` provider is configured, `agent_create` has no `avatar_prompt` p
801
872
  The owner's profile panel reports the missing provider and offers no redraw option.
802
873
  Each agent gets a picture generated from its display name and the assistant's icon, so you can tell agents apart.
803
874
  `bunx roundtable doctor` reports whether the slot is filled.
875
+ The copy-in [`codex-images` provider template][codex-images-template] is a complete implementation of the `images` slot.
804
876
 
805
877
  <!-- example: examples/providers.ts -->
806
878
  ```ts
@@ -1193,6 +1265,7 @@ The configuration's `http` block opens a listener named `public`, which serves t
1193
1265
  A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
1194
1266
  Two routes that could take the same request are refused, so a route cannot shadow the avatars.
1195
1267
  This listener is reachable from the internet, so check a secret in the handler before taking action.
1268
+ The [web package source][web-source] is a complete owner-console implementation built on these HTTP routes.
1196
1269
  A handler that throws, or returns a rejected promise, answers `500 Internal Server Error` with that fixed body, and the listener keeps serving.
1197
1270
  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.
1198
1271
 
@@ -1389,6 +1462,8 @@ Give `stop` to a claim whose conversations run turns that can be interrupted.
1389
1462
 
1390
1463
  For a claim with its own conversations, return the conversation's kind from `startFresh`.
1391
1464
  Run turns with [`context.turns`](#personas-and-contextturns-conversations-of-a-kind-of-your-own), which handles typing, the stop control, events, and replies.
1465
+ 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.
1466
+ Its container runs a minimal Chat Completions loop rather than a Pi session.
1392
1467
 
1393
1468
  <!-- example: examples/channels.ts -->
1394
1469
  ```ts
@@ -1811,27 +1886,90 @@ The following fields and parts from 0.1.0 were removed because only built-in plu
1811
1886
 
1812
1887
  ## Official plugins
1813
1888
 
1814
- The package ships two plugins you can copy into a project and change.
1815
- `roundtable add plugin codex-images` and `roundtable add plugin dice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`.
1889
+ The package ships three plugins you can copy into a project and change.
1890
+ `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`.
1816
1891
  You can edit the copied files; `add plugin` refuses to overwrite existing ones.
1817
- Both names are reserved for these copies.
1892
+ The three names are reserved for these copies.
1893
+ Inspect the ready-made implementations in the [codex-images][codex-images-template], [dice][dice-template], and [release-notice][release-notice-template] templates.
1818
1894
 
1819
- A separate package, [pi-roundtable-mcp](https://www.npmjs.com/package/pi-roundtable-mcp), adds two more plugins, `mcpConnectors` and `remoteMcp`.
1895
+ The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, adds two more plugins, `mcpConnectors` and `remoteMcp`.
1820
1896
  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.
1821
- Install it with `bun add pi-roundtable-mcp`.
1897
+ Install it with `bun add pi-roundtable-mcp` after the next lockstep release publishes the migrated package.
1898
+ Until then, npm's MCP 0.4.1 requires core below 0.6.0 and is not compatible with core 0.7.x.
1899
+
1900
+ Five official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
1901
+ The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
1902
+
1903
+ - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
1904
+ - [pi-roundtable-coding][coding-package]: [site guide source][coding-guide] · repository shelves and owner-approved Pi coding workers.
1905
+ - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
1906
+ - [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.
1907
+ - [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.
1908
+
1909
+ [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
1910
+ [coding-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/coding/README.md
1911
+ [web-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/web/README.md
1912
+ [sandbox-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/sandbox/README.md
1913
+ [mcp-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/mcp/README.md
1914
+ [package-guides]: https://github.com/wayne930242/pi-roundtable/tree/master/site/src/content/docs/plugins/
1915
+ [drawing-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/drawing-package.mdx
1916
+ [coding-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/coding-package.mdx
1917
+ [web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
1918
+ [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
1919
+ [mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
1920
+ [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
1921
+ [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
1922
+ [web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
1923
+ [drawing-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/drawing
1924
+ [mcp-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/mcp
1925
+ [codex-images-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/codex-images/plugin.ts
1926
+ [dice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/dice/plugin.ts
1927
+ [release-notice-template]: https://github.com/wayne930242/pi-roundtable/blob/master/templates/official/release-notice/plugin.ts
1928
+
1929
+ Install only the packages your host uses; the core does not depend on these workspaces.
1822
1930
 
1823
1931
  | Plugin | What it does | What it needs |
1824
1932
  |---|---|---|
1825
1933
  | `codex-images` | Fills the [`images` slot](#providers-replace-a-part-the-core-runs-on), so agents can draw avatars from a prompt and reference pictures | A login to the `openai-codex` provider; setup throws a `PluginError` that says so when the host has none |
1826
1934
  | `dice` | Adds the `roll_dice` tool for members: `2d6+3`, `4d6k3` (keep or drop the highest or lowest dice), several groups, and fate dice `dF`, answered as text such as `2d6+3: [3, 5] + 3 = 11` | Nothing; it takes at most 100 dice in all and 1000 sides per die |
1935
+ | `release-notice` | After a deploy, posts once in the coordinator's channel which commits the running release added since the release last announced, and says which channels the previous shutdown cut short | A `release.json` that your deploy writes (see below); without the file it posts nothing |
1827
1936
 
1828
1937
  `codex-images` takes the login through [`context.apiKey("openai-codex")`](#the-context).
1829
1938
  It sends requests to ChatGPT's Codex backend using the owner's ChatGPT subscription login.
1830
1939
  OpenAI doesn't document the backend for this use, so it can stop working without notice; the subscription's terms apply.
1831
1940
  The copied file begins with this warning.
1832
1941
 
1833
- Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source.
1834
- Both files export a `create...` function (`createCodexImages`, `createDice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
1942
+ `release-notice` listens to two [events](#events-hear-what-the-core-does).
1943
+ When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
1944
+ It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
1945
+ At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on after an hour, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
1946
+ A plain restart of the same release with nothing cut short posts nothing.
1947
+
1948
+ The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
1949
+ The file holds the commit the release was built from and the subjects of the commits it added over the release last announced, newest first, so that a release whose announcement failed or never ran is not skipped over:
1950
+
1951
+ ```json
1952
+ { "sha": "abc1234", "commits": ["fix: handle an empty reply", "feat: add a notes tool"] }
1953
+ ```
1954
+
1955
+ The plugin keeps the sha it last announced in `announced-release` in its data directory, so a deploy script on the same host can read the range's start from there.
1956
+ This script writes the file from the checkout, and stops the deploy when `git log` or `jq` fails; the first deploy, with nothing announced yet, gets an empty `commits` list:
1957
+
1958
+ ```sh
1959
+ set -euo pipefail
1960
+ previous=$(cat data/announced-release 2>/dev/null || true)
1961
+ commits='[]'
1962
+ if [ -n "$previous" ]; then
1963
+ commits=$(git log --format=%s "$previous..HEAD" | jq -R . | jq -s .)
1964
+ fi
1965
+ printf '{"sha":"%s","commits":%s}\n' "$(git rev-parse --short HEAD)" "$commits" > release.json
1966
+ ```
1967
+
1968
+ `createReleaseNotice(options)` takes `releaseFile` (default `release.json` in the working directory), `dataDir` (default `./data`, where a new project's config keeps its data, so set it when the config's `dataDir` differs), and `announce` (default: the agent team's `announce`, which posts in the coordinator's channel and splits a long text).
1969
+ The plugin you list in the config, `releaseNotice`, uses the defaults.
1970
+
1971
+ Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source, `release-notice` with temporary directories and its own `announce`.
1972
+ Each file exports a `create...` function (`createCodexImages`, `createDice`, `createReleaseNotice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
1835
1973
 
1836
1974
  ## Testing a plugin
1837
1975
 
@@ -1847,6 +1985,7 @@ It returns:
1847
1985
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
1848
1986
  | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
1849
1987
  | `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
1988
+ | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
1850
1989
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
1851
1990
  | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
1852
1991
  | `runtime` | The runtime the plugin's `runtime` provider built, given stand-in dependencies; `undefined` when it fills no such slot |
@@ -2283,6 +2422,9 @@ Import from the entries listed below; source area files are internal.
2283
2422
  | `ResolvedProviders` | `pi-roundtable` | type |
2284
2423
  | `ResolvedSkill` | `pi-roundtable` | type |
2285
2424
  | `Roundtable` | `pi-roundtable` | value |
2425
+ | `ReplyFile` | `pi-roundtable` | type |
2426
+ | `ReplyFileError` | `pi-roundtable` | value |
2427
+ | `REPLY_FILE_LIMITS` | `pi-roundtable` | value |
2286
2428
  | `RoundtableConfig` | `pi-roundtable` | type |
2287
2429
  | `RoundtableOptions` | `pi-roundtable` | type |
2288
2430
  | `RoundtablePlugin` | `pi-roundtable` | type |
@@ -2336,6 +2478,7 @@ Import from the entries listed below; source area files are internal.
2336
2478
  | `TurnResult` | `pi-roundtable` | type |
2337
2479
  | `TurnSelection` | `pi-roundtable` | type |
2338
2480
  | `Weekday` | `pi-roundtable` | type |
2481
+ | `attachReplyFile` | `pi-roundtable` | value |
2339
2482
  | `channelKey` | `pi-roundtable` | value |
2340
2483
  | `definePlugin` | `pi-roundtable` | value |
2341
2484
  | `defineRoundtable` | `pi-roundtable` | value |
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { ChatSurface } from "pi-roundtable";
3
+ import { testPlugin } from "pi-roundtable/testing";
4
+ import { imageReply } from "./reply-files.ts";
5
+
6
+ test("reply_image records its attachment without posting ahead of the agent", async () => {
7
+ let posted = false;
8
+ const surface: ChatSurface = {
9
+ surface: "fake",
10
+ supportsFiles: true,
11
+ start: async () => undefined,
12
+ sendReply: async () => {
13
+ posted = true;
14
+ },
15
+ };
16
+ const harness = await testPlugin(imageReply, { surfaces: [surface] });
17
+ try {
18
+ expect(
19
+ await harness.runTool("reply_image", {}, { channel: "fake:room" }),
20
+ ).toBe("The sample image is attached to this turn's reply.");
21
+ expect(posted).toBe(false);
22
+ expect(harness.files).toHaveLength(1);
23
+ expect(harness.files[0]?.channel).toBe("fake:room");
24
+ expect(harness.files[0]?.file.name).toBe("sample.png");
25
+ expect(harness.files[0]?.file.data.slice(0, 8)).toEqual(
26
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
27
+ );
28
+ await expect(harness.runTool("reply_image", {})).rejects.toThrow(
29
+ "does not support reply files",
30
+ );
31
+ } finally {
32
+ await harness.stop();
33
+ }
34
+ });
@@ -0,0 +1,27 @@
1
+ import { definePlugin, defineTool } from "pi-roundtable";
2
+ import { Type } from "typebox";
3
+
4
+ /** A small PNG keeps this example runnable without an image provider. */
5
+ export const imageReply = definePlugin({
6
+ name: "image-reply",
7
+ setup: () => ({
8
+ tools: [
9
+ defineTool({
10
+ name: "reply_image",
11
+ description: "Attach a sample image to your reply.",
12
+ parameters: Type.Object({}),
13
+ minTier: "member",
14
+ run: (_args, turn) => {
15
+ turn.attachFile({
16
+ name: "sample.png",
17
+ data: Buffer.from(
18
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
19
+ "base64",
20
+ ),
21
+ });
22
+ return "The sample image is attached to this turn's reply.";
23
+ },
24
+ }),
25
+ ],
26
+ }),
27
+ });
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.6.1",
3
+ "version": "0.7.2",
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.14",
73
+ "@biomejs/biome": "2.5.15",
67
74
  "@types/bun": "1.4.2",
68
75
  "pi-web-access": "0.35.0"
69
76
  }
package/src/cli/cli.ts CHANGED
@@ -36,7 +36,7 @@ const USAGE = `roundtable: a Discord agent server on Pi
36
36
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
37
37
  roundtable start run the checks that need no network, then the bot
38
38
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
39
- the names ${OFFICIAL_PLUGINS.join(" and ")} are reserved for the official plugins,
39
+ the names ${new Intl.ListFormat("en").format(OFFICIAL_PLUGINS)} are reserved for the official plugins,
40
40
  which are copied in ready to run instead of the template
41
41
  roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
42
42
  loads it and gives its tools to every agent turn
@@ -20,7 +20,11 @@ const OFFICIAL_DIR = "official";
20
20
  const PACKAGE_DIR = "package";
21
21
 
22
22
  /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
23
- export const OFFICIAL_PLUGINS = ["codex-images", "dice"] as const;
23
+ export const OFFICIAL_PLUGINS = [
24
+ "codex-images",
25
+ "dice",
26
+ "release-notice",
27
+ ] as const;
24
28
 
25
29
  /** Whether `name` is one of the official plugins. */
26
30
  export const isOfficialPlugin = (name: string): boolean =>
@@ -2,6 +2,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
2
2
  import type {
3
3
  ChannelKey,
4
4
  PendingConfirmation,
5
+ ReplyFile,
5
6
  TurnResult,
6
7
  } from "../domain/conversation.ts";
7
8
  import { AgentError } from "../domain/errors.ts";
@@ -9,6 +10,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
9
10
  import { messages } from "../i18n/index.ts";
10
11
  import { splitReply } from "../presentation/reply-splitter.ts";
11
12
  import { thinkingLine } from "../presentation/thinking-line.ts";
13
+ import { withReplyFiles } from "../reply-files.ts";
12
14
  import { endOf, settleTurn } from "../routing/settle-turn.ts";
13
15
  import type { Speaker } from "../speakers.ts";
14
16
  import { toolTiers } from "../tool-tiers.ts";
@@ -183,17 +185,19 @@ export class TeamTurns {
183
185
  try {
184
186
  result = await settleTurn(
185
187
  () =>
186
- this.#options.runtime().runTurn({
187
- channel: postTo,
188
- selection: this.#options.selection(scope),
189
- text,
190
- ...(extra.attachments ? { attachments: extra.attachments } : {}),
191
- ...(extra.confirmed ? { confirmed: true } : {}),
192
- ...(extra.steerable ? { steerable: true } : {}),
193
- ...(extra.interactive ? { interactive: true } : {}),
194
- agent: scope,
195
- speaker: chain.speaker,
196
- }),
188
+ withReplyFiles(true, () =>
189
+ this.#options.runtime().runTurn({
190
+ channel: postTo,
191
+ selection: this.#options.selection(scope),
192
+ text,
193
+ ...(extra.attachments ? { attachments: extra.attachments } : {}),
194
+ ...(extra.confirmed ? { confirmed: true } : {}),
195
+ ...(extra.steerable ? { steerable: true } : {}),
196
+ ...(extra.interactive ? { interactive: true } : {}),
197
+ agent: scope,
198
+ speaker: chain.speaker,
199
+ }),
200
+ ),
197
201
  "agent turn",
198
202
  );
199
203
  } finally {
@@ -220,6 +224,7 @@ export class TeamTurns {
220
224
  await this.#post(postTo, this.#current(agent), {
221
225
  ...(thinking ? { thinking } : {}),
222
226
  chunks: result.ok ? splitReply(result.text) : [noticeOf(result)],
227
+ ...(result.ok && result.files?.length ? { files: result.files } : {}),
223
228
  });
224
229
  } catch (error) {
225
230
  logger.error({ agent: agent.name, err: error }, "agent reply not posted");
@@ -248,7 +253,12 @@ export class TeamTurns {
248
253
  async #post(
249
254
  channel: ChannelKey,
250
255
  as: Agent,
251
- body: { thinking?: string; chunks: string[]; threadId?: string },
256
+ body: {
257
+ thinking?: string;
258
+ chunks: string[];
259
+ files?: ReplyFile[];
260
+ threadId?: string;
261
+ },
252
262
  ): Promise<void> {
253
263
  const { store, channels, studio, logger } = this.#options;
254
264
  if (!channelOwner(store, channel)) {
@@ -39,6 +39,8 @@ export function channelKey(surface: string, id: string): ChannelKey {
39
39
  export interface ChatSurface {
40
40
  /** The key prefix of this surface's channels, such as "discord"; unique per host. */
41
41
  readonly surface: string;
42
+ /** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
43
+ readonly supportsFiles?: boolean;
42
44
  /**
43
45
  * Connects and delivers every incoming message; the host passes its conversation router. A
44
46
  * message whose channel key has another prefix is logged and dropped.
@@ -1,7 +1,9 @@
1
1
  import type { Static, TObject } from "typebox";
2
+ import type { ReplyFile } from "./domain/conversation.ts";
2
3
  import { PluginError } from "./errors.ts";
3
4
  import type { HoldRule } from "./holds.ts";
4
5
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
6
+ import { attachReplyFile } from "./reply-files.ts";
5
7
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
6
8
  import { toolError, toolText } from "./shared/tool-result.ts";
7
9
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
@@ -22,6 +24,8 @@ export interface ToolTurn {
22
24
  /** The agent whose turn it is. */
23
25
  agent: AgentTurnScope | undefined;
24
26
  signal: AbortSignal | undefined;
27
+ /** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
28
+ attachFile(file: ReplyFile): void;
25
29
  }
26
30
 
27
31
  /**
@@ -108,6 +112,7 @@ export function defineTool<Schema extends TObject>(
108
112
  channel: context.turnChannel,
109
113
  agent: context.agent,
110
114
  signal,
115
+ attachFile: attachReplyFile,
111
116
  }),
112
117
  );
113
118
  } catch (error) {
@@ -64,6 +64,7 @@ export class DiscordSurface
64
64
  implements ChatSurface, OwnerNotifier, DiscordConnection
65
65
  {
66
66
  readonly surface = "discord";
67
+ readonly supportsFiles = true;
67
68
  readonly #options: DiscordSurfaceOptions;
68
69
  readonly #client = new Client({
69
70
  intents: [
@@ -25,8 +25,14 @@ export interface PendingConfirmation {
25
25
  calls: HeldCall[];
26
26
  }
27
27
 
28
+ /** A file produced for a reply; data is raw bytes, not a path or base64. */
29
+ export interface ReplyFile {
30
+ name: string;
31
+ data: Uint8Array;
32
+ }
33
+
28
34
  export type TurnResult =
29
- | { ok: true; text: string; thinking?: string }
35
+ | { ok: true; text: string; thinking?: string; files?: ReplyFile[] }
30
36
  | { ok: false; error: Error; stopped?: true };
31
37
 
32
38
  export interface OutboundReply {
@@ -37,5 +43,5 @@ export interface OutboundReply {
37
43
  /** Message texts in posting order; the card is posted before them. */
38
44
  chunks: string[];
39
45
  /** Files the run produced, posted after the text. */
40
- files?: { name: string; data: Uint8Array }[];
46
+ files?: ReplyFile[];
41
47
  }