pi-roundtable 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.11.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - Channel context reads the people a plugin mirrors into a channel. `ChannelContextOptions.foreignWebhook(webhookId)` (host default under `discord.channelContext`, or per call) makes a post through a webhook of the assistant's application, for the webhooks it returns true for, another author's: kept as a bot named by the post's username, and no longer ending the window, which still ends at the assistant's own posts (its bot user and the webhooks it returns false for). Near-repeat merging of those posts also needs the same name, since one webhook carries many people. The default is unchanged: every webhook of the application is the assistant's.
13
+ - Channel context attachments. `ChannelContextMessage.attachments` (`ChannelContextAttachment`: `name`, `contentType`, `size`, `url`, `messageId`) always lists the files of a returned message. `ChannelContextOptions.attachments` (`ChannelContextAttachmentOptions`: `images` 0 to 4, default 0; `maxBytes`, default 10 MiB, at most 25 MiB) fetches up to that many PNG, JPEG, GIF or WebP images of the kept messages, newest first, into `ChannelContext.images` (`ModelImage[]`, prepared like a turn's own images, held in memory by the call); an image skipped as unsupported, too large, undownloadable or undecodable is named in its message's text. `withChannelContextImages(attachments, context)` returns `{ attachments, kept }`: the turn's attachments with them after its own, within the four a turn carries, and how many fitted; `withChannelContext(text, context, imagesKept?)` then mentions pictures only for images the turn has. An image is sent as the type its bytes show, up to two are downloaded at once, and a call's downloads share a 20 second deadline. Once handed to a turn the images are in its session history like the turn's own. New exports from `pi-roundtable/discord`: `withChannelContextImages`, `ChannelContextAttachment`, `ChannelContextAttachmentOptions`.
14
+ - `ApprovalDetails.copy` (`{ label?, text }`): a text an approval offers to copy. The Discord approval card gets a copy button (labelled "Copy" by default, and in Traditional Chinese on a host in that language) that anyone who can see the card may press, any number of times and also after the card ended; it replies ephemerally with the text in a code block, or as `copy.txt` when the text does not fit a message or holds a code fence, and never approves, declines or otherwise answers the prompt. The button's custom id holds only the card's id; the text is kept in `card-copies.json` under the host's data directory (plain text, readable by the host's user only, written in the background), so the button works after a restart; `discord.cardCopies` (`{ keepDays?, keep? }`, default the newest 100 cards for 30 days, or `false` for memory only) sets how long. A text over 20,000 characters gets no button; the web chat leaves it out of the frame when it would not fit beside the call's input. Surfaces that do not show it ignore it (the web chat forwards it in the approval frame as `approval.copy`); the surface contract says a copy is informational, never an answer.
15
+
16
+ ### Changed
17
+
18
+ - `prepareImageBytes` (`pi-roundtable/kit`), and with it a user's uploaded images and the images of a tool result, reports the `mimeType` its bytes show (PNG, JPEG, GIF or WebP) instead of the declared content type, which may be a file name's guess. The formats accepted are unchanged; its second parameter is now unused.
19
+ - `discord.cardCopies: false` also deletes a `card-copies.json` (and a leftover temporary file) that an earlier run left in the data directory when the host starts.
20
+
21
+ ### Fixed
22
+
23
+ - The Discord plugin waits for pending card copy texts to be written when the host stops, so the last cards' copy buttons survive the restart.
24
+
8
25
  ## [0.10.0] - 2026-10-13
9
26
 
10
27
  ### Added
package/README.md CHANGED
@@ -186,6 +186,8 @@ Tune it under `discord.channelContext`, or set `channelContext: false` to turn i
186
186
  | `similarity` | `0.8` | How alike two consecutive messages of one author must be to count as one |
187
187
  | `messageChars` | `500` | Where a message is cut |
188
188
  | `botMessageChars` | `80` | Where another bot's message is cut |
