pi-roundtable 0.31.4 → 0.31.6

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,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.31.6] - 2026-11-02
9
+
10
+ ### Changed
11
+
12
+ - 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.
13
+
14
+ ## [0.31.5] - 2026-11-02
15
+
16
+ - 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.
17
+
8
18
  ## [0.31.4] - 2026-11-02
9
19
 
10
20
  - Release in lockstep with pi-roundtable-web 0.31.4 (a card asked in a dispatch thread or temp knight's room shows in the room above it, in the animated scene and the chat); 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 `botMessageChars` 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).
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 is unchanged: its window still starts after the assistant's last post and other bots' messages are still cut at `botMessageChars`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
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",
@@ -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 { fetch, keep, similarity, messageChars, botMessageChars } = settings;
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 = { fetch, keep, messageChars, botMessageChars };
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<Pick<ChannelContextSettings, "foreignWebhook" | "authors">>;
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
- { seen, owners, settings }: ChannelContextSelection,
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.