pi-roundtable 0.31.5 → 0.31.7
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 +10 -0
- package/README.md +1 -0
- package/README.zh-TW.md +1 -0
- package/docs/plugins.md +3 -3
- package/package.json +1 -1
- package/src/core/builtin/discord.ts +12 -0
- package/src/core/config/adapters.ts +1 -0
- package/src/core/contract/channel-context.ts +6 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/discord/channel-context.ts +47 -9
- package/src/core/host.ts +2 -0
- package/src/core/routing/peer-bots.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.31.7] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
- Release in lockstep with pi-roundtable-web 0.31.7 (menu rows hover in the quiet fill with 12 px padding wherever they are used; the message box draws no scrollbar below its tallest; a room row swiped right marks the room read); no core behavior changes.
|
|
11
|
+
|
|
12
|
+
## [0.31.6] - 2026-11-02
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Channel context cuts a peer bot's message at the new option `peerMessageChars` (default 4000, an integer from 1 to 4000 in `discord.channelContext`), not at `botMessageChars` (80): a bot `discord.peerBots` lists with an entry that covers the addressed message's channel and server. A host that talks with a peer read only the first 80 characters of what the peer had said to others in the channel, such as a report a person then asked the host about. Every other bot's message is still cut at `botMessageChars`. The sandbox package's `channelContext` option takes `peerMessageChars` with the same bound.
|
|
17
|
+
|
|
8
18
|
## [0.31.5] - 2026-11-02
|
|
9
19
|
|
|
10
20
|
- Release in lockstep with pi-roundtable-web 0.31.5 (a temp knight's cards show in the room it was made from; answered thread cards survive trimming; a room's cards in opening order; the waiting chip follows redrawn slots); no core behavior changes.
|
package/README.md
CHANGED
|
@@ -228,6 +228,7 @@ Tune it under `discord.channelContext`, or set `channelContext: false` to turn i
|
|
|
228
228
|
| `similarity` | `0.8` | How alike two consecutive messages of one author must be to count as one |
|
|
229
229
|
| `messageChars` | `500` | Where a message is cut |
|
|
230
230
|
| `botMessageChars` | `80` | Where another bot's message is cut |
|
|
231
|
+
| `peerMessageChars` | `4000` | Where a peer bot's message is cut (a bot `discord.peerBots` lists for the channel) |
|
|
231
232
|
| `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) |
|
|
232
233
|
| `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`: how many images of the kept messages are fetched for the turn, at most 4 |
|
|
233
234
|
|
package/README.zh-TW.md
CHANGED
|
@@ -223,6 +223,7 @@ routing: {
|
|
|
223
223
|
| `similarity` | `0.8` | 同一作者相鄰兩則訊息相似到什麼程度算同一則 |
|
|
224
224
|
| `messageChars` | `500` | 訊息截斷的長度 |
|
|
225
225
|
| `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
|
|
226
|
+
| `peerMessageChars` | `4000` | peer bot(`discord.peerBots` 為這個頻道列出的 bot)訊息截斷的長度 |
|
|
226
227
|
| `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是騎士自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
|
|
227
228
|
| `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
|
|
228
229
|
|
package/docs/plugins.md
CHANGED
|
@@ -2741,7 +2741,7 @@ It reads Discord, not the host's history, so it survives restarts:
|
|
|
2741
2741
|
- 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.
|
|
2742
2742
|
- 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.
|
|
2743
2743
|
- 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.
|
|
2744
|
-
- 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).
|
|
2744
|
+
- 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), and a peer bot's (one `discord.peerBots` lists for the channel, see below) at `peerMessageChars` (default 4000).
|
|
2745
2745
|
- 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.
|
|
2746
2746
|
|
|
2747
2747
|
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. `authors(message)` is the same decision for any surface and any message, not only a webhook's: it receives the fetched message (`ChannelContextSource`: `id`, `authorId`, `authorName`, `bot`, `own`, `webhookId?`, `external?`, `attachments?`, `at`, `text`) and answers `"keep"` (another author's, which does not end the window; `foreignWebhook` returning `true`), `"skip"` (dropped as if it were not in the channel, without ending the window; `"skip"` of `foreignWebhook`), `"end"` (the assistant's own, which ends the window and is left out; `foreignWebhook` returning `false` for an application webhook) or `undefined`, which leaves the default: the assistant's own posts end the window and everything else is kept. Where it answers, it wins over `foreignWebhook`; it is a function, so it can be set only in code, as a host default (`discord.channelContext.authors`) or per call. 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.
|
|
@@ -2753,7 +2753,7 @@ Every returned message has `attachments` (`ChannelContextAttachment`: `name`, `c
|
|
|
2753
2753
|
`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.
|
|
2754
2754
|
The configuration's values are the host's defaults, and a call's `options` override them for that call.
|
|
2755
2755
|
Call it once per answered message: what it returns is remembered as handed over.
|
|
2756
|
-
A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `
|
|
2756
|
+
A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars`, `botMessageChars` and `peerMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `authors` a function of a message returning `"keep"`, `"skip"`, `"end"` or `undefined`, `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).
|
|
2757
2757
|
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.
|
|
2758
2758
|
|
|
2759
2759
|
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.
|
|
@@ -2866,7 +2866,7 @@ discord: {
|
|
|
2866
2866
|
An id that is not a Discord id (17 to 20 digits) stops the start with a `ConfigError` that names its place in the list.
|
|
2867
2867
|
The router, not each claim, applies the list: for a message from a listed bot it sets `InboundMessage.peerBot` and resolves a `speaker` for it through the identity service as for a person, so the host's `access` rules still decide whether that bot is served and at which tier (`discord:<id>` under `access.members` or `access.admins`, or `everyone` and `provisioning: "admitted"`); a bot the rules do not serve gets no turn. A peer is served through its identity and `everyone` only: the Discord roles it holds are not read, so a role rule meant for people never lifts a bot. A webhook is never a peer, and a surface's own `peerBot` is dropped.
|
|
2868
2868
|
A channel answers at most `maxRounds` of the listed bots' messages in a row, counted when a claim takes the message; a person's message in the channel starts the count over, and the messages after the cap are ignored (logged at info).
|
|
2869
|
-
Channel context
|
|
2869
|
+
Channel context's window still starts after the assistant's last post. A listed peer's messages in a channel its entry covers are cut at `peerMessageChars` (default 4000), so the host reads what the peer said to others in full, such as a report it was asked about; every other bot's are still cut at `botMessageChars`.
|
|
2870
2870
|
|
|
2871
2871
|
When a person addresses two bots in one message, both would answer. An entry with `yields: true` makes the host leave such a message to that peer: a person's message (not in a direct conversation) that mentions or replies to the host and also mentions the peer or replies to its message, in a channel the entry covers, starts no turn here (logged at info as `message left to a peer bot`; observers still hear it). The peer answers alone and hands over by mentioning the host, which answers a listed peer as usual. Set it on one host only, and scope the entry with `guilds` or `channels` to where the peer actually answers; two hosts that yield to each other both stay silent. The router reads `InboundMessage.mentionedUserIds` (every user the message mentions) and `InboundMessage.reference.authorId` (who wrote the replied-to message), which the Discord surface sets.
|
|
2872
2872
|
|
package/package.json
CHANGED
|
@@ -24,6 +24,7 @@ import { restOf, restPoster } from "../discord/poster/rest-poster.ts";
|
|
|
24
24
|
import { stopButtonModule } from "../discord/stop-button.ts";
|
|
25
25
|
import type { InboundMessage } from "../domain/conversation.ts";
|
|
26
26
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
27
|
+
import { PeerBots, type PeerBotsConfig } from "../routing/peer-bots.ts";
|
|
27
28
|
import { IDENTITY, THREADS } from "../services.ts";
|
|
28
29
|
import type { DispatchThreads } from "../threads/dispatch-threads.ts";
|
|
29
30
|
|
|
@@ -77,6 +78,8 @@ export interface DiscordOptions {
|
|
|
77
78
|
refusalHint?: string;
|
|
78
79
|
/** The host's channel context settings; `false` turns it off. Default: on, with the defaults. */
|
|
79
80
|
channelContext?: ChannelContextOptions | false;
|
|
81
|
+
/** The host's peer bots (`discord.peerBots`): channel context cuts their messages at `peerMessageChars`. Default none. */
|
|
82
|
+
peerBots?: PeerBotsConfig;
|
|
80
83
|
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
|
|
81
84
|
freshMarker?: string | false;
|
|
82
85
|
/** What the model is told about how replies render in Discord; `false` tells it nothing. Default: Markdown renders, except tables. */
|
|
@@ -198,10 +201,19 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
198
201
|
...(identity ? { identity } : {}),
|
|
199
202
|
logger,
|
|
200
203
|
});
|
|
204
|
+
const peers = options.peerBots
|
|
205
|
+
? new PeerBots(options.peerBots, "discord", "discord.peerBots")
|
|
206
|
+
: undefined;
|
|
201
207
|
const context = new ChannelContextReader({
|
|
202
208
|
read: (channelId, before, limit) =>
|
|
203
209
|
connected.messagesBefore(channelId, before, limit),
|
|
204
210
|
owners: () => owners.ids(),
|
|
211
|
+
...(peers
|
|
212
|
+
? {
|
|
213
|
+
peer: (authorId: string, message: InboundMessage) =>
|
|
214
|
+
peers.listsBot(authorId, message),
|
|
215
|
+
}
|
|
216
|
+
: {}),
|
|
205
217
|
settings: options.channelContext ?? {},
|
|
206
218
|
logger,
|
|
207
219
|
});
|
|
@@ -166,6 +166,7 @@ const channelContextShape = shape({
|
|
|
166
166
|
similarity: optional(number(0, 1)),
|
|
167
167
|
messageChars: optional(integer(1, 4000)),
|
|
168
168
|
botMessageChars: optional(integer(1, 4000)),
|
|
169
|
+
peerMessageChars: optional(integer(1, 4000)),
|
|
169
170
|
foreignWebhook: optional(
|
|
170
171
|
guarded(
|
|
171
172
|
"a function of a webhook id",
|
|
@@ -23,6 +23,12 @@ export interface ChannelContextOptions {
|
|
|
23
23
|
messageChars?: number;
|
|
24
24
|
/** Where another bot's message is cut; default 80 characters. */
|
|
25
25
|
botMessageChars?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Where a message of a peer bot is cut: a bot the host lists in `discord.peerBots` whose entry
|
|
28
|
+
* covers the channel, since the host talks with it and reads what it says; default 4000
|
|
29
|
+
* characters. Any other bot's message is cut at `botMessageChars`.
|
|
30
|
+
*/
|
|
31
|
+
peerMessageChars?: number;
|
|
26
32
|
/**
|
|
27
33
|
* Whether a post through a webhook of the assistant's own application is another author's, not
|
|
28
34
|
* the assistant's. `webhookId` is the webhook's id; when this returns true the post is kept as a
|
|
@@ -158,6 +158,7 @@ function discordAssembly(
|
|
|
158
158
|
? {}
|
|
159
159
|
: { refusalHint: discord.refusalHint }),
|
|
160
160
|
channelContext: discord.channelContext,
|
|
161
|
+
...(discord.peerBots ? { peerBots: discord.peerBots } : {}),
|
|
161
162
|
...(discord.freshMarker === undefined
|
|
162
163
|
? {}
|
|
163
164
|
: { freshMarker: discord.freshMarker }),
|
|
@@ -36,6 +36,7 @@ export const CHANNEL_CONTEXT_DEFAULTS: Readonly<
|
|
|
36
36
|
similarity: 0.8,
|
|
37
37
|
messageChars: 500,
|
|
38
38
|
botMessageChars: 80,
|
|
39
|
+
peerMessageChars: 4_000,
|
|
39
40
|
foreignWebhook: () => false,
|
|
40
41
|
authors: () => undefined,
|
|
41
42
|
attachments: Object.freeze({ images: 0, maxBytes: 10 * 1024 * 1024 }),
|
|
@@ -63,9 +64,22 @@ function resolvedSettings(
|
|
|
63
64
|
|
|
64
65
|
/** Throws on a value outside what the configuration accepts: a programming error of the caller. */
|
|
65
66
|
function assertChannelContextSettings(settings: ChannelContextSettings): void {
|
|
66
|
-
const {
|
|
67
|
+
const {
|
|
68
|
+
fetch,
|
|
69
|
+
keep,
|
|
70
|
+
similarity,
|
|
71
|
+
messageChars,
|
|
72
|
+
botMessageChars,
|
|
73
|
+
peerMessageChars,
|
|
74
|
+
} = settings;
|
|
67
75
|
const { images, maxBytes } = settings.attachments;
|
|
68
|
-
const positive = {
|
|
76
|
+
const positive = {
|
|
77
|
+
fetch,
|
|
78
|
+
keep,
|
|
79
|
+
messageChars,
|
|
80
|
+
botMessageChars,
|
|
81
|
+
peerMessageChars,
|
|
82
|
+
};
|
|
69
83
|
for (const [name, value] of Object.entries(positive))
|
|
70
84
|
if (!Number.isInteger(value) || value < 1)
|
|
71
85
|
throw new RangeError(
|
|
@@ -217,12 +231,32 @@ export interface ChannelContextSelection {
|
|
|
217
231
|
seen: ReadonlySet<string>;
|
|
218
232
|
/** The owners' user ids. */
|
|
219
233
|
owners: readonly string[];
|
|
234
|
+
/** Whether a bot author is a peer the host talks with here, cut at `peerMessageChars`; without it none is. */
|
|
235
|
+
peer?: (authorId: string) => boolean;
|
|
220
236
|
/** What the selection reads of the settings; without `foreignWebhook` every webhook of the application is the assistant's. */
|
|
221
237
|
settings: Omit<
|
|
222
238
|
ChannelContextSettings,
|
|
223
|
-
"foreignWebhook" | "authors" | "attachments"
|
|
239
|
+
"foreignWebhook" | "authors" | "attachments" | "peerMessageChars"
|
|
224
240
|
> &
|
|
225
|
-
Partial<
|
|
241
|
+
Partial<
|
|
242
|
+
Pick<
|
|
243
|
+
ChannelContextSettings,
|
|
244
|
+
"foreignWebhook" | "authors" | "peerMessageChars"
|
|
245
|
+
>
|
|
246
|
+
>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Where a kept message is cut: a person's, a peer bot's, or another bot's limit. */
|
|
250
|
+
function limitOf(
|
|
251
|
+
message: ContextSourceMessage,
|
|
252
|
+
{ peer, settings }: ChannelContextSelection,
|
|
253
|
+
): number {
|
|
254
|
+
if (!message.bot) return settings.messageChars;
|
|
255
|
+
if (peer?.(message.authorId))
|
|
256
|
+
return (
|
|
257
|
+
settings.peerMessageChars ?? CHANNEL_CONTEXT_DEFAULTS.peerMessageChars
|
|
258
|
+
);
|
|
259
|
+
return settings.botMessageChars;
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
/**
|
|
@@ -232,8 +266,9 @@ export interface ChannelContextSelection {
|
|
|
232
266
|
*/
|
|
233
267
|
export function selectChannelContext(
|
|
234
268
|
fetched: readonly ContextSourceMessage[],
|
|
235
|
-
|
|
269
|
+
selection: ChannelContextSelection,
|
|
236
270
|
): { messages: ChannelContextMessage[]; window: string[] } {
|
|
271
|
+
const { seen, owners, settings } = selection;
|
|
237
272
|
// `authors` says whose a message is; without an answer, a webhook of the application that the
|
|
238
273
|
// host calls foreign (`foreignWebhook`) speaks for other people, one it calls "skip" speaks for
|
|
239
274
|
// no one, and the rest is the assistant's when it posted it. A skipped post is not in the channel
|
|
@@ -270,10 +305,7 @@ export function selectChannelContext(
|
|
|
270
305
|
)
|
|
271
306
|
.map((message) => ({
|
|
272
307
|
...message,
|
|
273
|
-
text: cut(
|
|
274
|
-
message.text,
|
|
275
|
-
message.bot ? settings.botMessageChars : settings.messageChars,
|
|
276
|
-
),
|
|
308
|
+
text: cut(message.text, limitOf(message, selection)),
|
|
277
309
|
}));
|
|
278
310
|
const kept = merged(fresh, settings.similarity, foreign).slice(
|
|
279
311
|
-settings.keep,
|
|
@@ -326,6 +358,8 @@ export interface ChannelContextReaderOptions {
|
|
|
326
358
|
): Promise<readonly ContextSourceMessage[] | undefined>;
|
|
327
359
|
/** The owners' Discord user ids. */
|
|
328
360
|
owners(): Promise<readonly string[]>;
|
|
361
|
+
/** Whether a bot is a peer the host lists for the addressed message's channel (`discord.peerBots`); without it no bot is. */
|
|
362
|
+
peer?: (authorId: string, message: InboundMessage) => boolean;
|
|
329
363
|
/** The host's settings; `false` turns channel context off for every claim. */
|
|
330
364
|
settings: ChannelContextOptions | false;
|
|
331
365
|
logger: Logger;
|
|
@@ -371,10 +405,14 @@ export class ChannelContextReader {
|
|
|
371
405
|
);
|
|
372
406
|
if (!fetched) return undefined;
|
|
373
407
|
const owners = await this.#owners();
|
|
408
|
+
const peer = this.#options.peer;
|
|
374
409
|
const { messages, window } = selectChannelContext(fetched, {
|
|
375
410
|
seen,
|
|
376
411
|
owners,
|
|
377
412
|
settings,
|
|
413
|
+
...(peer
|
|
414
|
+
? { peer: (authorId: string) => peer(authorId, message) }
|
|
415
|
+
: {}),
|
|
378
416
|
});
|
|
379
417
|
for (const id of window) seen.add(id);
|
|
380
418
|
this.#trim(seen);
|
package/src/core/host.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface RoundtableOptions {
|
|
|
54
54
|
/**
|
|
55
55
|
* The other bots on Discord whose mentions and replies are answered like a guest's (what
|
|
56
56
|
* `discord.peerBots` sets); without it no bot is. Stops the start for an id that is not a Discord id.
|
|
57
|
+
* A host composed by hand passes the same value to `discordPlugin` too, so channel context cuts
|
|
58
|
+
* the peers' messages at `peerMessageChars`; `defineRoundtable` does both.
|
|
57
59
|
*/
|
|
58
60
|
peerBots?: PeerBotsConfig;
|
|
59
61
|
/** The model the default judge asks, when no plugin provides a judge. */
|
|
@@ -106,6 +106,11 @@ export class PeerBots {
|
|
|
106
106
|
return this.#listsHere(message.authorId, message) !== undefined;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Whether the host lists this bot for the message's channel and server, whoever wrote the message. */
|
|
110
|
+
listsBot(botId: string, message: InboundMessage): boolean {
|
|
111
|
+
return this.#listsHere(botId, message) !== undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
/**
|
|
110
115
|
* The peer bot a person's message addressed to the host is left to: one the host lists here with
|
|
111
116
|
* `yields` that the message also mentions or replies to; undefined when the host answers it.
|