189
+ | `foreignWebhook` | none | `(webhookId) => boolean`: posts through those webhooks of the application are read as other people's messages, not the assistant's (code only) |
190
+ | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`: how many images of the kept messages are fetched for the turn, at most 4 |
189
191
 
190
192
  When a conversation is started over in a server channel or thread, the assistant posts a short divider there so the next turn does not read what came before; direct messages get none.
191
193
 
@@ -195,6 +197,7 @@ When a conversation is started over in a server channel or thread, the assistant
195
197
  | `false` | | Post no divider |
196
198
 
197
199
  An approval card or an `ask_user` question holds its turn for `discord.cardGraceSeconds` (default `120`).
200
+ The copy button of an approval card keeps its text in `card-copies.json` under the data directory, the newest 100 for 30 days; `discord.cardCopies` (`{ keepDays?, keep? }`) changes that, and `false` keeps them in memory only.
198
201
  Answered within it, the turn goes on with the answer; otherwise the turn ends saying it is waiting, the card stays open with no deadline, and answering it later starts a new turn in that conversation with the answer, or with the result of the approved action.
199
202
  A coding worker's cards keep the worker waiting instead.
200
203
 
package/README.zh-TW.md CHANGED
@@ -181,8 +181,11 @@ export default {
181
181
  | `similarity` | `0.8` | 同一作者相鄰兩則訊息相似到什麼程度算同一則 |
182
182
  | `messageChars` | `500` | 訊息截斷的長度 |
183
183
  | `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
184
+ | `foreignWebhook` | 無 | `(webhookId) => boolean`:經由這些 webhook 發出的貼文視為別人的訊息,不是助理自己的(只能用程式碼設定) |
185
+ | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
184
186
 
185
187
  核准卡和 `ask_user` 問題會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
188
+ 核准卡「複製」按鈕的文字存在資料目錄的 `card-copies.json`,預設留最新 100 張、30 天;`discord.cardCopies`(`{ keepDays?, keep? }`)可以調整,設成 `false` 則只留在記憶體。
186
189
  在這段時間內回答,這一輪就帶著答案繼續;沒回答的話,這一輪會說它在等你,然後結束,卡片繼續開著、沒有期限,之後再回答就會在同一個對話開新的一輪,帶上你的答案,或核准後那個動作的結果。
187
190
  coding worker 的卡片則會讓 worker 一直等下去。
188
191
 
