pi-roundtable 0.10.0 → 0.12.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 +28 -0
- package/README.md +3 -0
- package/README.zh-TW.md +3 -0
- package/docs/plugins.md +32 -7
- package/package.json +1 -1
- package/src/core/agents/agent-claim.ts +11 -7
- package/src/core/attachments/image-prep.ts +32 -4
- package/src/core/builtin/discord.ts +22 -0
- package/src/core/config/adapters.ts +33 -0
- package/src/core/contract/channel-context.ts +88 -3
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +20 -4
- package/src/core/discord/card-copies.ts +192 -0
- package/src/core/discord/channel-context.ts +149 -16
- package/src/core/discord/context-images.ts +164 -0
- package/src/core/discord/owner-cards-fixture.ts +9 -2
- package/src/core/discord/owner-cards.ts +47 -1
- package/src/core/domain/attachment.ts +3 -0
- package/src/core/holds.ts +14 -4
- package/src/core/i18n/cards.ts +7 -0
- package/src/core/interactions/prompts.ts +10 -0
- package/src/core/runtime/extensions/approval-card.ts +4 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +7 -6
- package/src/core/runtime/extensions/gate-refusals.ts +7 -2
- package/src/core/runtime/extensions/hold-lookups.ts +68 -16
- package/src/discord/index.ts +6 -1
- package/src/index.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.12.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- A held tool call's approval card can carry a copy button. A tool's `hold`, and a hold rule's `describeInTurn`, may answer `HoldDescription` (new type, exported from `pi-roundtable`): the description as before, or `{ text, copy? }` with `copy` (`{ label?, text }`, as `ApprovalDetails.copy`) for the card to offer; the confirmation gate puts it in the `ApprovalDetails` it builds, so every limit of `ApprovalDetails.copy` applies (Discord's button, `discord.cardCopies`, the 20,000 character cap, the web chat's `approval.copy`). It shares the description's wait and failure: a rejection, a throw or a timeout holds the call under the generic description with no copy, and a `copy` that is not a non-empty text is left out. The copy is not kept with the held call, so a confirmation by message after a restart has no button; a host with no turn (a precheck script's call) uses `text`. A hold that answers a string is unchanged, as is `holdChain(rules).inTurn(...)` for rules that do.
|
|
13
|
+
- `ChannelContextOptions.foreignWebhook` may return `"skip"` as well as a boolean (`(webhookId: string) => boolean | "skip"`): a post through that webhook of the application is dropped from the channel context and does not end the window, as if it were not in the channel, for a plugin whose own status notices go through a webhook of their own. `true` and `false` mean what they did, and any other answer reads as false. A skipped post is still one of the `fetch` messages read, so it uses that budget.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Code that reads a hold through `HoldCheck.inTurn`, `holdChain(rules)`, `testPlugin().holds.inTurn`, a wrapped `describeInTurn` or `ToolSpec.hold` now gets a `HoldDescription` (a string or `{ text, copy? }`) where it got a string; `ChannelContextOptions.foreignWebhook` may return the truthy `"skip"`, so a caller that treats its answer as a boolean should compare with `true`.
|
|
18
|
+
|
|
19
|
+
## [0.11.0] - 2026-10-13
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
- 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`.
|
|
25
|
+
- `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.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `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.
|
|
30
|
+
- `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.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- 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.
|
|
35
|
+
|
|
8
36
|
## [0.10.0] - 2026-10-13
|
|
9
37
|
|
|
10
38
|
### 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 \| "skip"`: posts through the webhooks it returns true for are read as other people's messages, not the assistant's; `"skip"` drops a webhook's posts as if absent, without ending the window (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 \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是助理自己的;回傳 `"skip"` 則把該 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.
|
|
@@ -810,7 +810,18 @@ The gate awaits the description before it posts the card, so the card's bold lin
|
|
|
810
810
|
- A hold must change nothing: it may be called again for the same call, such as when the owner's confirming message makes the call again.
|
|
811
811
|
- A hold that declares only its arguments and answers at once is called as it always was. Where a host checks the hold with no turn, such as the `holds` of a test harness or a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s tool call, a hold that declares `turn` is not called; it is described as `run <tool>` and so counts as held.
|
|
812
812
|
|
|
813
|
-
A
|
|
813
|
+
A hold may also offer the owner's approval card a text to copy, such as the whole message a call would send, by answering `{ text, copy }` (the type `HoldDescription`: the description alone, or `{ text, copy?: { label?, text } }`) instead of the description:
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
hold: async ({ customer, body }, turn) => ({
|
|
817
|
+
text: `Send a message to ${(await customers.get(customer, turn.speaker, turn.signal)).name}`,
|
|
818
|
+
copy: { label: "Copy message", text: body },
|
|
819
|
+
}),
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
`text` is the description, with every behaviour above; `copy` goes to the gate's `ApprovalDetails.copy` for that call, so the Discord card gets the [copy button](#who-answers-a-prompt-promptscope) with all of its limits (the 20,000 character cap, the label, `discord.cardCopies`), and the web chat forwards it as `approval.copy`. It is part of the same answer, so a rejection, a throw or a timeout drops it with the description: the call is held under the generic description with no copy, and the exact input is still on the card. A `copy` whose `text` is not a non-empty string is left out and the call is still held. The copy is not kept with the held call (`HeldCall` has no copy), so a card the owner answers by a message after a restart has no button, and a host that shows no card, or that checks the hold with no turn, such as a precheck script's call, uses `text` and ignores `copy`. A hold that answers a plain string is unchanged. The copy is visible to anyone who sees the card, so it holds nothing they may not read.
|
|
823
|
+
|
|
824
|
+
A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise, and a `HoldDescription` as well as a string, and which the gate asks instead of `describe` in a turn.
|
|
814
825
|
`describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
|
|
815
826
|
|
|
816
827
|
A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool. A rule with `approversInTurn` whose answer depends on the input can likewise answer `mayName(tool)`: whether it may name approvers for some call of that tool; without it the rule is taken to name approvers for every tool.
|
|
@@ -2270,7 +2281,7 @@ test("the persona is the plugin's and belongs to the study kind only", async ()
|
|
|
2270
2281
|
In a Discord server channel, people talk among themselves and address the assistant now and then.
|
|
2271
2282
|
Channel context lets the turn for an addressed message also read what was posted there since the assistant last posted, mentions or not.
|
|
2272
2283
|
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.
|
|
2284
|
+
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
2285
|
|
|
2275
2286
|
It reads Discord, not the host's history, so it survives restarts:
|
|
2276
2287
|
|
|
@@ -2278,13 +2289,17 @@ It reads Discord, not the host's history, so it survives restarts:
|
|
|
2278
2289
|
- 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
2290
|
- 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
2291
|
- 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).
|
|
2292
|
+
- 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
2293
|
- 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
2294
|
|
|
2295
|
+
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. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. 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.
|
|
2296
|
+
|
|
2297
|
+
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.
|
|
2298
|
+
|
|
2284
2299
|
`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
2300
|
The configuration's values are the host's defaults, and a call's `options` override them for that call.
|
|
2286
2301
|
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).
|
|
2302
|
+
A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `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
2303
|
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
2304
|
|
|
2290
2305
|
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 +2314,7 @@ The text is `discord.freshMarker` (default `─── new conversation ───
|
|
|
2299
2314
|
A surface a plugin contributes may implement the optional `ChatSurface.markFresh(channel)` for the same job.
|
|
2300
2315
|
|
|
2301
2316
|
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,
|
|
2317
|
+
`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
2318
|
`ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
|
|
2304
2319
|
In a test, `fakeDiscord({ channelContext })` answers `DISCORD.channelContext` as the test says; by default with no context.
|
|
2305
2320
|
|
|
@@ -2543,8 +2558,13 @@ The web chat shows prompts to the conversation's person only: its owners are not
|
|
|
2543
2558
|
`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
2559
|
The confirmation gate and a coding worker's cards pass both.
|
|
2545
2560
|
`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
|
|
2561
|
+
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
2562
|
A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
|
|
2563
|
+
|
|
2564
|
+
`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.
|
|
2565
|
+
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).
|
|
2566
|
+
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.
|
|
2567
|
+
Because anyone who can see the card can copy the text, it holds nothing they may not read.
|
|
2548
2568
|
`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
2569
|
|
|
2550
2570
|
#### A prompt that outlives its turn: `PromptWait`
|
|
@@ -3225,6 +3245,7 @@ A plugin that contributes a `ChatSurface` can test it against the contract every
|
|
|
3225
3245
|
The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
|
|
3226
3246
|
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
3247
|
A check for a part the surface does not offer passes.
|
|
3248
|
+
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
3249
|
`checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
|
|
3229
3250
|
|
|
3230
3251
|
## What happens when the bot starts and stops
|
|
@@ -3540,6 +3561,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3540
3561
|
| `HeldCall` | `pi-roundtable` | type |
|
|
3541
3562
|
| `HoldCheck` | `pi-roundtable` | type |
|
|
3542
3563
|
| `HoldContext` | `pi-roundtable` | type |
|
|
3564
|
+
| `HoldDescription` | `pi-roundtable` | type |
|
|
3543
3565
|
| `HoldRefusal` | `pi-roundtable` | type |
|
|
3544
3566
|
| `HoldRule` | `pi-roundtable` | type |
|
|
3545
3567
|
| `HostEnv` | `pi-roundtable` | type |
|
|
@@ -3896,6 +3918,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
3896
3918
|
| `CHANNEL_OPERATIONS` | `pi-roundtable/discord` | value |
|
|
3897
3919
|
| `CHANNEL_TOOLS` | `pi-roundtable/discord` | value |
|
|
3898
3920
|
| `ChannelContext` | `pi-roundtable/discord` | type |
|
|
3921
|
+
| `ChannelContextAttachment` | `pi-roundtable/discord` | type |
|
|
3922
|
+
| `ChannelContextAttachmentOptions` | `pi-roundtable/discord` | type |
|
|
3899
3923
|
| `ChannelContextMessage` | `pi-roundtable/discord` | type |
|
|
3900
3924
|
| `ChannelContextOptions` | `pi-roundtable/discord` | type |
|
|
3901
3925
|
| `ChannelExecutor` | `pi-roundtable/discord` | type |
|
|
@@ -3940,3 +3964,4 @@ Import from the entries listed below; source area files are internal.
|
|
|
3940
3964
|
| `plain` | `pi-roundtable/discord` | value |
|
|
3941
3965
|
| `replyWithPanels` | `pi-roundtable/discord` | value |
|
|
3942
3966
|
| `withChannelContext` | `pi-roundtable/discord` | value |
|
|
3967
|
+
| `withChannelContextImages` | `pi-roundtable/discord` | value |
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 | "skip" =>
|
|
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,46 @@ 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
|
+
* `"skip"` is for a webhook that is neither: one the plugin posts its own status notices
|
|
34
|
+
* through. A post through it is dropped from the context and does not end the window, as if it
|
|
35
|
+
* were not in the channel; it is not another author's words and not the assistant's answer.
|
|
36
|
+
* Only `true` and `"skip"` change anything; any other answer reads as false.
|
|
37
|
+
*/
|
|
38
|
+
foreignWebhook?: (webhookId: string) => boolean | "skip";
|
|
39
|
+
/** Which attachments of the kept messages are fetched for the turn; none by default. */
|
|
40
|
+
attachments?: ChannelContextAttachmentOptions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `ChannelContextOptions.attachments` asks of the images in the kept messages. */
|
|
44
|
+
export interface ChannelContextAttachmentOptions {
|
|
45
|
+
/**
|
|
46
|
+
* How many images are fetched, at most, the newest first; default 0, none. At most 4, as many
|
|
47
|
+
* as a turn carries. A PNG, JPEG, GIF or WebP that cannot be fetched, is larger than
|
|
48
|
+
* `maxBytes`, or cannot be decoded is skipped and named in its message's text.
|
|
49
|
+
*/
|
|
50
|
+
images?: number;
|
|
51
|
+
/** The most bytes of one image fetched; default 10 MiB, at most 25 MiB. */
|
|
52
|
+
maxBytes?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A file attached to a channel message, as Discord describes it; its bytes are not fetched by this. */
|
|
56
|
+
export interface ChannelContextAttachment {
|
|
57
|
+
name: string;
|
|
58
|
+
/** Empty when Discord does not say. */
|
|
59
|
+
contentType: string;
|
|
60
|
+
/** Bytes, as Discord reports. */
|
|
61
|
+
size: number;
|
|
62
|
+
url: string;
|
|
63
|
+
/** The message it is attached to. */
|
|
64
|
+
messageId: string;
|
|
19
65
|
}
|
|
20
66
|
|
|
21
67
|
/** One channel message a turn reads around the message that addressed the assistant. */
|
|
@@ -31,6 +77,8 @@ export interface ChannelContextMessage {
|
|
|
31
77
|
/** The text, with mentions by name and stickers and files as text, cut where the options say. */
|
|
32
78
|
text: string;
|
|
33
79
|
at: Date;
|
|
80
|
+
/** The files attached to it, whether or not their bytes were fetched; always present on what the core returns. */
|
|
81
|
+
attachments?: readonly ChannelContextAttachment[];
|
|
34
82
|
}
|
|
35
83
|
|
|
36
84
|
/**
|
|
@@ -40,14 +88,51 @@ export interface ChannelContextMessage {
|
|
|
40
88
|
export interface ChannelContext {
|
|
41
89
|
/** Oldest first; never empty. */
|
|
42
90
|
readonly messages: readonly ChannelContextMessage[];
|
|
43
|
-
/** The delimited block `withChannelContext` appends to a turn's text. */
|
|
91
|
+
/** The delimited block `withChannelContext` appends to a turn's text; it says nothing of images, `withChannelContext` adds that. */
|
|
44
92
|
readonly text: string;
|
|
93
|
+
/**
|
|
94
|
+
* The images of the messages, newest first, as `TurnAttachments.images`: fetched for the call
|
|
95
|
+
* when `ChannelContextOptions.attachments.images` asked for some, prepared as a turn's own
|
|
96
|
+
* images are, and never stored or logged; absent or empty otherwise. Pass them to the turn with
|
|
97
|
+
* `withChannelContextImages`.
|
|
98
|
+
*/
|
|
99
|
+
readonly images?: readonly ModelImage[];
|
|
45
100
|
}
|
|
46
101
|
|
|
47
|
-
/**
|
|
102
|
+
/** What the turn is told when channel context images are among its images. */
|
|
103
|
+
const CONTEXT_IMAGES_NOTE =
|
|
104
|
+
"Pictures posted in the channel messages above are shown to you among this turn's images, the newest first.";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The turn's text, then its channel context when there is one, as `withReference` adds a replied-to
|
|
108
|
+
* message. `imagesKept` is how many of the context's images the turn carries, as
|
|
109
|
+
* `withChannelContextImages` reports; above zero the block says so, and it never does otherwise.
|
|
110
|
+
*/
|
|
48
111
|
export function withChannelContext(
|
|
49
112
|
text: string,
|
|
50
113
|
context: ChannelContext | undefined,
|
|
114
|
+
imagesKept = 0,
|
|
51
115
|
): string {
|
|
52
|
-
|
|
116
|
+
if (!context) return text;
|
|
117
|
+
return `${text}\n\n${context.text}${imagesKept > 0 ? `\n${CONTEXT_IMAGES_NOTE}` : ""}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The turn's attachments with the context's images after its own, at most as many images as a turn
|
|
122
|
+
* carries (4); the turn's own images come first and are never displaced, so a turn that already
|
|
123
|
+
* carries four takes none, and an image crowded out is not noted anywhere. `kept` is how many of
|
|
124
|
+
* the context's images made it, the newest first; give it to `withChannelContext` so the block says
|
|
125
|
+
* so only for images the turn has. The attachments are untouched without a context or images.
|
|
126
|
+
*/
|
|
127
|
+
export function withChannelContextImages(
|
|
128
|
+
attachments: TurnAttachments,
|
|
129
|
+
context: ChannelContext | undefined,
|
|
130
|
+
): { attachments: TurnAttachments; kept: number } {
|
|
131
|
+
const more = context?.images ?? [];
|
|
132
|
+
if (more.length === 0) return { attachments, kept: 0 };
|
|
133
|
+
const images = [...attachments.images, ...more].slice(0, MAX_TURN_IMAGES);
|
|
134
|
+
return {
|
|
135
|
+
attachments: { ...attachments, images },
|
|
136
|
+
kept: Math.max(0, images.length - attachments.images.length),
|
|
137
|
+
};
|
|
53
138
|
}
|
|
@@ -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
|
package/src/core/define.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
import type { ClientPort } from "./domain/client.ts";
|
|
7
7
|
import type { ReplyFile } from "./domain/conversation.ts";
|
|
8
8
|
import { PluginError } from "./errors.ts";
|
|
9
|
-
import { type HoldRule, isPromise } from "./holds.ts";
|
|
9
|
+
import { type HoldDescription, type HoldRule, isPromise } from "./holds.ts";
|
|
10
10
|
import { messages } from "./i18n/index.ts";
|
|
11
11
|
import type { ApprovedBy } from "./interactions/prompts.ts";
|
|
12
12
|
import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
|
|
@@ -110,11 +110,18 @@ export interface ToolSpec<Schema extends TObject> {
|
|
|
110
110
|
* A host that checks the hold with no turn, such as a precheck script's call, never calls a hold
|
|
111
111
|
* that declares `turn`; it sees the generic description. A hold that declares only `args` and
|
|
112
112
|
* answers at once is called there as it always was.
|
|
113
|
+
*
|
|
114
|
+
* Instead of the description alone it may answer `{ text, copy }`, where `copy` (`{ label?, text }`)
|
|
115
|
+
* is a text the owner's approval card offers to copy, such as the whole message the call would
|
|
116
|
+
* send; see `ApprovalDetails.copy` for what a surface does with it and its limits. It is the same
|
|
117
|
+
* answer as the description, so it shares its wait and its failure: a hold that rejects or
|
|
118
|
+
* outlasts the timeout holds the call with the generic description and no copy. A host with no
|
|
119
|
+
* card, or that checks the hold with no turn, uses `text` and ignores `copy`.
|
|
113
120
|
*/
|
|
114
121
|
hold?: (
|
|
115
122
|
args: Static<Schema>,
|
|
116
123
|
turn: ToolTurn,
|
|
117
|
-
) =>
|
|
124
|
+
) => HoldDescription | undefined | Promise<HoldDescription | undefined>;
|
|
118
125
|
/**
|
|
119
126
|
* The principal ids that may approve this call when it is held, instead of the speaker whose
|
|
120
127
|
* turn makes it, looked up as that turn like `hold` (same wait, same `turn.signal`, and it
|
|
@@ -253,9 +260,18 @@ function holdWithoutTurn<Schema extends TObject>(
|
|
|
253
260
|
const answer = (
|
|
254
261
|
hold as (
|
|
255
262
|
args: Static<Schema>,
|
|
256
|
-
) =>
|
|
263
|
+
) => HoldDescription | undefined | Promise<HoldDescription | undefined>
|
|
257
264
|
)(input as Static<Schema>);
|
|
258
|
-
if (!isPromise(answer))
|
|
265
|
+
if (!isPromise(answer)) {
|
|
266
|
+
if (answer === undefined || typeof answer === "string") return answer;
|
|
267
|
+
// An object without a non-empty text still holds the call, never lets it run.
|
|
268
|
+
return typeof answer === "object" &&
|
|
269
|
+
answer !== null &&
|
|
270
|
+
typeof answer.text === "string" &&
|
|
271
|
+
answer.text !== ""
|
|
272
|
+
? answer.text
|
|
273
|
+
: messages().holdGeneric(name);
|
|
274
|
+
}
|
|
259
275
|
// Its answer is not waited for here; a rejection must not surface on its own.
|
|
260
276
|
Promise.resolve(answer).catch(() => undefined);
|
|
261
277
|
} catch {
|