pi-roundtable 0.26.3 → 0.27.1

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +23 -0
  3. package/README.zh-TW.md +31 -8
  4. package/docs/plugins.md +176 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-book.ts +34 -1
  7. package/src/core/agents/agent-claim.ts +25 -5
  8. package/src/core/agents/agent-dashboard.ts +23 -2
  9. package/src/core/agents/agent-messages.ts +28 -10
  10. package/src/core/agents/agent-ports.ts +10 -0
  11. package/src/core/agents/agent-prompt.ts +17 -1
  12. package/src/core/agents/agent-rules.ts +11 -1
  13. package/src/core/agents/agent-schema.ts +22 -0
  14. package/src/core/agents/agent-statements.ts +16 -2
  15. package/src/core/agents/agent-store.ts +30 -20
  16. package/src/core/agents/agent-team-fake-runtime.ts +48 -0
  17. package/src/core/agents/agent-team-fixture.ts +32 -44
  18. package/src/core/agents/agent-team.ts +69 -74
  19. package/src/core/agents/agent-tool-schema.ts +84 -0
  20. package/src/core/agents/agent-tools.ts +50 -85
  21. package/src/core/agents/group-turns.ts +132 -41
  22. package/src/core/agents/helper-create.ts +180 -0
  23. package/src/core/agents/helper-ops.ts +33 -0
  24. package/src/core/agents/helper-test-kit.ts +72 -0
  25. package/src/core/agents/helper-tools.ts +104 -0
  26. package/src/core/agents/helper-work.ts +101 -0
  27. package/src/core/agents/message-target.ts +15 -23
  28. package/src/core/agents/routes.ts +347 -0
  29. package/src/core/agents/team-approval.ts +32 -0
  30. package/src/core/agents/team-editing.ts +97 -32
  31. package/src/core/agents/team-helpers.ts +216 -0
  32. package/src/core/agents/team-lifecycle.ts +26 -1
  33. package/src/core/agents/team-options.ts +8 -0
  34. package/src/core/agents/team-prompt.ts +75 -0
  35. package/src/core/agents/team-selection.ts +31 -13
  36. package/src/core/agents/team-status.ts +6 -0
  37. package/src/core/agents/team-text.ts +20 -4
  38. package/src/core/agents/team-turn-types.ts +12 -1
  39. package/src/core/agents/team-turns.ts +25 -31
  40. package/src/core/builtin/agent-server.ts +37 -2
  41. package/src/core/builtin/modules.ts +4 -4
  42. package/src/core/config/adapters.ts +12 -0
  43. package/src/core/config/agent-team.ts +2 -0
  44. package/src/core/config/config-schema.ts +4 -0
  45. package/src/core/config/config.ts +48 -2
  46. package/src/core/config/helpers.ts +60 -0
  47. package/src/core/config/ops.ts +8 -0
  48. package/src/core/config/routing.ts +296 -0
  49. package/src/core/contract/channels.ts +73 -2
  50. package/src/core/contract/surface.ts +31 -1
  51. package/src/core/define-roundtable.ts +8 -1
  52. package/src/core/discord/agent-discord.ts +67 -6
  53. package/src/core/discord/discord-surface.ts +24 -24
  54. package/src/core/discord/message-changes.ts +66 -0
  55. package/src/core/discord/poster/poster-checks.ts +62 -8
  56. package/src/core/discord/poster/poster.ts +41 -0
  57. package/src/core/discord/poster/rest-poster-read.ts +130 -0
  58. package/src/core/discord/poster/rest-poster.ts +38 -120
  59. package/src/core/domain/round.ts +31 -0
  60. package/src/core/host-registration.ts +15 -0
  61. package/src/core/host.ts +6 -0
  62. package/src/core/i18n/conversation.ts +3 -0
  63. package/src/core/i18n/dashboard.ts +4 -0
  64. package/src/core/ops/error-reporter.ts +4 -0
  65. package/src/core/ops/ops-alerts.ts +102 -6
  66. package/src/core/plugin.ts +17 -0
  67. package/src/core/registry/contributions.ts +1 -26
  68. package/src/core/registry/surface-service.ts +41 -0
  69. package/src/core/routing/channel-router.ts +54 -3
  70. package/src/core/routing/claim-events.ts +42 -0
  71. package/src/core/routing/message-observers.ts +86 -0
  72. package/src/core/routing/peer-bots.ts +149 -0
  73. package/src/core/routing/surface-port.ts +4 -0
  74. package/src/core/services.ts +7 -6
  75. package/src/core/testing/fake-poster-guild.ts +1 -1
  76. package/src/core/testing/fake-poster-reads.ts +96 -0
  77. package/src/core/testing/fake-poster.ts +62 -94
  78. package/src/core/tool-set.snapshot.json +8 -0
  79. package/src/discord/index.ts +2 -0
  80. package/src/index.ts +18 -1
  81. package/src/kit/channels.ts +1 -0
  82. package/src/kit/index.ts +5 -0
  83. package/src/kit/ops-alerts.ts +4 -0
  84. package/src/testing.ts +18 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.27.1] - 2026-10-31