package/docs/plugins.md CHANGED
@@ -55,7 +55,7 @@ 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.
58
+ - Admitted media: `prepareImageBytes(data, contentType)` prepares raster bytes without reopening a guest-writable path; the result's type is the one the bytes show (PNG, JPEG, GIF or WebP), not the declared one, and other bytes throw `ImagePreparationError`; supported small images remain unchanged, and larger sides/encoded images are downsized to JPEG at a 2,000 px long side.
59
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
60
  `ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
61
61
  Ordinary 48 MP images and thin 9000×1 images remain admitted.
@@ -2270,7 +2270,7 @@ test("the persona is the plugin's and belongs to the study kind only", async ()
2270
2270
  In a Discord server channel, people talk among themselves and address the assistant now and then.
2271
2271
  Channel context lets the turn for an addressed message also read what was posted there since the assistant last posted, mentions or not.
2272
2272
  The agent server reads it for every turn in an agent's channel; a group's round does not, since it already carries what was said since the group's last turn.
2273
- A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)` from `pi-roundtable/discord` inside its turn's `run`, and appending the result with `withChannelContext(text, context)`, as `withReference` adds the replied-to message.
2273
+ A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)` from `pi-roundtable/discord` inside its turn's `run`, and appending the result with `withChannelContext(text, context, imagesKept?)`, as `withReference` adds the replied-to message.
2274
2274
 
2275
2275
  It reads Discord, not the host's history, so it survives restarts:
2276
2276
 
@@ -2278,13 +2278,17 @@ It reads Discord, not the host's history, so it survives restarts:
2278
2278
  - It leaves out the assistant's own posts, the addressed message, empty messages, and every message it handed over before or that addressed it before in that channel, remembered in memory per channel, so a restart can repeat at most what came since the assistant's last post.
2279
2279
  - Other bots and integrations are kept and marked as bots; the host's owners (the primary owner and every other owner's Discord identities) are marked as owners.
2280
2280
  - Consecutive messages of one author at least `similarity` alike (default 0.8, by their longest common run of text) count as one, the later kept.
2281
- - It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; nothing is downloaded or shown to the model as an image. A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
2281
+ - It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; by default nothing is downloaded or shown to the model as an image (see `attachments` below). A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
2282
2282
  - It never fails the turn over Discord: a Discord error, such as a channel the bot cannot read, is logged and the turn runs without context.
2283
2283
 
2284
+ A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
2285
+
2286
+ Every returned message has `attachments` (`ChannelContextAttachment`: `name`, `contentType`, `size`, `url`, `messageId`), whether or not anything was fetched. With `attachments: { images, maxBytes }` (`ChannelContextAttachmentOptions`; default none) a call also fetches up to `images` (at most 4, a turn's limit) PNG, JPEG, GIF or WebP images of the kept messages, the newest first, each at most `maxBytes` (default 10 MiB, at most 25 MiB), and returns them as `context.images`, prepared as a turn's own images are (large ones are scaled down) and kept in memory by this call and never logged; once a turn is given them they are in its session history like the turn's own images, and stay there. Pass them on with `withChannelContextImages(attachments, context)`, which returns `{ attachments, kept }`: the turn's attachments with the context's images after the turn's own, within the four a turn carries, and how many of them fitted (a turn that already carries four keeps none, and an image crowded out is not noted anywhere). Give `kept` to `withChannelContext(text, context, kept)`: above zero it adds a line that pictures are shown among the turn's images, and it adds none otherwise, so the block never claims images the turn does not have. The image is sent as the type its bytes show (PNG, JPEG, GIF or WebP), not as Discord's guess from the file name, and bytes of none of them are skipped as `unsupported type`. Up to two images are downloaded at once, within 20 seconds for the whole call; the images still to be fetched when it passes are skipped as `download failed`. An image that is not a type the model takes, is over `maxBytes`, cannot be downloaded or cannot be decoded is skipped, and its message's text says `[image not attached: <name>: <reason>]`, the reason being `unsupported type` (by Discord's type or by the bytes), `too large`, `download failed` or `could not be decoded`; skipped images do not count toward `images`, but at most twice that many are tried. An image of a message already handed over is not fetched again, since its message is not returned again.
2287
+
2284
2288
  `DISCORD.channelContext` resolves undefined in a direct message, on another surface, when nothing new was said, and when the host's `discord.channelContext` is `false`, which turns it off for the agent server and every claim.
2285
2289
  The configuration's values are the host's defaults, and a call's `options` override them for that call.
2286
2290
  Call it once per answered message: what it returns is remembered as handed over.
2287
- A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2291
+ A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2288
2292
  It remembers what it handed over and what addressed the assistant, not every message that ran a turn: a post a claim answered some other way, such as a webhook report turn or a steered message, can also come in the context of a later turn if the assistant has not posted since.
2289
2293
 
2290
2294
  What it returns is public channel text, visible to everyone in the channel; it is not private memory, carries no `privateTo`, and goes into the turn's text, so it lives in the conversation's history, not in the system prompt.
@@ -2299,7 +2303,7 @@ The text is `discord.freshMarker` (default `─── new conversation ───
2299
2303
  A surface a plugin contributes may implement the optional `ChatSurface.markFresh(channel)` for the same job.
2300
2304
 
2301
2305
  The block gives each author as a name, their Discord id and a role (`owner`, `bot` or `member`) in separate attributes, and says the messages were not addressed to the assistant. A name loses the characters `"`, `'`, `=`, `<`, `>`, `(`, `)` and `,`, so it cannot imitate another author, an attribute or a role, and every `<` (also the full-width `<`) in a message's text is replaced by `‹`, so a message can neither close the block nor open a message of its own.
2302
- `ChannelContext` is `{ messages, text }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `text`, `at`), oldest first, and `text` is the block.
2306
+ `ChannelContext` is `{ messages, text, images? }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `text`, `at`, `attachments`), oldest first, `text` is the block, and `images` the fetched images (see above).
2303
2307
  `ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
2304
2308
  In a test, `fakeDiscord({ channelContext })` answers `DISCORD.channelContext` as the test says; by default with no context.
2305
2309
 
@@ -2543,8 +2547,13 @@ The web chat shows prompts to the conversation's person only: its owners are not
2543
2547
  `confirm(title, message, signal?, minTier?, wait?, details?, options?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2544
2548
  The confirmation gate and a coding worker's cards pass both.
2545
2549
  `options` (`ApprovalOptions`) says who else may approve the call and hears who did: `approvers`, the principal ids that approve it instead of the speaker (see [who answers a prompt](#who-answers-a-prompt-promptscope)), and `approvedBy(by)`, which a surface that knows who answered calls with `{ principalId, speakerId, name, owner? }` (`ApprovedBy`; `owner` when they approved as an owner) before `confirm` resolves `approved`. A surface may ignore both for a call that names no approvers. For one that does, the runtime accepts the approval only from a surface that calls `approvedBy` with a named principal or an `owner`, so a surface that cannot say who answered must not offer such a call to a shared conversation.
2546
- A surface that shows text, such as Discord, uses `message` and ignores `details`; the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2550
+ A surface that shows text, such as Discord, uses `message` and reads only `details.copy` (below); the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2547
2551
  A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2552
+
2553
+ `details.copy` (`{ label?, text }`) is a text the approval offers to copy, such as a command or a message the call would send; whoever builds `details` for a `confirm` of their own sets it.
2554
+ It is informational and never an answer: using it does not approve, decline or otherwise settle the prompt, and a surface that cannot show it ignores it, as the web chat's own client does (the frame carries it as `approval.copy`, left out when it would not fit beside the call's `input` in the frame's 256 KiB: the input is never cut for it).
2555
+ On Discord the card gets a third button (`label`, default "Copy", or its Traditional Chinese equivalent on a host in that language) that any member who can see the card may press, any number of times, also after the card has ended; it replies ephemerally with the text in a code block, or as a `copy.txt` file when the text does not fit a message (2,000 characters) or holds a code fence. The button's custom id holds the card's id, never the text: the text is kept under that id in `card-copies.json` in the host's `dataDir`, in plain text and readable by the host's user only, so the button works after a restart even though the card itself no longer answers; a card whose text is gone says so. `discord.cardCopies` (`{ keepDays?, keep? }`, default the newest 100 cards for 30 days) sets how long and how many; `false` keeps the texts in memory only, with the same limits, so they are gone at a restart. The file is written in the background, one write at a time, so a card is not held up by it. The attachments' `retention` and discarding a conversation do not remove the texts: they age out, or set `cardCopies` to `false` on a host that wants none on disk: the host then deletes an existing `card-copies.json` when it starts. A text over 20,000 characters gets no button, and a warning is logged.
2556
+ Because anyone who can see the card can copy the text, it holds nothing they may not read.
2548
2557
  `confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
2549
2558
 
2550
2559
  #### A prompt that outlives its turn: `PromptWait`
@@ -3225,6 +3234,7 @@ A plugin that contributes a `ChatSurface` can test it against the contract every
3225
3234
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3226
3235
  It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; with an `approver` and a `stranger`, that an approval naming the approver is answered by the approver and not by the person or the stranger, unless the surface is `privateOnly`, and, with an `approver`, that a private conversation ignores the names; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3227
3236
  A check for a part the surface does not offer passes.
3237
+ The contract has no check for `ApprovalDetails.copy`: a surface that shows it keeps it informational, so using it never resolves, approves or declines the prompt, whoever uses it.
3228
3238
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
3229
3239
 
3230
3240
  ## What happens when the bot starts and stops
@@ -3896,6 +3906,8 @@ Import from the entries listed below; source area files are internal.
3896
3906
  | `CHANNEL_OPERATIONS` | `pi-roundtable/discord` | value |
3897
3907
  | `CHANNEL_TOOLS` | `pi-roundtable/discord` | value |
3898
3908
  | `ChannelContext` | `pi-roundtable/discord` | type |
3909
+ | `ChannelContextAttachment` | `pi-roundtable/discord` | type |
3910
+ | `ChannelContextAttachmentOptions` | `pi-roundtable/discord` | type |
3899
3911
  | `ChannelContextMessage` | `pi-roundtable/discord` | type |
3900
3912
  | `ChannelContextOptions` | `pi-roundtable/discord` | type |
3901
3913
  | `ChannelExecutor` | `pi-roundtable/discord` | type |
@@ -3940,3 +3952,4 @@ Import from the entries listed below; source area files are internal.
3940
3952
  | `plain` | `pi-roundtable/discord` | value |
3941
3953
  | `replyWithPanels` | `pi-roundtable/discord` | value |
3942
3954
  | `withChannelContext` | `pi-roundtable/discord` | value |
3955
+ | `withChannelContextImages` | `pi-roundtable/discord` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@ import { attachmentsOf } from "../attachments/turn-attachments.ts";
2
2
  import {
3
3
  type ChannelContext,
4
4
  withChannelContext,
5
+ withChannelContextImages,
5
6
  } from "../contract/channel-context.ts";
6
7
  import type {
7
8
  Admission,
@@ -165,18 +166,21 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
165
166
  const saved = await attachments(message);
166
167
  const text = withReference(message, forwarder);
167
168
  // A group's history already names each author.
168
- if (owned === "agent")
169
+ if (owned === "agent") {
170
+ const context = await around(message);
171
+ // The turn's own images first; the block mentions the context's only if they fit.
172
+ const { attachments: carried, kept } = withChannelContextImages(
173
+ saved,
174
+ context,
175
+ );
169
176
  await team.answerOwner(
170
177
  channel,
171
178
  speaker,
172
- attributed(
173
- speaker,
174
- withChannelContext(text, await around(message)),
175
- ),
179
+ attributed(speaker, withChannelContext(text, context, kept)),
176
180
  message.text,
177
- saved,
181
+ carried,
178
182
  );
179
- else
183
+ } else
180
184
  await team.answerGroup(
181
185
  channel,
182
186
  speaker,
@@ -1,5 +1,9 @@
1
1
  import { createCanvas, loadImage } from "canvas";
2
- import type { ModelImage, StoredAttachment } from "../domain/attachment.ts";
2
+ import {
3
+ MAX_TURN_IMAGES,
4
+ type ModelImage,
5
+ type StoredAttachment,
6
+ } from "../domain/attachment.ts";
3
7
  import { MAX_ATTACHMENT_BYTES } from "./attachment-fetcher.ts";
4
8
  import { assertImagePixelBudget } from "./image-budget.ts";
5
9
  import { ImagePreparationError } from "./image-preparation-error.ts";
@@ -11,7 +15,7 @@ const IMAGE_TYPES = new Set([
11
15
  "image/gif",
12
16
  "image/webp",
13
17
  ]);
14
- export const MAX_IMAGES_PER_TURN = 4;
18
+ export const MAX_IMAGES_PER_TURN = MAX_TURN_IMAGES;
15
19
  /** Base64 grows by a third, so 3.75 MB stays under Claude's 5 MB per image. */
16
20
  const MAX_RAW_BYTES = 3.75 * 1024 * 1024;
17
21
  const MAX_SIDE = 8000;
@@ -25,6 +29,26 @@ export function isModelImage(
25
29
  );
26
30
  }
27
31
 
32
+ /**
33
+ * The type the bytes themselves say they are (PNG, JPEG, GIF or WebP), whatever was declared for
34
+ * them; undefined for anything else.
35
+ */
36
+ export function sniffImageType(data: Uint8Array): string | undefined {
37
+ const at = (offset: number, ...bytes: number[]) =>
38
+ bytes.every((byte, index) => data[offset + index] === byte);
39
+ if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
40
+ if (at(0, 0xff, 0xd8, 0xff)) return "image/jpeg";
41
+ if (
42
+ at(0, 0x47, 0x49, 0x46, 0x38) &&
43
+ (at(4, 0x37) || at(4, 0x39)) &&
44
+ at(5, 0x61)
45
+ )
46
+ return "image/gif";
47
+ if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50))
48
+ return "image/webp";
49
+ return undefined;
50
+ }
51
+
28
52
  /**
29
53
  * Returns the image as the model accepts it. An image over the size or side limit is
30
54
  * re-encoded as JPEG with its long side at 2,000 px instead of being dropped.
@@ -51,16 +75,20 @@ export async function prepareImage(
51
75
  }
52
76
 
53
77
  /** 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. */
78
+ * Headers and all GIF/WebP frames must fit 64 MP; encoded bytes must fit 25 MiB. The type of the
79
+ * result is the one the bytes show (PNG, JPEG, GIF or WebP), not the declared `contentType`, which
80
+ * may be a file name's guess; bytes of none of them are `invalid-image`. */
55
81
  export async function prepareImageBytes(
56
82
  data: Uint8Array,
83
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for callers; the type comes from the bytes
57
84
  contentType: string,
58
85
  ): Promise<ModelImage> {
59
86
  if (data.byteLength > MAX_ATTACHMENT_BYTES)
60
87
  throw new ImagePreparationError("byte-limit");
61
88
  const bytes = Buffer.from(data);
62
89
  assertImagePixelBudget(bytes, MAX_SIDE * MAX_SIDE);
63
- const mimeType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
90
+ const mimeType = sniffImageType(bytes);
91
+ if (!mimeType) throw new ImagePreparationError("invalid-image");
64
92
  const image = await loadImage(bytes);
65
93
  if (
66
94
  bytes.byteLength <= MAX_RAW_BYTES &&
@@ -1,3 +1,4 @@
1
+ import { rmSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import type {
3
4
  ChannelContext,
@@ -72,6 +73,13 @@ export interface DiscordOptions {
72
73
  * card open; a later answer starts a new turn. Default `CARD_GRACE_MS`, two minutes.
73
74
  */
74
75
  cardGraceMs?: number;
76
+ /**
77
+ * How the texts behind approval cards' copy buttons are kept: the newest `keep` (default 100)
78
+ * for `keepDays` (default 30), in `card-copies.json` under `dataDir`, so the buttons work after
79
+ * a restart. `false` keeps them in memory only, with the same limits: they are gone when the
80
+ * process restarts and the button then says so.
81
+ */
82
+ cardCopies?: { keepDays?: number; keep?: number } | false;
75
83
  }
76
84
 
77
85
  /**
@@ -94,6 +102,12 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
94
102
  },
95
103
  setup: async ({ conversations, services, logger }) => {
96
104
  const identity = services.find(IDENTITY);
105
+ // A host that keeps no copy texts on disk drops what an earlier run left.
106
+ if (options.cardCopies === false) {
107
+ const file = join(options.dataDir, "card-copies.json");
108
+ rmSync(file, { force: true });
109
+ rmSync(`${file}.tmp`, { force: true });
110
+ }
97
111
  // The surface exists by the time a turn posts a card.
98
112
  const cards: OwnerCards = new OwnerCards({
99
113
  ownerId: options.ownerId,
@@ -105,6 +119,12 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
105
119
  ? {}
106
120
  : { graceMs: options.cardGraceMs }),
107
121
  resume: (turn) => connected.resume(turn),
122
+ copies: {
123
+ ...(options.cardCopies === false
124
+ ? {}
125
+ : { path: join(options.dataDir, "card-copies.json") }),
126
+ ...(options.cardCopies || {}),
127
+ },
108
128
  });
109
129
  const connected = new DiscordSurface({
110
130
  token: options.token,
@@ -181,6 +201,8 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
181
201
  services: [
182
202
  // The sweep reads threads through the connection, so it follows the surface's start.
183
203
  { name: "threads", start: () => void threads.sweep() },
204
+ // The copy texts of the last cards are on disk before the host exits.
205
+ { name: "card-copies", stop: () => cards.flushCopies() },
184
206
  ],
185
207
  };
186
208
  },
@@ -2,6 +2,7 @@ import type { ChannelContextOptions } from "../contract/channel-context.ts";
2
2
  import { ConfigError } from "../domain/errors.ts";
3
3
  import {
4
4
  bool,
5
+ guarded,
5
6
  integer,
6
7
  number,
7
8
  oneOf,
@@ -62,6 +63,14 @@ export interface DiscordConfig {
62
63
  * the answer. Default 120.
63
64
  */
64
65
  cardGraceSeconds?: number;
66
+ /**
67
+ * How the texts behind approval cards' copy buttons are kept: the newest `keep` (default 100)
68
+ * for `keepDays` (default 30), in `card-copies.json` under the data directory, so the buttons
69
+ * work after a restart. `false` keeps them in memory only, with the same limits: they are gone
70
+ * when the process restarts, and the button then says so. Discarding a conversation does not
71
+ * remove them; they age out.
72
+ */
73
+ cardCopies?: { keepDays?: number; keep?: number } | false;
65
74
  }
66
75
 
67
76
  /** The Discord settings with every default filled in. */
@@ -78,6 +87,8 @@ export interface ResolvedDiscord {
78
87
  channelContext: NonNullable<DiscordConfig["channelContext"]>;
79
88
  freshMarker?: string | false;
80
89
  cardGraceSeconds: number;
90
+ /** As configured: `{}` when left out, so the defaults apply, or `false`. */
91
+ cardCopies: NonNullable<DiscordConfig["cardCopies"]> | false;
81
92
  }
82
93
 
83
94
  /** The configured Discord settings, defaults filled in; `rootCommand` is the host's slug. */
@@ -105,6 +116,7 @@ export function resolvedDiscord(
105
116
  ? {}
106
117
  : { freshMarker: discord.freshMarker }),
107
118
  cardGraceSeconds: discord.cardGraceSeconds ?? 120,
119
+ cardCopies: discord.cardCopies ?? {},
108
120
  };
109
121
  }
110
122
 
@@ -115,6 +127,19 @@ const channelContextShape = shape({
115
127
  similarity: optional(number(0, 1)),
116
128
  messageChars: optional(integer(1, 4000)),
117
129
  botMessageChars: optional(integer(1, 4000)),
130
+ foreignWebhook: optional(
131
+ guarded(
132
+ "a function of a webhook id",
133
+ (value): value is (webhookId: string) => boolean =>
134
+ typeof value === "function",
135
+ ),
136
+ ),
137
+ attachments: optional(
138
+ shape({
139
+ images: optional(integer(0, 4)),
140
+ maxBytes: optional(integer(1, 25 * 1024 * 1024)),
141
+ }),
142
+ ),
118
143
  });
119
144
 
120
145
  /** The Discord settings, at the top level or in a Discord adapter. */
@@ -130,6 +155,14 @@ export const discordShape = shape({
130
155
  channelContext: optional(orOff(channelContextShape)),
131
156
  freshMarker: optional(orOff(text)),
132
157
  cardGraceSeconds: optional(integer(1, 86_400)),
158
+ cardCopies: optional(
159
+ orOff(
160
+ shape({
161
+ keepDays: optional(integer(1, 3_650)),
162
+ keep: optional(integer(1, 10_000)),
163
+ }),
164
+ ),
165
+ ),
133
166
  });
134
167
 
135
168
  /** The adapters `adapters` takes, by the name their factory gives them. */
@@ -1,3 +1,9 @@
1
+ import {
2
+ MAX_TURN_IMAGES,
3
+ type ModelImage,
4
+ type TurnAttachments,
5
+ } from "../domain/attachment.ts";
6
+
1
7
  // What a surface tells a turn about the channel around the message it answers. Discord fills it
2
8
  // (`DISCORD.channelContext`); the types and the way a turn carries it are any surface's.
3
9
 
@@ -16,6 +22,41 @@ export interface ChannelContextOptions {
16
22
  messageChars?: number;
17
23
  /** Where another bot's message is cut; default 80 characters. */
18
24
  botMessageChars?: number;
25
+ /**
26
+ * Whether a post through a webhook of the assistant's own application is another author's, not
27
+ * the assistant's. `webhookId` is the webhook's id; when this returns true the post is kept as a
28
+ * message of a bot named by the post's username, and does not end the window, so people a
29
+ * plugin mirrors into the channel through a webhook of its own are read. The assistant's own
30
+ * posts, which end the window, stay its bot user and every webhook this returns false for, such
31
+ * as an agent's voice. Default: none, every webhook of the application is the assistant's.
32
+ */
33
+ foreignWebhook?: (webhookId: string) => boolean;
34
+ /** Which attachments of the kept messages are fetched for the turn; none by default. */
35
+ attachments?: ChannelContextAttachmentOptions;
36
+ }
37
+
38
+ /** What `ChannelContextOptions.attachments` asks of the images in the kept messages. */
39
+ export interface ChannelContextAttachmentOptions {
40
+ /**
41
+ * How many images are fetched, at most, the newest first; default 0, none. At most 4, as many
42
+ * as a turn carries. A PNG, JPEG, GIF or WebP that cannot be fetched, is larger than
43
+ * `maxBytes`, or cannot be decoded is skipped and named in its message's text.
44
+ */
45
+ images?: number;
46
+ /** The most bytes of one image fetched; default 10 MiB, at most 25 MiB. */
47
+ maxBytes?: number;
48
+ }
49
+
50
+ /** A file attached to a channel message, as Discord describes it; its bytes are not fetched by this. */
51
+ export interface ChannelContextAttachment {
52
+ name: string;
53
+ /** Empty when Discord does not say. */
54
+ contentType: string;
55
+ /** Bytes, as Discord reports. */
56
+ size: number;
57
+ url: string;
58
+ /** The message it is attached to. */
59
+ messageId: string;
19
60
  }
20
61
 
21
62
  /** One channel message a turn reads around the message that addressed the assistant. */
@@ -31,6 +72,8 @@ export interface ChannelContextMessage {
31
72
  /** The text, with mentions by name and stickers and files as text, cut where the options say. */
32
73
  text: string;
33
74
  at: Date;
75
+ /** The files attached to it, whether or not their bytes were fetched; always present on what the core returns. */
76
+ attachments?: readonly ChannelContextAttachment[];
34
77
  }
35
78
 
36
79
  /**
@@ -40,14 +83,51 @@ export interface ChannelContextMessage {
40
83
  export interface ChannelContext {
41
84
  /** Oldest first; never empty. */
42
85
  readonly messages: readonly ChannelContextMessage[];
43
- /** The delimited block `withChannelContext` appends to a turn's text. */
86
+ /** The delimited block `withChannelContext` appends to a turn's text; it says nothing of images, `withChannelContext` adds that. */
44
87
  readonly text: string;
88
+ /**
89
+ * The images of the messages, newest first, as `TurnAttachments.images`: fetched for the call
90
+ * when `ChannelContextOptions.attachments.images` asked for some, prepared as a turn's own
91
+ * images are, and never stored or logged; absent or empty otherwise. Pass them to the turn with
92
+ * `withChannelContextImages`.
93
+ */
94
+ readonly images?: readonly ModelImage[];
45
95
  }
46
96
 
47
- /** The turn's text, then its channel context when there is one, as `withReference` adds a replied-to message. */
97
+ /** What the turn is told when channel context images are among its images. */
98
+ const CONTEXT_IMAGES_NOTE =
99
+ "Pictures posted in the channel messages above are shown to you among this turn's images, the newest first.";
100
+
101
+ /**
102
+ * The turn's text, then its channel context when there is one, as `withReference` adds a replied-to
103
+ * message. `imagesKept` is how many of the context's images the turn carries, as
104
+ * `withChannelContextImages` reports; above zero the block says so, and it never does otherwise.
105
+ */
48
106
  export function withChannelContext(
49
107
  text: string,
50
108
  context: ChannelContext | undefined,
109
+ imagesKept = 0,
51
110
  ): string {
52
- return context ? `${text}\n\n${context.text}` : text;
111
+ if (!context) return text;
112
+ return `${text}\n\n${context.text}${imagesKept > 0 ? `\n${CONTEXT_IMAGES_NOTE}` : ""}`;
113
+ }
114
+
115
+ /**
116
+ * The turn's attachments with the context's images after its own, at most as many images as a turn
117
+ * carries (4); the turn's own images come first and are never displaced, so a turn that already
118
+ * carries four takes none, and an image crowded out is not noted anywhere. `kept` is how many of
119
+ * the context's images made it, the newest first; give it to `withChannelContext` so the block says
120
+ * so only for images the turn has. The attachments are untouched without a context or images.
121
+ */
122
+ export function withChannelContextImages(
123
+ attachments: TurnAttachments,
124
+ context: ChannelContext | undefined,
125
+ ): { attachments: TurnAttachments; kept: number } {
126
+ const more = context?.images ?? [];
127
+ if (more.length === 0) return { attachments, kept: 0 };
128
+ const images = [...attachments.images, ...more].slice(0, MAX_TURN_IMAGES);
129
+ return {
130
+ attachments: { ...attachments, images },
131
+ kept: Math.max(0, images.length - attachments.images.length),
132
+ };
53
133
  }
@@ -173,6 +173,7 @@ function discordAssembly(
173
173
  ? {}
174
174
  : { freshMarker: discord.freshMarker }),
175
175
  cardGraceMs: discord.cardGraceSeconds * 1_000,
176
+ cardCopies: discord.cardCopies,
176
177
  }),
177
178
  admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
178
179
  skills: config.skills