9
+
10
+ ### Fixed
11
+
12
+ - A reply card Discord refuses (a channel that denies Attach Files) no longer loses the reply: the Discord surface logs a warning and sends the text alone. A card that posts still goes first, as its own message.
13
+ - The shell-policy fuzz runner caps each line at 30 CPU seconds per process (`ulimit -t`), so a busy loop a line leaves behind ends even when the test runner is killed before it can kill the line's process group.
14
+
15
+ ## [0.27.0] - 2026-10-31
16
+
17
+ ### Added
18
+
19
+ - Temp knights: a knight can make helper agents for one self-contained task with `helper_create` (`display_name`, `prompt`, `task`, and optional `name`, `class`, `color`, `model`, `thinking`, `skills`) and clear them with `helper_end` (`names`, or `"finished"`). A temp knight is a real agent with its own prompt, picture and sessions, listed under its knight; it starts at once and its answer returns to the knight's conversation as a new turn. It runs its knight's model unless told otherwise, takes no group seat, and has no agent, group, archive, channel or schedule tools. Both tools are on the `member` tier and are offered to every agent that is not itself a temp knight. Whether a temp knight is *finished* is derived (nothing runs, queued, held for the owner or unanswered), never set by the knight.
20
+ - Temp knight limits: `helper_create` calls run one at a time per host, so the caps hold and unnamed calls get distinct names; a create that fails closes its thread. A temp knight never gets more than its knight unless the speaker's tier allows `agent_skills` or `agent_update` (skills a subset of its knight's; its knight's model and thinking). Its prompt is framed as a task brief, not an owner instruction. Only the conversation that made it, or an owner, may message it (`Agent.origin`, migration `agents-helper-origin`). Group seats are not offered the tools, and temp knights marked working are stamped finished at the team's start.
21
+ - `helpers` configuration: `{ perParent, perHost, archiveFinishedAfterHours }` (defaults 4, 12 and 24; `false` offers neither tool). At a limit the knight's oldest finished temp knight is archived to make room, and the call is refused only when every one is busy; depth is one. A value out of range, or a `perParent` above `perHost`, stops the start with a `ConfigError`. A finished temp knight is archived after `archiveFinishedAfterHours` by an hourly sweep, and an archived one keeps its history and comes back with `restoreHelpers`.
22
+ - `AgentTeam.helpersOf`, `finishedHelpers`, `archiveHelpers`, `restoreHelpers` and `onHelpersEnded` (types `HelperTeam`, `HelpersConfig`, `HelpersEnded`), and `isHelper` (`isHelper(agent)`) for a surface that lists agents. `Agent` gains `parent` and `finishedAt`; the migration `agents-helpers` adds two nullable columns and an index to the agents table.
23
+ - On Discord a temp knight is a thread under its knight's channel, posting through the channel's webhook under its own name and picture; archiving it archives and locks the thread, restoring it unarchives the thread. A knight with no Discord channel gives it a room on a surface that hosts rooms. Its routing rules are its knight's. The dashboard counts them.
24
+ - `pi-roundtable-web`: temp knights hang under their knight in the room list with their state (working, waiting for the owner, ended), follow its pin and folder, and take no round table seat or group. The room list's menu, a folder's and a knight's get **Clear finished temp knights** (`POST api/rooms/archive-finished-helpers`, `POST api/rooms/restore-helpers`, owner only), which archives at once and offers Undo; a temp knight's own menu archives it the same way. When a knight clears its own, its room gets a line naming them with Undo. The archive page lists each under its knight. See the web package changelog.
25
+ - Peer bots: `discord.peerBots` (also the host option `peerBots`, and `testPlugin({ peerBots })`) lists other bots by user id whose mention or reply the host answers like a guest's, so separate bots can talk to each other. Explicit ids only: there is no wildcard and no "all bots", an id that is not a Discord id stops the start with a `ConfigError` naming its place, and leaving it out keeps today's behaviour, where every bot's message is ignored. An entry can be limited to servers (`guilds`) or channels (`channels`). The router sets the new `InboundMessage.peerBot` on a listed bot's message and resolves its `speaker` through the identity service as for a person, so `access` still decides whether the bot is served and at which tier; webhooks are never peers and a surface's own `peerBot` is dropped. A channel answers at most `maxRounds` (default 3) of their messages in a row, counted when a claim takes one; a person's message restarts the count and the messages after the cap are ignored. The agent server answers a peer that mentions the agent or replies to it. A claim of your own uses the new `answersAuthor` (`answersAuthor(message)`, from `pi-roundtable/kit`) where it checked `!message.authorIsBot`; the types `PeerBotsConfig` and `PeerBotEntry` are in the main entry. Replies already allow user mentions, so a `<@id>` of a peer pings it (now covered by a test). Channel context is unchanged. See [the plugin guide](docs/plugins.md#peer-bots-answering-other-bots).
26
+ - `routing`: one declared table for where work lands that no person is waiting on, instead of a Discord-first choice written in each feature. The optional config block `routing: { agents, places, surfaces }` sets, per agent (`"*"` is every agent's default), its `primary` place (`auto`, or a surface name), where a foreign webhook's report turn runs (`integration`: `origin`, `primary` or a surface), where `message_agent` goes when the call names no conversation (`agentMessage`: `latest`, `primary` or a surface), and where an opening task runs (`opening`: `origin-surface` or `primary`). `routing.places.ops` names where the process's error reports go: a conversation key, or `{ agent }` for that agent's primary place, which needs the agent server and no longer Discord. Leave `routing` out and everything lands where it did before. A message, a card, a schedule's firing place and a message exchange's answer back are never routed: they stay where their person is. A webhook report moved away from where it was posted says so in the turn text (`Webhook "X" posted in your discord place (discord:…)`), the raw post stays, and a destination that has vanished sends the report to where it was posted with a warning, never dropping it.
27
+ - `AGENTS.routes` (`RoutingTable`: `primary`, `placeOn`, `of`, `place`) reads the table from code; the agent team's turns, `agent_create`, `schedule_list`, the error reporter and the webhook claim all ask it, replacing the three copies of "Discord first". `SurfacePort.names()` lists the registered surfaces. `RoutingConfig`, `AgentRoutes`, `SurfaceRouting`, `PlaceTarget`, `RoutingTable` and `RouteKind` are exported.
28
+ - `routing` mistakes stop the start with a `ConfigError` naming the key and the fix: a surface nobody registered or that holds no agent places (checked once every plugin has registered its surfaces, listing the registered ones), a value of another key (`latest` for `integration`), a bad agent key or place name, a place that is not a conversation key or `{ agent }`, `ops` and `routing.places.ops` together, and routing on a host with no agent server. An agent the rules name before it exists is only a warning. `mirror`, `routing.surfaces.<name>.answeredBy` and `agentPlaces: "none"` are defined positions that are not built yet and stop the start when set.
29
+ - `ChatSurface.groupRound(channel, round)` (and `SurfacePort.groupRound`): a surface that hosts group rooms is told where a group round stands, with the new `RoundProgress` and `RoundEnd` types: the round is planned, a member's turn starts and ends, a handoff is appended, and the round ends as `done`, `limit` or `stopped`. Discord does not implement it and is unchanged.
30
+ - Discord cards can carry pictures and be posted by a webhook (generic poster API, for any plugin). `DiscordCard.thumbnail?: { url, description? }` makes the heading a Components V2 Section with a Thumbnail accessory, and `DiscordCard.image?: { url, description? }` adds a Media Gallery of one picture under the text sections and above the quiet last line; both take an `https` address (at most 2,048 characters) and an alt text of 1 to 1,024 characters, else `invalid`, and `editCard` keeps them. `DiscordPoster.postCard(channelId, { webhook, username, avatarUrl?, card })` posts a card through the bot's own webhook of that name with `with_components=true`, so the webhook's name and picture show, the colour bar stays and its buttons reach the Discord plugin's interactions; `editWebhookCard(channelId, messageId, card, webhooks)` edits it (`message_gone` when no named webhook posted it, `rejected` for a message that is not a card). A card without the new fields is sent byte for byte as before; the webhook token is never logged. The fake poster and the emulated Discord do the same (the emulator drops a webhook post's components unless `with_components=true`). `CardPicture` and `WebhookCardPost` are exported from `pi-roundtable/discord`. Core posts no card of its own, so there is no default thumbnail to set. See [the plugin guide](docs/plugins.md#posting-through-discordposter).
31
+ - `OpsAlerts.resolve(key)` says a fault is over: it gives the key back, calls the optional `sink.resolved({ kind, key })` (`OpsResolution`), and `faults()` shows `clearedAt` on the kind once none of its alerts is open. It is idempotent, and resolving a key that was never posted does nothing.
32
+ - `ChannelClaim.onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`: the claim that owns a channel hears of messages deleted or edited there. A surface reports them through the new second argument of `ChatSurface.start`, `SurfaceMessageEvents` (`{ deleted, edited }`); the Discord surface does, including for messages it had not cached, and a surface that cannot tell never calls them. They are delivered outside the channel's queue, errors are logged and isolated, the bot's own edits and edits that change no text, embed or attachment are not reported, and a delete is reported for the bot's own messages too, since Discord does not say whose it was.
33
+ - `PluginContext.observe(filter, observer)` hears every inbound message that reaches routing in a channel some claim owns, whichever claim it is, without admitting, blocking or delaying it. The filter narrows by `surface`, `space` and `channels`; direct messages are heard only with `includeDirect: true`; it hears people, listed peer bots and integrations (never an unlisted bot) and carries no `speaker`. Each observer gets its own frozen copy on a later tick, and its errors are logged by name and isolated. `ObserveFilter`, `MessageObserver` and `SurfaceMessageEvents` are exported.
34
+
35
+ ### Changed
36
+
37
+ - Stop ends a group's whole round, on Discord and in the web app alike: the member speaking is stopped and the members still waiting do not speak (it used to end only the running member's turn, and the rest of the round went on). A group round now shows the stop control for the round, and a stopped round with members still waiting posts a short notice. `AgentClaim` stops a group round through the team's new `stopRound`.
38
+ - A group holds 2 to 8 members (it was 2 to 6): `group_create`, `group_update`, the stores and the group editor of `pi-roundtable-web` all take the new range. A round still holds at most 8 replies. No stored group exceeds the old range, so none needs trimming.
39
+ - `pi-roundtable-web`: the round table is a council, one agent group at the podiums; mute only stops push and no longer decides who sits; the table seats at most 8 and shows no "+N" podium; the owners' table no longer seats host rooms. See that package's changelog.
40
+
41
+ - `OpsAlerts` sinks receive structured information: `sink(text, { kind, key, code?, info?, severity? })` (`OpsAlert`), so a host can choose a picture or a route per kind (`OpsAlert`, and the sink's type `OpsSink`). `raise` takes a new `severity` (`OpsSeverity`: `"info"`, `"warning"` or `"critical"`) that only the sink reads. A sink that takes only the text works unchanged.
42
+
43
+ - `InboundMessage.speaker` is resolved for a listed peer bot too (see Added: peer bots). A third-party claim that checks only `speaker` therefore answers a listed peer without asking for anything more; check `answersAuthor(message)` (or `peerBot`) to tell a person from a peer. The `speaker` doc says so.
44
+ - A listed peer bot is served through its identity and `everyone` in `access`, never through the Discord roles it holds, so a role rule meant for people cannot lift a bot to member or admin. Give a peer a `discord:<id>` entry in `access.members` or `access.admins` to serve it.
45
+ - `context.observe` leaves out the rooms of a surface that hosts agent rooms (`agentRooms: true`) unless the filter names that surface in `surface`, since those rooms are one person's or one team's; guild messages of other surfaces are heard as before. The observer's copy has neither `speaker` nor `peerBot` even if a surface set one, and a message that cannot be copied is dropped for the observers instead of throwing into routing.
46
+
47
+ ### Deprecated
48
+
49
+ - The top-level `ops` is an alias of `routing.places.ops` and logs a deprecation at start; it goes away in 1.0. Setting both stops the start. Its error messages name `routing.places.ops` when that is the key in use.
50
+
51
+ ### Fixed
52
+
53
+ - `pi-roundtable-web`: the owner app's small visual faults from the 0.26.2 pass. A popover opened near the window's right edge (the ⋯ menu on a finished thread card) is placed by its own width and keeps the edge's 8px margin, where it used to be measured while squeezed against the edge and sat about 4px from it. A thread card's time moved to its status row, so a long title keeps the whole row above it and no longer truncates beside "1 minute ago" on a phone. A thread named inside a message (the "Started …" line a dispatch posts) is a plain inline link again: it had taken the thread card's block layout, which cut the sentence into fragments in the round table's dialogue box; the box also keeps the newest words of a long line in view while it is typed out, until the person scrolls up. At phone widths every text input, textarea and select is 16px or larger, including the controls inside a label and the full-screen prompt and SKILL.md editor, so iOS no longer zooms in on focus; a test reads the page's styles and fails on any rule that sets a text control smaller.
54
+ - The zh-TW site docs use the same word for an agent as the web app does ("knight", the main agent "main knight"), where they said "assistant" or "partner".
55
+
8
56
  ## [0.26.3] - 2026-10-30
9
57
 
10
58
  ### Added
package/README.md CHANGED
@@ -128,6 +128,7 @@ test("hello greets", async () => {
128
128
 
129
129
  The [plugin guide](docs/plugins.md) explains every part a plugin can add (tools, prompt sections, agents, events, services, migrations, providers, slash commands, HTTP routes, and more), the order things start and stop in, and every startup error with its fix.
130
130
  Its examples live in [`examples/`](examples), and the test suite runs each of them.
131
+ A plugin can also listen without owning a channel: `context.observe` hears guild messages whichever claim owns them, a claim hears of deleted and edited messages, and `OpsAlerts` can tell a sink which kind an alert is and when its fault is over.
131
132
  `pi-roundtable/kit` supplies claim, tool and presentation helpers and type-only names for the context’s existing services.
132
133
  `pi-roundtable/discord` supplies the slash-command registrar, owner-command and panel helpers, and the agent panel.
133
134
  It is the entry that names discord.js types (`pi-roundtable/testing` names a few, through `testHost`'s composed commands).
@@ -199,6 +200,21 @@ Without `discord` the agents still run when `agents` lists them, in the rooms of
199
200
  | `agents` | none | The first team, created once. Without `discord`, a seed has no `channelId` and the team's rows are kept under the id `local` |
200
201
  | `coordinator` | the first of `agents` | Without `discord`, the agent the owner's general requests come to; it cannot be archived. With `discord` it is an error: the entry channel's agent coordinates |
201
202
  | `skills` | on with `agents` | The skills addon; without `discord` it is on only when `agents` lists some |
203
+ | `helpers` | 4 per knight, 12 in all, archived 24 hours after finishing | Temp knights, the helpers a knight makes with `helper_create` for one task and clears with `helper_end`: `{ perParent, perHost, archiveFinishedAfterHours }`; `false` offers neither tool |
204
+
205
+ Work no person is waiting on (a foreign webhook's post in an agent's channel, a `message_agent` call that names no conversation, an agent's opening task, the error reports) lands where it always did, unless the optional `routing` block says otherwise.
206
+ It names each agent's primary place, which surface each kind of event goes to, and the host's named places such as `ops`; a message, a card or a schedule's firing is never routed and stays where its person is.
207
+ For example, Infra's alerts handled in the web app while Discord keeps the raw posts:
208
+
209
+ ```ts
210
+ routing: {
211
+ agents: { infra: { primary: "app", integration: "primary" } },
212
+ places: { ops: { agent: "infra" } }, // replaces the deprecated top-level `ops`
213
+ },
214
+ ```
215
+
216
+ A mistake stops the start naming the key and the fix.
217
+ The [settings page](https://pi-roundtable.wayneh.tw/configure/settings/#where-work-lands-routing) lists every key, its values and the checks.
202
218
 
203
219
  When someone addresses the assistant in an agent's channel, the turn also reads the channel messages posted since the assistant's last post there (channel context; see the [plugin guide](docs/plugins.md#channel-context-what-was-said-around-an-addressed-message)).
204
220
  Tune it under `discord.channelContext`, or set `channelContext: false` to turn it off:
@@ -213,6 +229,13 @@ Tune it under `discord.channelContext`, or set `channelContext: false` to turn i
213
229
  | `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) |
214
230
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`: how many images of the kept messages are fetched for the turn, at most 4 |
215
231
 
232
+ By default the assistant ignores every other bot. To let separate bots talk, list the ones to answer by user id under `discord.peerBots`:
233
+
234
+ | `discord.peerBots` | Default | What it is |
235
+ | --- | --- | --- |
236
+ | `allow` | none | Bot user ids, each as a string or `{ id, guilds?, channels? }` to limit it to servers or channels. Explicit ids only: there is no wildcard and no "all bots", and a bad id stops the start. A listed bot's mention or reply is answered like a guest's, as far as `access` serves it |
237
+ | `maxRounds` | `3` | Messages from listed bots answered in a row in one channel; a person's message starts the count over |
238
+
216
239
  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.
217
240
 
218
241
  | `discord.freshMarker` | Default | What it is |
package/README.zh-TW.md CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
8
  一個建立在 [Pi](https://github.com/earendil-works/pi) 上的智慧體(agent)伺服器,可以透過 Discord 或網頁聊天跟它對話。
9
9
  在 Discord 裡,每個 AI 智慧體在你的伺服器有自己的頻道和對話,共用工具與記憶。
10
- 在網頁上,經你的 OpenID Connect 供應商登入的人,透過 [pi-roundtable-webchat](packages/webchat) 在各自的私人對話裡跟助理聊天。
10
+ 在網頁上,經你的 OpenID Connect 供應商登入的人,透過 [pi-roundtable-webchat](packages/webchat) 在各自的私人對話裡跟騎士聊天。
11
11
  想加功能,就用 TypeScript 寫外掛(plugin)。
12
12
 
13
- - 為一位擁有者設計。Discord 和網頁聊天都能用,也可以讓其他人跟助理對話,但開放給多人使用的風險由營運者自行承擔:請先讀[威脅模型](#威脅模型)。
13
+ - 為一位擁有者設計。Discord 和網頁聊天都能用,也可以讓其他人跟騎士對話,但開放給多人使用的風險由營運者自行承擔:請先讀[威脅模型](#威脅模型)。
14
14
  - 只支援 Bun。套件直接發佈 TypeScript 原始碼,不需要建置步驟。
15
15
  - MIT 授權。
16
16
 
@@ -122,6 +122,7 @@ test("hello greets", async () => {
122
122
  });
123
123
  ```
124
124
 
125
+ 外掛也可以只聽、不擁有頻道:`context.observe` 會聽到伺服器頻道的訊息,不論由哪個 claim 擁有;claim 會得知訊息被刪除或編輯;`OpsAlerts` 能告訴 sink 告警的類型,以及故障何時結束。
125
126
  [外掛指南](docs/plugins.md)(英文)說明外掛能新增的每個部分(工具、提示詞區段、智慧體、事件、服務、migration、provider、斜線指令、HTTP 路由等等)、啟動與停止的順序,以及每一種啟動錯誤和它的修正方式。
126
127
  指南裡的範例放在 [`examples/`](examples),測試套件會執行每一個範例。
127
128
  `pi-roundtable/kit` 提供頻道認領(claim)、工具與呈現用的輔助函式,以及 context 現有服務的純型別名稱。
@@ -132,7 +133,7 @@ test("hello greets", async () => {
132
133
 
133
134
  套件 [pi-roundtable-webchat](packages/webchat) 是以外掛形式提供的聊天管道:在主機的 listener 上的 `/chat` 底下提供 WebSocket 和 REST API。
134
135
  經你的 OpenID Connect 供應商登入的人,可以用你列出的人格(persona)開私人對話,在每一輪執行時看到它寫的文字和用的工具,也能回答暫緩呼叫的核准卡。
135
- 設定裡沒有 `discord`、`plugins` 列了 `webChat({ ... })` 的主機,就是只有網頁的助理;`roundtable init --adapter web` 會建立這種專案。
136
+ 設定裡沒有 `discord`、`plugins` 列了 `webChat({ ... })` 的主機,就是只有網頁的騎士;`roundtable init --adapter web` 會建立這種專案。
136
137
 
137
138
  ```sh
138
139
  npx pi-roundtable init my-desk --adapter web
@@ -194,8 +195,23 @@ export default {
194
195
  | `agents` | 無 | 第一批團隊,只建立一次。沒有 `discord` 時,種子沒有 `channelId`,團隊的資料列記在 id `local` 之下 |
195
196
  | `coordinator` | `agents` 的第一位 | 沒有 `discord` 時,接手主人一般請求的 agent,不能被封存。有 `discord` 時設它會出錯:由入口頻道的 agent 協調 |
196
197
  | `skills` | 有 `agents` 時開啟 | 技能 addon;沒有 `discord` 時,只有 `agents` 列了 agent 才會開 |
198
+ | `helpers` | 每位騎士 4 位、全站 12 位,結束 24 小時後封存 | 臨時騎士:騎士用 `helper_create` 為單一任務做出、完成後用 `helper_end` 清掉的幫手。設定為 `{ perParent, perHost, archiveFinishedAfterHours }`;`false` 兩個工具都不提供 |
197
199
 
198
- 有人在 agent 的頻道對助理說話時,那一輪也會讀進助理上次在該頻道發言之後的頻道訊息(頻道脈絡;見[外掛指南](docs/plugins.md#channel-context-what-was-said-around-an-addressed-message))。
200
+ 沒有人在等的工作(外部 webhook 在 agent 頻道的貼文、沒指定對話的 `message_agent` 呼叫、agent 的開場任務、錯誤回報)照舊落在原本的地方,除非選填的 `routing` 區塊另有指定。
201
+ 它指定每個 agent 的 primary place、每種事件送到哪個 surface,以及 host 的具名 place(例如 `ops`);訊息、卡片、排程的觸發不會被改道,一律留在當事人所在的地方。
202
+ 例如讓 Infra 的警報在網頁 app 處理,Discord 保留原始貼文:
203
+
204
+ ```ts
205
+ routing: {
206
+ agents: { infra: { primary: "app", integration: "primary" } },
207
+ places: { ops: { agent: "infra" } }, // 取代已棄用的最上層 `ops`
208
+ },
209
+ ```
210
+
211
+ 寫錯會讓啟動中止,並指出鍵與修法。
212
+ 每個鍵、它的值與檢查項目見[設定頁](https://pi-roundtable.wayneh.tw/zh-tw/configure/settings/)。
213
+
214
+ 有人在 agent 的頻道對騎士說話時,那一輪也會讀進騎士上次在該頻道發言之後的頻道訊息(頻道脈絡;見[外掛指南](docs/plugins.md#channel-context-what-was-said-around-an-addressed-message))。
199
215
  用 `discord.channelContext` 調整,或設 `channelContext: false` 關閉:
200
216
 
201
217
  | `discord.channelContext` | 預設 | 說明 |
@@ -205,10 +221,17 @@ export default {
205
221
  | `similarity` | `0.8` | 同一作者相鄰兩則訊息相似到什麼程度算同一則 |
206
222
  | `messageChars` | `500` | 訊息截斷的長度 |
207
223
  | `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
208
- | `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是助理自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
224
+ | `foreignWebhook` | 無 | `(webhookId) => boolean \| "skip"`:回傳 true 的 webhook 所發的貼文視為別人的訊息,不是騎士自己的;回傳 `"skip"` 則把該 webhook 的貼文當作不存在,不納入也不截斷範圍(只能用程式碼設定) |
209
225
  | `attachments` | `{ images: 0, maxBytes: 10 MiB }` | `images`:替這一輪抓取保留訊息中的幾張圖片,最多 4 張 |
210
226
 
211
- 在伺服器頻道或討論串裡重新開始對話時,助理會在那裡貼一條簡短的分隔線,讓下一輪不讀之前的內容;私訊不貼。
227
+ 預設騎士不理會其他 bot。要讓不同的 bot 互相對話,在 `discord.peerBots` 用使用者 id 列出要回應的 bot:
228
+
229
+ | `discord.peerBots` | 預設 | 說明 |
230
+ | --- | --- | --- |
231
+ | `allow` | 無 | bot 的使用者 id,可寫成字串,或寫成 `{ id, guilds?, channels? }` 限定伺服器或頻道。只接受明確的 id:沒有萬用字元,也沒有「所有 bot」,id 不對會讓啟動失敗。名單內 bot 的提及或回覆,會像訪客一樣被回應,能用到什麼程度由 `access` 決定 |
232
+ | `maxRounds` | `3` | 同一頻道連續回應名單內 bot 訊息的次數;有人說話就重新計算 |
233
+
234
+ 在伺服器頻道或討論串裡重新開始對話時,騎士會在那裡貼一條簡短的分隔線,讓下一輪不讀之前的內容;私訊不貼。
212
235
 
213
236
  | `discord.freshMarker` | 預設 | 說明 |
214
237
  | --- | --- | --- |
@@ -230,14 +253,14 @@ agent 頻道裡訊息的附件,整則訊息(連同它回覆的那則)要
230
253
 
231
254
  ## 威脅模型
232
255
 
233
- pi-roundtable 為一位擁有者執行一個助理。
256
+ pi-roundtable 為一位擁有者執行騎士。
234
257
  0.9 加入以 principal 為範圍的記憶和私人對話隔離,但模型憑證與主機執行權限仍然共用,還不是完整的多使用者服務;開放給別人使用的風險仍由營運者承擔。
235
258
 
236
259
  - **信任誰。** 營運者掌控主機、設定和憑證。principal 代表主機服務的人,identity 是連到這個人的帳號或憑證。擁有者只能由 `access.owners` 或 `roundtable principal` 授予,不能來自 token claim。其他人的 `member` 或 `admin` tier 由核心 `access` 依驗證過的身分與帶 surface 前綴的角色判斷;外掛不能自行指定發言者的權限。
237
260
  - **一輪能做什麼。** 一輪只拿得到發言者的 tier 允許的工具;沒有外掛給 tier 的工具只有擁有者能用。工具在主機的行程裡執行,用的是主機的檔案和網路:`web_search` 和 `fetch_content` 連得到內部位址,所以網頁人格要在 `selection` 裡寫明它的工具。外掛也在同一個行程裡執行,權限一樣。
238
261
  - **核准。** 暫緩的呼叫只由當輪暫緩它的發言者核准,而且當下 tier 仍要足夠;在共用對話裡,擁有者也可以在卡片上或用確認訊息核准。私人對話裡別人都不能核准,高於發言者 tier 的呼叫在那裡也不會被核准。
239
262
  - **對話。** Discord 裡智慧體的頻道和群組房間是共用的:在那裡發言的每個人,都會寫進智慧體讀取的同一段對話。網頁聊天的對話屬於開啟它的人:在網頁聊天裡只有開啟者本人能讀、能發言、能回答它的提問,但擁有者可以透過擁有者主控台([pi-roundtable-web](packages/web))讀取,營運者也能從資料庫和資料目錄讀到。
240
- - **記憶隔離。** 記憶依 principal 分開,不依擁有者 tier 判斷。私人對話讀本人的,共用對話讀當輪發言者的;連結 Discord 與網頁身分後,同一個人共用一份記憶。在共用對話送給模型的請求和摘要裡,記憶工具與載入記憶的 worker 的呼叫(參數和結果成對)會對其他發言者隱藏,先前回合的 reasoning 則依 provider 清除。其他工具的參數和公開的助理回覆仍是公開內容,即使模型在裡面重述私人記憶;這不是一般的秘密流向防護。第三方摘要器必須先使用 `privateCompaction`,才能摘要共用歷史。`claude-bridge` 的共用回合只在原始讀者紀錄(含已壓縮的回合)顯示另一位讀者的私人記憶(連只放進 prompt 的記憶與其 reasoning 也算),或某個呼叫已對當前讀者隱藏時才被拒絕。沒有私人記憶的回合不會擋其他讀者,關掉之後的記憶也繞不過先前的私人歷史;開放其他人進來只會在開機和 doctor 裡警告。SYSTEM 只在這項核心防護裡算作主要擁有者,所以它能讀取並公開重述擁有者保留的呼叫與 reasoning;0.9 之前、第一筆範圍紀錄之前未標記的內建記憶結果,仍歸屬擁有者。沙盒的全場共用 prompt 區塊必須明確使用 `memory.visibility: "shared"`,私人回合需要 worker 能力。Bridge 會在這些投影之外重播未過濾的歷史;見[確切規則與剩餘風險](docs/migration-0.9.md#claude-bridge-guard)。
263
+ - **記憶隔離。** 記憶依 principal 分開,不依擁有者 tier 判斷。私人對話讀本人的,共用對話讀當輪發言者的;連結 Discord 與網頁身分後,同一個人共用一份記憶。在共用對話送給模型的請求和摘要裡,記憶工具與載入記憶的 worker 的呼叫(參數和結果成對)會對其他發言者隱藏,先前回合的 reasoning 則依 provider 清除。其他工具的參數和公開的騎士回覆仍是公開內容,即使模型在裡面重述私人記憶;這不是一般的秘密流向防護。第三方摘要器必須先使用 `privateCompaction`,才能摘要共用歷史。`claude-bridge` 的共用回合只在原始讀者紀錄(含已壓縮的回合)顯示另一位讀者的私人記憶(連只放進 prompt 的記憶與其 reasoning 也算),或某個呼叫已對當前讀者隱藏時才被拒絕。沒有私人記憶的回合不會擋其他讀者,關掉之後的記憶也繞不過先前的私人歷史;開放其他人進來只會在開機和 doctor 裡警告。SYSTEM 只在這項核心防護裡算作主要擁有者,所以它能讀取並公開重述擁有者保留的呼叫與 reasoning;0.9 之前、第一筆範圍紀錄之前未標記的內建記憶結果,仍歸屬擁有者。沙盒的全場共用 prompt 區塊必須明確使用 `memory.visibility: "shared"`,私人回合需要 worker 能力。Bridge 會在這些投影之外重播未過濾的歷史;見[確切規則與剩餘風險](docs/migration-0.9.md#claude-bridge-guard)。
241
264
  - **模型憑證。** 每一輪不論誰發言,都用主機的模型登入執行、由它計費。每個人自己的憑證預計在之後的版本加入。
242
265
  - **資料。** 對話、記憶和排程以未加密的形式存在 PostgreSQL 和資料目錄裡。
243
266
 
package/docs/plugins.md CHANGED
@@ -181,6 +181,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
181
181
  | `attachments` | Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation until a turn uses them: [`attachments`](#contextattachments-files-from-outside-a-turn) |
182
182
  | `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
183
183
  | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
184
+ | `observe(filter, observer)` | Hears inbound messages without owning or admitting them: [`observe`](#observe-hearing-messages-without-owning-them). Returns a function that stops it; may be called during `setup` |
184
185
  | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
185
186
  | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
186
187
 
@@ -195,8 +196,8 @@ Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue
195
196
  `context.logger` is a child of the host's logger and adds `plugin: <your plugin's name>` to each line in the journal.
196
197
  `Logger` has five levels and `child(fields)`; you can pass an existing pino logger as `DefineOverrides.logger`.
197
198
 
198
- With `defineRoundtable`, the host's logger sends every `error` and `fatal` line to the ops agent named by `config.ops.agent`, which reports it in its channel, or, with `config.ops.conversation`, to that conversation as a visible message and a report turn its claim answers.
199
- The report turn is the host's own background turn of the `owner` target: it runs as the system principal (`SYSTEM_PRINCIPAL`) at the owner tier, as in 0.8, and so does the report turn of an outside Discord webhook's post in an agent's channel, whose text is untrusted input; neither can set up a schedule or delegate a task. The host does not start, with a `ConfigError` naming `config ops.conversation` and the reason, when no chat surface serves the key, no plugin's claim owns it, no plugin contributes the `owner` background target, the claim that owns it takes no background turns (has no `background`), or that claim says `takesSystemReports: false`, since its conversations are each private to their person and refuse the system principal's turns; a report is then never only logged. A claim like that is answered at startup, not by a warning at each report.
199
+ With `defineRoundtable`, the host's logger sends every `error` and `fatal` line to the ops agent named by `config.routing.places.ops` as `{ agent }` (or the deprecated `config.ops.agent`), which reports it in its primary place, or, with a conversation key there (or `config.ops.conversation`), to that conversation as a visible message and a report turn its claim answers.
200
+ The report turn is the host's own background turn of the `owner` target: it runs as the system principal (`SYSTEM_PRINCIPAL`) at the owner tier, as in 0.8, and so does the report turn of an outside Discord webhook's post in an agent's channel, whose text is untrusted input; neither can set up a schedule or delegate a task. The host does not start, with a `ConfigError` naming `config ops.conversation` (or `config routing.places.ops`) and the reason, when no chat surface serves the key, no plugin's claim owns it, no plugin contributes the `owner` background target, the claim that owns it takes no background turns (has no `background`), or that claim says `takesSystemReports: false`, since its conversations are each private to their person and refuse the system principal's turns; a report is then never only logged. A claim like that is answered at startup, not by a warning at each report.
200
201
  A webhook's post in an agent's channel starts that report turn with the post's text, and an embed counts as text: the post's embeds (author, title, description, each field as `name: value`, footer, link) follow its own content, plain and at most 4,000 characters, so a CI bot that posts only an embed, such as "Build 42 failed" with the branch and the run's link as fields, reaches the agent as `Webhook "CI" posted in your channel:` followed by those lines. A post with neither text nor embed text starts no turn. A replied-to message's embeds and the embeds in `discord.channelContext` are read the same way.
201
202
 
202
203
  The core contributes `PERSONAL_TARGET` (`"owner"`) with or without Discord; `ops.conversation` still needs a surface and a claim that accepts system report turns.
@@ -206,9 +207,19 @@ A logger you supply reaches the ops agent and `errorSink` only if it forwards it
206
207
  The report names the plugin next to `app` and `module`; the same error is reported at most once an hour, regardless of which plugin wrote it.
207
208
 
208
209
  `OpsAlerts` in `pi-roundtable/kit` is for a plugin's own alerts, such as a failed push, that the people who run the host should read; it is not the error report above, and a host that wants both uses both.
209
- `new OpsAlerts({ sink?, logger, kinds?, windowMs?, kindCap?, now? })` takes `sink(text) => Promise<boolean>`, which posts the text where those people read it: resolve `true` when it was posted, `false` when you chose not to (log why yourself, for example a channel outside the one guild alerts may go to), or throw. The kit names no discord.js type, so the sink is the only place a transport appears.
210
- `raise(key, text, { code?, info? })` resolves whether the alert was posted. The kind is the key's word before the first colon (`push:group-7` is `push`).
210
+ `new OpsAlerts({ sink?, logger, kinds?, windowMs?, kindCap?, now? })` takes `sink(text, alert) => Promise<boolean>`, which posts the text where those people read it: resolve `true` when it was posted, `false` when you chose not to (log why yourself, for example a channel outside the one guild alerts may go to), or throw. The kit names no discord.js type, so the sink is the only place a transport appears.
211
+ The second argument, `alert`, is `{ kind, key, code?, info?, severity? }`, so a sink can choose a picture or a route per kind; a sink that takes only the text still works, and `code`, `info` and `severity` are present only when `raise` was given them. `severity` (`"info"`, `"warning"` or `"critical"`) is yours to set and nothing in `OpsAlerts` reads it.
212
+ `raise(key, text, { code?, info?, severity? })` resolves whether the alert was posted. The kind is the key's word before the first colon (`push:group-7` is `push`).
211
213
  A key is posted at most once per window (one hour by default) and a kind at most `kindCap` times per window (8 by default), whatever the keys, so a fault that touches everything cannot flood the sink. An alert is posted when it is raised, not queued, and what is held back is only counted: `faults()` lists the last fault of each kind, newest first, with its `name` (from `kinds`, else the kind itself), `code`, `raised` and `suppressed`, in memory only, for a status command. `info: true` marks news, not a fault: it posts at most once per key, does not count against the kind's cap and is not listed in `faults()`.
214
+ `resolve(key)` says a fault is over: it gives the key back (a new fault under it posts again at once), calls the optional `sink.resolved({ kind, key })` so the sink can mark the alert it posted as recovered, such as by editing its message, and sets `clearedAt` on the kind in `faults()` once none of the kind's alerts is open; a later raise of the kind removes it again. It resolves `true` when it ended an open alert. Resolving a key that was never posted, an info alert, a key already resolved, or one whose post has not finished does nothing and resolves `false`, so it is safe to call from every "the read worked" path. Posted alerts are remembered for `resolve` up to 1,000 keys, the oldest first forgotten, and past the window too: a fault that lasts for hours still resolves. A `resolved` that throws is logged by the error's name and never reaches the caller.
215
+
216
+ ```ts
217
+ const sink: OpsSink = Object.assign(
218
+ async (text: string, alert: OpsAlert) => post(text, alert.kind),
219
+ { resolved: async ({ key }: OpsResolution) => markRecovered(key) },
220
+ );
221
+ ```
222
+
212
223
  A sink that throws or answers `false` gives the key and the kind's slot back, so the next raise tries again, and a throw is logged by the error's name only. `raise` never throws. With no `sink`, alerts are only recorded, the key stays held and the log has one line per window (`an ops alert was raised and there is no sink to post it to`); `posts` tells which. `ErrorReporter` and `OpsAlerts` each keep their own limits, so alerts never count against the error reports' five an hour.
213
224
 
214
225
  ### Services: what plugins provide to each other
@@ -245,7 +256,7 @@ The built-in plugins provide these, from the main entry:
245
256
  | `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
246
257
  | `MODES` | `ConversationModes` | `modes` | Each conversation's modes, stored per key and kept across restarts: `peek(channel)` gives its `ConversationMode` (`yolo` and `longRunning`, both off by default; `changedBy?`, `changedAt?`) from memory, `set(channel, { yolo?, longRunning? }, speaker)` changes them and throws `PluginError` unless the speaker is an owner other than `SYSTEM_PRINCIPAL`, `onChange(listener)` hears each change, and `autoApproved(entry)` with `onAutoApproved(listener)` report a held call that yolo mode approved. See [Conversation modes](#conversation-modes-modes) |
247
258
  | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
248
- | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), and `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`) |
259
+ | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`), and `routes` (`RoutingTable`: where work no person started lands; see [Routing](#routing-where-work-no-person-started-lands)) |
249
260
  | `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
250
261
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
251
262
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
@@ -2408,6 +2419,8 @@ The router records the author, such as linking them at their first contact, only
2408
2419
  One exception: an author already linked whom the access rules now refuse is recorded as seen at no tier when the message is resolved, whether or not a claim takes it, at most once every few minutes, so their background turns stop with them.
2409
2420
  When that record finds the author someone else than the claim admitted, such as linked to another principal meanwhile or refused now, or cannot be made, the router drops the message: a turn admission's optional `dropped()` is called instead of `run`, so the claim frees what it holds for it, and a background admission's `unanswered` hears it skipped.
2410
2421
 
2422
+ A claim may also hear that a message it owns changed, through `onMessageDeleted(channel, messageId)` and `onMessageEdited(message)`, which a surface reports with the second argument of its `start` (`{ deleted, edited }`, a `SurfaceMessageEvents`). Discord reports both, including for a message the bot had not cached; a surface that cannot tell never calls them. The router delivers each as it comes, to the claim that owns the channel and no other, outside the channel's queue, so a running turn does not hold it up; a throw or rejection is logged and never fails the router. A delete carries only ids, and Discord's delete event does not say who wrote the message, so the claim hears the deletion of the bot's own messages too and ignores an id it does not know. An edit carries the message as it reads now, as an `InboundMessage`, without `speaker` or `peerBot` because nobody is resolved or admitted for it; the bot's own edits are not reported, nor is a change that leaves the text, the embeds and the attachments as they were, such as a pin. A bulk delete is reported as one delete per message.
2423
+
2411
2424
  A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
2412
2425
  The router calls only the owning claim's `stop`, returning `false` when that method is absent.
2413
2426
  Give `stop` to a claim whose conversations run turns that can be interrupted.
@@ -2449,6 +2462,27 @@ export const echo = definePlugin({
2449
2462
  ```
2450
2463
  <!-- /example -->
2451
2464
 
2465
+ ### `observe`: hearing messages without owning them
2466
+
2467
+ `context.observe(filter, observer)` hears inbound messages whichever claim owns their channel, without admitting, blocking or delaying any of them. A plugin that only listens, such as one that decides whether to offer help in a channel another claim owns, uses it instead of tapping each claim's `admit`.
2468
+
2469
+ ```ts
2470
+ const stop = context.observe({ surface: "discord", space: guildId }, (message) => {
2471
+ remember(message.channel, message.messageId, message.text);
2472
+ });
2473
+ ```
2474
+
2475
+ The filter narrows by `surface` (the key prefix; naming a surface is also how you opt in to the rooms it hosts, below), `space` (the server or workspace id), and `channels` (channel keys); an empty filter hears every message the router considers. The observer gets what the router would consider and no more:
2476
+
2477
+ - Messages in a channel some claim owns. A channel nobody owns is not heard.
2478
+ - People's messages, those of a bot the host lists as a peer (`discord.peerBots`) even when its rounds in the channel are used up (observing does not count a round), and an integration's, such as a webhook's, with `integration` set (`integration.own` says whether the assistant posts through it). Never an unlisted bot's, and never the assistant's own, which no surface delivers.
2479
+ - Guild and workspace messages only. A direct message is private to its two sides, so it is heard only when the filter says `includeDirect: true`.
2480
+ - Not the rooms of a surface that hosts agent rooms (`agentRooms: true`, such as a web app's agent and host rooms): they are one person's or one team's, so a filter that does not name that surface in `surface` does not hear them. Name the surface to opt in; the observer then answers for keeping that text private to its people.
2481
+
2482
+ The observer is called on a later tick with its own frozen copy of the message, so it cannot change what a claim or another observer sees, and routing does not wait for it. A throw or rejection is logged by the error's name, never with the message, and affects nothing else. The message has no `speaker` and no `peerBot`: no one is resolved for an observer, and no contact is recorded. A message that cannot be copied is dropped for the observers, with the error's name logged, and never reaches routing.
2483
+
2484
+ Observers see other people's untrusted text. Keep it out of logs, hold it no longer than the purpose needs, never act on instructions in it, and treat it as private to the people who wrote it. The returned function removes the observer. An observer cannot answer or admit a message; to answer, own the channel with a claim.
2485
+
2452
2486
  ### `personas` and `context.turns`: conversations of a kind of your own
2453
2487
 
2454
2488
  A conversation has a kind: the string its claim returns from `startFresh`, such as `"owner"` or `"study"`.
@@ -2770,6 +2804,51 @@ export function tavern(channelIds: readonly string[]) {
2770
2804
  ```
2771
2805
  <!-- /example -->
2772
2806
 
2807
+ ### Peer bots: answering other bots
2808
+
2809
+ Every bot's message is ignored by default, so two bots in one channel never talk to each other.
2810
+ A host opts in by listing the other bots it answers, by user id, in `discord.peerBots` (or the host option `peerBots`):
2811
+
2812
+ ```ts
2813
+ discord: {
2814
+ // ...
2815
+ peerBots: {
2816
+ // Explicit ids only: there is no wildcard and no "all bots". Left out, no bot is answered.
2817
+ allow: [
2818
+ "800000000000000001",
2819
+ { id: "800000000000000002", guilds: ["900000000000000001"], channels: ["700000000000000001"] },
2820
+ ],
2821
+ maxRounds: 3, // default 3
2822
+ },
2823
+ },
2824
+ ```
2825
+
2826
+ 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.
2827
+ 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.
2828
+ 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).
2829
+ Channel context is unchanged: its window still starts after the assistant's last post and other bots' messages are still cut at `botMessageChars`.
2830
+
2831
+ The agent server answers a peer that mentions the agent or replies to it, like a guest. A listed peer's `speaker` is resolved like a person's, so a claim that checks only `speaker` answers it too. A claim of your own opts in where it once checked `!message.authorIsBot`, with `answersAuthor(message)` from `pi-roundtable/kit`, which is true for a person and for a flagged peer that mentions the bot or replies to it:
2832
+
2833
+ ```ts
2834
+ import { answersAuthor } from "pi-roundtable/kit";
2835
+
2836
+ // A status-board claim: people and the listed build bot may ask for the latest run.
2837
+ const statusClaim: ChannelClaim = {
2838
+ name: "status-board",
2839
+ priority: 50,
2840
+ owns: (channel) => channel === "discord:700000000000000001",
2841
+ admit: (message) =>
2842
+ answersAuthor(message) && message.speaker
2843
+ ? { kind: "turn", run: () => answer(message), failure: "status turn failed" }
2844
+ : undefined,
2845
+ startFresh: async () => "status-board",
2846
+ };
2847
+ ```
2848
+
2849
+ A reply that contains `<@id>` of a peer pings it, since replies allow user mentions; without that the peer's `mentionsBot` stays false.
2850
+ In a test, `testPlugin({ peerBots: { allow: [...] } })` puts the same list on the test router.
2851
+
2773
2852
  ### Posting through `DISCORD.poster`
2774
2853
 
2775
2854
  `DISCORD.poster` (`DiscordPoster`, from `pi-roundtable/discord`) is Discord's REST API for a plugin's own posts, over the Discord plugin's own client.
@@ -2786,7 +2865,7 @@ await poster.edit(channelId, posted.messageId, { content: "Hello again" }, ["rel
2786
2865
  await poster.remove(channelId, posted.messageId, ["relay"]);
2787
2866
  ```
2788
2867
 
2789
- What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), the bot's own messages and cards (`say`, `sayCard`, `editCard`, `deleteAsBot`), direct messages (`sendDirect`), pins, reading (`message`, `recentMessages`, `botUserId`, `channelGuild`, `canView`, `memberName`), text channels (`createTextChannel`, `textChannels`, `deleteChannel`, `renameChannel`) and threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`).
2868
+ What it covers: webhook posts under names you choose (`post`, `edit`, `remove`, `webhookIds`, `setAvatar`, `onWebhookMade`), cards posted through such a webhook (`postCard`, `editWebhookCard`), the bot's own messages and cards (`say`, `sayCard`, `editCard`, `deleteAsBot`), direct messages (`sendDirect`), pins, reading (`message`, `recentMessages`, `botUserId`, `channelGuild`, `canView`, `memberName`), text channels (`createTextChannel`, `textChannels`, `deleteChannel`, `renameChannel`) and threads (`startThread`, `setThreadArchived`, `threadState`, `threadCloser`).
2790
2869
  Use `DISCORD.connection.channelInfo(channelId)` to read what a channel is called now: it comes from the gateway's cache.
2791
2870
 
2792
2871
  The rules the poster keeps, so a plugin does not rediscover them:
@@ -2794,6 +2873,25 @@ The rules the poster keeps, so a plugin does not rediscover them:
2794
2873
  - **Webhooks are named by the plugin.** `post` finds the bot's webhook of that name in the channel, or makes it on first use; a same-named webhook someone else made is never used. A plugin that needs several voices keeps one name per voice, so the `webhookId` on a message tells them apart. A webhook name, and a post's `username`, are 1 to 80 characters without "discord" or "clyde" in any case (full-width letters too); a username is not "everyone" or "here" either. Anything else is refused as `invalid` before a request is sent. `edit` and `remove` take the names to ask, in order, since a webhook changes only its own messages; they make no webhook. `remove` falls back to deleting as the bot (Manage Messages) for a message an earlier webhook of the same name posted.
2795
2874
  - **Nothing pings by accident.** Every post, edit, card and direct message sends `allowed_mentions` with `parse: []` and, for a post, the `mentions` users you list (at most 20). `@everyone`, roles and users merely named in the text never ping.
2796
2875
  - **Cards are Components V2 messages.** A card carries no `content`. Three lessons: every `editCard` re-sends the Components V2 flag (without it Discord answers a 400); a message posted by `say` cannot be edited into a card (`rejected`: delete it and post a card, which changes the message id); and a pin makes Discord post a system notice (type 6) as the bot, which `recentMessages` lists, `deleteAsBot` deletes, and nothing can edit (`rejected`, Discord's 50021), so skip it when looking for your own message.
2876
+ - **A card can carry a picture.** `thumbnail: { url, description? }` makes the heading a Section with a small picture beside it; `image: { url, description? }` puts a Media Gallery of one picture under the text sections and above the quiet last line. Both take an `https` address of at most 2,048 characters and an alt text of 1 to 1,024 characters, else `invalid`; `editCard` and `editWebhookCard` keep them when you send them again, and a card without them is sent exactly as before. Discord fetches the address itself, so it must be reachable from the internet.
2877
+ - **A webhook can post a card.** `postCard` takes the same `webhook`, `username` and `avatarUrl` as `post` plus a `card`, and sends it with `with_components=true`, which Discord allows for the bot's own (application-owned) webhooks; a button or menu on it reaches your interaction handler like one on a bot card, and the colour bar stays. `editWebhookCard(channelId, messageId, card, webhooks)` asks each named webhook in order, as `edit` does: `message_gone` when none posted it, `rejected` for a message that is not a card. The webhook's token is never logged or put in an error.
2878
+
2879
+ ```ts
2880
+ await poster.postCard(channelId, {
2881
+ webhook: "alerts",
2882
+ username: "Build alerts",
2883
+ avatarUrl: "https://assets.example.org/alerts.png",
2884
+ card: {
2885
+ accent: 0xcc3333,
2886
+ title: "Build failed",
2887
+ sections: ["`main` failed at the test step."],
2888
+ thumbnail: { url: "https://assets.example.org/failed.png", description: "A red cross" },
2889
+ image: { url: "https://assets.example.org/graph.png", description: "Failures over the last week" },
2890
+ links: [{ label: "Open the run", url: "https://ci.example.org/runs/42" }],
2891
+ },
2892
+ });
2893
+ ```
2894
+
2797
2895
  - **A channel is renamed twice in ten minutes.** Discord answers a third with a wait of minutes. `renameChannel` does not queue it: after ten seconds it fails as `unavailable`, and the held request is aborted, never sent later.
2798
2896
  - **Threads.** `startThread` makes a public thread on a message that archives itself after a week and wakes on the next post. The bot needs no Manage Threads to archive or open a thread it started; a locked thread needs it to open again (`forbidden`). `threadCloser` reads the guild's audit log (View Audit Log, else `forbidden`): a person's id, the bot, or `unknown` for Discord's own auto-archive. An archive counts only if its entry is within two minutes of the thread's `archive_timestamp`. It reads the newest 50 `THREAD_UPDATE` entries, so on a busy guild an old close may have left the log and read as `unknown`.
2799
2897
  - **Errors.** Every failure is a `DiscordPosterError` with a `kind`: `invalid` (refused before sending), `channel_gone`, `message_gone`, `forbidden` (a missing permission), `rejected` (Discord refused; asking again fails again) and `unavailable` (Discord or the network failed, or a rate limit held the call; `retryable`). `reason` says more for a full category (`category_full`), a channel that is no category (`not_category`) and one in no guild (`wrong_guild`). A webhook deleted by hand is found or made again once. A person who takes no direct messages is the answer `"closed"`, and a user who is not a member is `false` from `canView` and `undefined` from `memberName`, not failures. A message never holds a URL, a webhook token or the bot token.
@@ -2937,11 +3035,12 @@ The agent server claims `discord:` keys, and, only on a surface that declares `a
2937
3035
  | `maxAttachmentBytes` | The most bytes of one attachment a turn takes from this surface, for a ref, a file, a stream or a download alike; a positive integer, so a surface that keeps large uploads can raise it past 25 MiB. See [files from outside a turn](#contextattachments-files-from-outside-a-turn) | 25 MiB, as Discord's |
2938
3036
  | `formatting` | A short English text on how a reply renders here, such as "no Markdown tables"; the runtime appends it to the system prompt of every turn that replies on this surface, after the persona | nothing is appended |
2939
3037
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
2940
- | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
3038
+ | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)`. A group's round shows it for the whole round, and stopping ends the round: the member speaking is stopped and the members still waiting do not speak | none is shown |
2941
3039
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2942
3040
  | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`, and, where the surface declares `asks`, `questionnaire` ([several questions on one card](#several-questions-on-one-card-questionnaire-and-asksupport)); without a scope, the owners | the action is held until a message approves it |
2943
3041
  | `interim(channel, as?)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place. `as` is a `PostAuthor` (`{ name, agent? }`) when an agent of the team speaks, as `OutboundReply.author` is for the final reply | only the final reply is posted |
2944
3042
  | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
3043
+ | `groupRound(channel, round)` | Shows where a group room's round stands: a `RoundProgress`, `{ id, state, current?, next, spoken, replies, limit, ended?, queued? }`, with `id` the group message that opened the round, `state` `running` or `ended`, `current` the member speaking, `next` those still to speak in order (a handoff appended), `spoken` those who replied, `ended` (once it ended) `done`, `limit` or `stopped`, and `queued` the owner's messages waiting for the next round. Called when the round is planned, before and after each member's turn, and when it ends; each call carries the whole state. Called for the room keys of a surface that hosts rooms, never for a Discord channel; a rejection is logged and the round goes on | the surface shows no rounds |
2945
3044
 
2946
3045
  #### Agent rooms on any surface
2947
3046
 
@@ -2964,6 +3063,52 @@ Messages in a room are admitted by the agent server's claim like those in a Disc
2964
3063
  The claim reads the attachments of a room's message through the surface's `readAttachment`, so a ref the surface made is never fetched over the network; a surface without it has its refs downloaded from their URLs.
2965
3064
  `AGENTS.team.owns(key)` answers for a room too, only while its agent or group is active, and `AGENTS.team.coordinator()` names the coordinator, the agent in the entry channel, or on a host without Discord the one `coordinator` names.
2966
3065
 
3066
+ #### Routing: where work no person started lands
3067
+
3068
+ An agent has a *place* on each surface that holds one: its Discord channel, and its room `<surface>:agent.<name>` on a surface with `agentRooms`. Its *primary place* is where its unprompted work lands. `AGENTS.routes` (`RoutingTable`) answers, and the configuration's `routing` block sets it; without the block every answer is what the host did before the block existed:
3069
+
3070
+ | Call | Answers |
3071
+ | --- | --- |
3072
+ | `primary(agent)` | The primary place: the surface the rules name, and with `auto` the agent's Discord channel when it has one, else its room on the first surface that hosts rooms. Undefined for an unknown agent. |
3073
+ | `placeOn(agent, surface)` | The agent's place on a surface (`web` for the one surface that hosts rooms); undefined when it has none there. |
3074
+ | `of(agent, "integration", origin)` | Where the report turn of a foreign webhook's post made in `origin` runs; `origin` itself by default, and also when the destination has vanished. |
3075
+ | `of(agent, "agentMessage", from)` | The conversation a `message_agent` call that names none reaches: by default the agent's latest, never out of a room into Discord. Throws `AgentError` when the agent has no place. |
3076
+ | `of(agent, "opening", origin?)` | Where an opening task runs: by default the agent's place on the surface `origin` is on, else its primary place; undefined when it has no place at all. |
3077
+ | `place("ops")` | A named place of the host: the conversation, or the primary place of the agent it names while that agent is active. |
3078
+
3079
+ Work a person started, or that waits on a person (a message, a card, a late answer, a schedule's firing place, a message exchange's answer back), is never routed: it stays in the conversation the person sees. A plugin that posts an agent's unprompted work asks the table instead of choosing Discord first.
3080
+ `SurfacePort.names()` lists every registered surface by name, after link; the agent server uses it to refuse a `routing` key that names one nobody registered, naming the key and the registered surfaces.
3081
+ `mirror` (a read-only copy of a primary place's posts) and an answerer per surface are defined terms with a position in the block, but they are not built: setting either stops the start.
3082
+
3083
+ #### Temp knights: helpers an agent makes for one task
3084
+
3085
+ A knight (an agent) can make *temp knights*, helper agents for one self-contained task that can run on its own, and clear them when they are done.
3086
+ It calls `helper_create { display_name, prompt, task, name?, class?, color?, model?, thinking?, skills? }` once per task and ends its turn; each temp knight starts at once and its answer returns to the knight's conversation as a new turn, as a `message_agent` answer does.
3087
+ It merges the answers and calls `helper_end { names }`, where `names` is a list of its own temp knights or `"finished"`.
3088
+ Both tools are on the `member` tier and are offered to every agent that is not itself a temp knight.
3089
+
3090
+ A temp knight is a real agent: an ordinary row of the agent table with a nullable `parent` and `finished_at` (the migration `agents-helpers`, additive), its own prompt, picture and sessions, and a name that defaults to `<parent>-<n>`.
3091
+ Its model and thinking level are its knight's unless the call names others.
3092
+ It has no group seat, and no agent, group, archive, channel-arrange or schedule tools.
3093
+ It follows its knight's routing rules, and whether it counts as a long-running conversation follows the conversation that made it.
3094
+
3095
+ | Rule | What happens |
3096
+ |---|---|
3097
+ | Place | On Discord a temp knight lives in a thread under its knight's channel, whose posts go through the channel's webhook under the temp knight's own name and picture; a knight with no Discord channel gives it a room on a surface that hosts rooms. Archiving a temp knight archives and locks its thread, and restoring it unarchives the thread. |
3098
+ | Finished | Derived, never set by the knight: nothing runs, nothing is queued, no card waits for the owner and no message from its knight is still unanswered. `Agent.finishedAt` carries the stamp. |
3099
+ | Limits | `helpers.perParent` (default 4) and `helpers.perHost` (default 12) count active temp knights; depth is one, so a temp knight cannot make one. At a limit the knight's oldest finished temp knight is archived to make room, and the call is refused only when every one of them is busy. |
3100
+ | Cleanup | `helper_end`, the owner's clearing in a surface, a knight's own archive (which archives its temp knights first), and, after `helpers.archiveFinishedAfterHours` (default 24), an hourly sweep. An archived temp knight keeps its history and comes back with `restoreHelpers`. Deleting an archived knight deletes its temp knights. |
3101
+
3102
+ `AgentTeam` offers `helpersOf(parent)`, `finishedHelpers(parents?)`, `archiveHelpers(names, by)`, `restoreHelpers(names)` (only a name that is still an archived temp knight of an active knight is restored) and `onHelpersEnded(listener)`, which hears a knight's own `helper_end` and nothing else, so a surface can offer the owner an undo.
3103
+ `isHelper(agent)` tells a temp knight, for a surface that lists agents; a group never takes one.
3104
+ The `helpers` block of the configuration turns the limits and the sweep; `helpers: false` offers neither tool, and a value out of its range, or a `perParent` above `perHost`, stops the start with a `ConfigError`:
3105
+
3106
+ ```ts
3107
+ defineRoundtable({
3108
+ helpers: { perParent: 4, perHost: 12, archiveFinishedAfterHours: 24 },
3109
+ });
3110
+ ```
3111
+
2967
3112
  #### Dispatch threads on any surface
2968
3113
 
2969
3114
  A background dispatch (a coding task, a delegated task, a message between agents) gets a thread beside the channel that started it: one start line in the channel, then the dispatch's progress and report inside the thread.
@@ -4209,6 +4354,7 @@ Import from the entries listed below; source area files are internal.
4209
4354
  | `AgentChange` | `pi-roundtable` | type |
4210
4355
  | `AgentDirectory` | `pi-roundtable` | type |
4211
4356
  | `AgentGroup` | `pi-roundtable` | type |
4357
+ | `AgentRoutes` | `pi-roundtable` | type |
4212
4358
  | `AgentRunError` | `pi-roundtable` | value |
4213
4359
  | `AgentRuntime` | `pi-roundtable` | type |
4214
4360
  | `AgentSeed` | `pi-roundtable` | type |
@@ -4242,6 +4388,7 @@ Import from the entries listed below; source area files are internal.
4242
4388
  | `AvatarStudio` | `pi-roundtable` | type |
4243
4389
  | `PROMPTS` | `pi-roundtable` | value |
4244
4390
  | `characterOf` | `pi-roundtable` | value |
4391
+ | `isHelper` | `pi-roundtable` | value |
4245
4392
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4246
4393
  | `CONVERSATIONS` | `pi-roundtable` | value |
4247
4394
  | `BackgroundConfig` | `pi-roundtable` | type |
@@ -4293,6 +4440,9 @@ Import from the entries listed below; source area files are internal.
4293
4440
  | `GroupStatus` | `pi-roundtable` | type |
4294
4441
  | `HeldActionStore` | `pi-roundtable` | type |
4295
4442
  | `HeldCall` | `pi-roundtable` | type |
4443
+ | `HelperTeam` | `pi-roundtable` | type |
4444
+ | `HelpersConfig` | `pi-roundtable` | type |
4445
+ | `HelpersEnded` | `pi-roundtable` | type |
4296
4446
  | `HoldCheck` | `pi-roundtable` | type |
4297
4447
  | `HoldContext` | `pi-roundtable` | type |
4298
4448
  | `HoldDescription` | `pi-roundtable` | type |
@@ -4353,11 +4503,14 @@ Import from the entries listed below; source area files are internal.
4353
4503
  | `OwnerAnswer` | `pi-roundtable` | type |
4354
4504
  | `OwnerPrompts` | `pi-roundtable` | type |
4355
4505
  | `OwnerQuestion` | `pi-roundtable` | type |
4506
+ | `PeerBotEntry` | `pi-roundtable` | type |
4507
+ | `PeerBotsConfig` | `pi-roundtable` | type |
4356
4508
  | `PendingConfirmation` | `pi-roundtable` | type |
4357
4509
  | `PerPrincipalLimits` | `pi-roundtable` | type |
4358
4510
  | `PersonDisplay` | `pi-roundtable` | type |
4359
4511
  | `PersonProfiles` | `pi-roundtable` | type |
4360
4512
  | `Persona` | `pi-roundtable` | type |
4513
+ | `PlaceTarget` | `pi-roundtable` | type |
4361
4514
  | `PluginContext` | `pi-roundtable` | type |
4362
4515
  | `PluginIdentity` | `pi-roundtable` | type |
4363
4516
  | `PluginError` | `pi-roundtable` | value |
@@ -4391,6 +4544,8 @@ Import from the entries listed below; source area files are internal.
4391
4544
  | `ResolvedSkill` | `pi-roundtable` | type |
4392
4545
  | `RoleGrant` | `pi-roundtable` | type |
4393
4546
  | `RoleSource` | `pi-roundtable` | type |
4547
+ | `RoundEnd` | `pi-roundtable` | type |
4548
+ | `RoundProgress` | `pi-roundtable` | type |
4394
4549
  | `Roundtable` | `pi-roundtable` | value |
4395
4550
  | `ReplyFile` | `pi-roundtable` | type |
4396
4551
  | `ReplyFileError` | `pi-roundtable` | value |
@@ -4399,7 +4554,10 @@ Import from the entries listed below; source area files are internal.
4399
4554
  | `RoundtableOptions` | `pi-roundtable` | type |
4400
4555
  | `RegistrationReport` | `pi-roundtable` | type |
4401
4556
  | `RoundtablePlugin` | `pi-roundtable` | type |
4557
+ | `RouteKind` | `pi-roundtable` | type |
4402
4558
  | `RouteSocket` | `pi-roundtable` | type |
4559
+ | `RoutingConfig` | `pi-roundtable` | type |
4560
+ | `RoutingTable` | `pi-roundtable` | type |
4403
4561
  | `RuntimeDeps` | `pi-roundtable` | type |
4404
4562
  | `RuntimeFactory` | `pi-roundtable` | type |
4405
4563
  | `SCHEDULES` | `pi-roundtable` | value |
@@ -4451,7 +4609,11 @@ Import from the entries listed below; source area files are internal.
4451
4609
  | `SpeakerMemory` | `pi-roundtable` | type |
4452
4610
  | `StoredAttachment` | `pi-roundtable` | type |
4453
4611
  | `SurfacePort` | `pi-roundtable` | type |
4612
+ | `SurfaceRouting` | `pi-roundtable` | type |
4454
4613
  | `SurfaceThreads` | `pi-roundtable` | type |
4614
+ | `SurfaceMessageEvents` | `pi-roundtable` | type |
4615
+ | `ObserveFilter` | `pi-roundtable` | type |
4616
+ | `MessageObserver` | `pi-roundtable` | type |
4455
4617
  | `SYSTEM_PRINCIPAL` | `pi-roundtable` | value |
4456
4618
  | `THE_SPEAKER` | `pi-roundtable` | value |
4457
4619
  | `THREADS` | `pi-roundtable` | value |
@@ -4638,6 +4800,7 @@ Import from the entries listed below; source area files are internal.
4638
4800
  | `VirtualServer` | `pi-roundtable/kit` | type |
4639
4801
  | `YesNoQuestion` | `pi-roundtable/kit` | type |
4640
4802
  | `activeToolsExtension` | `pi-roundtable/kit` | value |
4803
+ | `answersAuthor` | `pi-roundtable/kit` | value |
4641
4804
  | `approvalCard` | `pi-roundtable/kit` | value |
4642
4805
  | `approvalDetails` | `pi-roundtable/kit` | value |
4643
4806
  | `archiveSessions` | `pi-roundtable/kit` | value |
@@ -4675,6 +4838,10 @@ Import from the entries listed below; source area files are internal.
4675
4838
  | `InterimPosterOptions` | `pi-roundtable/kit` | type |
4676
4839
  | `OpsAlerts` | `pi-roundtable/kit` | value |
4677
4840
  | `OpsAlertsOptions` | `pi-roundtable/kit` | type |
4841
+ | `OpsAlert` | `pi-roundtable/kit` | type |
4842
+ | `OpsResolution` | `pi-roundtable/kit` | type |
4843
+ | `OpsSeverity` | `pi-roundtable/kit` | type |
4844
+ | `OpsSink` | `pi-roundtable/kit` | type |
4678
4845
  | `OpsRaiseOptions` | `pi-roundtable/kit` | type |
4679
4846
  | `OpsAlertKind` | `pi-roundtable/kit` | type |
4680
4847
  | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
@@ -4755,6 +4922,7 @@ Import from the entries listed below; source area files are internal.
4755
4922
  | `DiscordPosterError` | `pi-roundtable/discord` | value |
4756
4923
  | `CardButton` | `pi-roundtable/discord` | type |
4757
4924
  | `CardLink` | `pi-roundtable/discord` | type |
4925
+ | `CardPicture` | `pi-roundtable/discord` | type |
4758
4926
  | `CardSelect` | `pi-roundtable/discord` | type |
4759
4927
  | `CategoryChannel` | `pi-roundtable/discord` | type |
4760
4928
  | `DiscordCard` | `pi-roundtable/discord` | type |
@@ -4767,6 +4935,7 @@ Import from the entries listed below; source area files are internal.
4767
4935
  | `RecentMessage` | `pi-roundtable/discord` | type |
4768
4936
  | `ThreadCloser` | `pi-roundtable/discord` | type |
4769
4937
  | `ThreadState` | `pi-roundtable/discord` | type |
4938
+ | `WebhookCardPost` | `pi-roundtable/discord` | type |
4770
4939
  | `WebhookPost` | `pi-roundtable/discord` | type |
4771
4940
  | `InteractionContribution` | `pi-roundtable/discord` | type |
4772
4941
  | `InteractionModule` | `pi-roundtable/discord` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.26.3",
3
+ "version": "0.27.1",
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",