pi-roundtable 0.7.19 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
package/docs/plugins.md CHANGED
@@ -139,7 +139,10 @@ Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue
139
139
  `context.logger` is a child of the host's logger and adds `plugin: <your plugin's name>` to each line in the journal.
140
140
  `Logger` has five levels and `child(fields)`; you can pass an existing pino logger as `DefineOverrides.logger`.
141
141
 
142
- 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.
142
+ 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.
143
+ The report turn is a background turn of the `owner` target, so 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, or the claim that owns it takes no background turns (has no `background`); a report is then never only logged.
144
+ On a host without Discord no plugin contributes `owner` unless one of yours does, so `ops.conversation` needs such a plugin there.
145
+ The web chat takes no error reports in 0.8: it posts only to a conversation a signed-in person opened, and its claim takes no background turns, so `web:<id>` fails at startup; name a conversation of another surface, or an agent with Discord.
143
146
  It then calls `DefineOverrides.errorSink(entry)` if you supply one; this function must not throw.
144
147
  A logger you supply reaches the ops agent and `errorSink` only if it forwards its error lines there.
145
148
  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.
@@ -172,7 +175,9 @@ The built-in plugins provide these, from the main entry:
172
175
 
173
176
  | Key | Port | Provided by | What it is |
174
177
  |---|---|---|---|
175
- | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
178
+ | `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
179
+ | `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 |
180
+ | `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), and `avatars` (`AvatarStudio`) |
176
181
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
177
182
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
178
183
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
@@ -511,7 +516,7 @@ export const cleanup = definePlugin({
511
516
 
512
517
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
513
518
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
514
- A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
519
+ A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owner; nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId`; a call held without one is the owner's to approve.
515
520
 
516
521
  #### The shell rule
517
522
 
@@ -642,6 +647,7 @@ export const library = definePlugin({
642
647
  | `serviceStarted(event)` | A service's `startInBackground` ended: `event` is `{ plugin, service, outcome }`, and `outcome` is `"ready"` or `"failed"`; the rest of the process runs either way |
643
648
  | `turnStarted(turn)` | An agent's turn began, or a turn run through `context.turns` |
644
649
  | `turnEnded(turn)` | The turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
650
+ | `turnProgress(event)` | A turn run through `context.turns` wrote text or ran a tool, between its start and its end: `event` is the turn's fields and `progress`, a `TurnProgress` (see the surface's `progress` below); a runtime without live progress reports none |
645
651
  | `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
646
652
  | `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
647
653
 
@@ -996,7 +1002,7 @@ An unknown or misspelled slot stops startup with an error listing the valid name
996
1002
  |---|---|---|
997
1003
  | `judge` | Asks the configured model small questions: does this reply approve the held actions, how hard is this turn, which agents does this group message concern | An object with `askYesNo`, `askChoice`, and `askScore` |
998
1004
  | `images` | No drawing; each agent gets an avatar generated from its display name | `async (prompt, references) => bytes` returning PNG bytes |
999
- | `runtime` | None: the agent server builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
1005
+ | `runtime` | None: the runtime plugin builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
1000
1006
 
1001
1007
  When no `images` provider is configured, `agent_create` has no `avatar_prompt` parameter and `agent_avatar` is unavailable.
1002
1008
  The owner's profile panel reports the missing provider and offers no redraw option.
@@ -1030,16 +1036,16 @@ export const pixelAvatars = definePlugin({
1030
1036
 
1031
1037
  The runtime handles the agent server's conversations and those of every claim that runs turns through `context.turns`.
1032
1038
  It keeps one persistent conversation per channel key, with its turns, held actions, and history.
1033
- By default, the agent server builds the Pi runtime.
1039
+ By default, the host's `runtime` plugin builds the Pi runtime and provides it as `RUNTIME`; the agent server and `context.turns` both run on it, and a host without the agent server has it too.
1034
1040
  Filling the `runtime` slot replaces it for agent turns, the owner's conversations, steering, held actions, and transcripts; the host skips building Pi.
1035
- The slot's default factory refuses calls, so check `providers.filled.has("runtime")` before calling `providers.runtime`.
1036
- The agent server does this for you.
1041
+ The slot's default factory refuses calls: read the running runtime with `services.get(RUNTIME)` instead of calling `providers.runtime`.
1037
1042
 
1038
- The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called once when the agent server sets up.
1043
+ The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called once when the runtime plugin sets up, before the agent server.
1039
1044
  `deps` provides `logger`, `env`, `owner`, `toolTiers`, and the host's `judge`.
1040
1045
  Its `sessions()` gives you linked hold rules, packages, session tools, and personas from preflight onwards; call it in a turn, when those parts are available.
1041
1046
  `prompts(conversation, speaker)` gives you the owner's approval and question cards on the conversation's surface, or `undefined`.
1042
1047
  `agents` holds the agent server's per-agent settings: `workDir`, `scratchDir` (the agents' shell's `TMPDIR`), `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1048
+ The agent server sets up after the runtime is built, so read `agents` when a turn runs; it is `undefined` on a host without the agent server, where no agent turn runs.
1043
1049
  `confirmations` stores held actions across restarts.
1044
1050
 
1045
1051
  An `AgentRuntime` has these methods:
@@ -1054,7 +1060,7 @@ An `AgentRuntime` has these methods:
1054
1060
  | `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation |
1055
1061
  | `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
1056
1062
  | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
1057
- | `dispose?()` | Runs when the host stops the agent server's runtime service |
1063
+ | `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
1058
1064
 
1059
1065
  A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
1060
1066
  An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
@@ -1162,7 +1168,7 @@ export const createEchoRuntime: RuntimeFactory = (deps) => {
1162
1168
 
1163
1169
  /**
1164
1170
  * A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
1165
- * agent server builds the Pi runtime. One plugin may fill it.
1171
+ * runtime plugin builds the Pi runtime. One plugin may fill it.
1166
1172
  */
1167
1173
  export const echoRuntime = definePlugin({
1168
1174
  name: "echo-runtime",
@@ -1391,7 +1397,7 @@ A test gives the plugin `fakeDiscord()` from `pi-roundtable/testing`: `testPlugi
1391
1397
  ### `http`: routes on the bot's listener
1392
1398
 
1393
1399
  The configuration's `http` block opens a listener named `public`, which serves the agents' avatars.
1394
- `http.publicUrl` is its internet address.
1400
+ `http.publicUrl` is its internet address, required with Discord; a host without Discord may leave `http` out and opens no listener.
1395
1401
  A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
1396
1402
  Two routes that could take the same request are refused, so a route cannot shadow the avatars.
1397
1403
  This listener is reachable from the internet, so check a secret in the handler before taking action.
@@ -1404,6 +1410,20 @@ The socket file's permission bits are `http.socketMode`, `0o660` by default: onl
1404
1410
  Widen it (`0o666`) only when the proxy runs as a user outside that group.
1405
1411
  More listeners than `public` come from `defineRoundtable`'s `listeners` override, each `{ id, socketPath, mode? }` or `{ id, port, hostname? }`, and a route attaches to one by its `id`.
1406
1412
 
1413
+ A route that also declares `websocket` takes WebSocket upgrades on its path; every other request still reaches `handle`, and its `methods`, when listed, must include `GET`.
1414
+ An upgrade is a `GET` with `Upgrade: websocket`; one without `Sec-WebSocket-Key` or with a `Sec-WebSocket-Version` other than `13` gets `400`, and any other method goes to `handle` as usual.
1415
+ An upgrade request first passes the Origin check: `origins` lists the `scheme://host[:port]` values a browser may connect from, exactly as a browser sends them, such as `https://chat.example.com` or `chrome-extension://<id>` (a lowercase scheme and no path, trailing slash, query, fragment, or user; another form, and `"null"`, is refused at startup), and a request with another Origin, or none, gets `403` without reaching the route. `"any"` skips the check, for clients that are not browsers.
1416
+ A route holds at most `maxConnections` sockets at once (256 by default), counting upgrades still in `accept`; one more gets `503` before `accept` runs.
1417
+ Then `accept` authenticates and authorizes the request before any socket opens: it returns `{ data }`, which every handler reads as `socket.data`, or a `Response` such as `401` or `403` that refuses the upgrade. An `accept` that throws answers `500` like a failing `handle`.
1418
+ Browsers cannot set headers on a WebSocket, so a browser client proves itself with a ticket: one-time and short-lived in the query, so a ticket that leaks from a log or the history opens nothing, or carried in `Sec-WebSocket-Protocol`, which `accept` answers by returning the chosen protocol in `headers`.
1419
+ Never combine `origins: "any"` with cookie authentication: the browser sends the cookie from any site, so any page its user opens could take over their socket (cross-site WebSocket hijacking).
1420
+ Each message reaches `message` as it arrives. A message over `maxMessageBytes` (64 KiB by default) closes the socket with `1009`, and more than `rate.messages` in `rate.perMs` (120 a minute by default) closes it with `1008`.
1421
+ `socket.send` answers `"sent"`, `"queued"` when the client reads slower than the route sends, or `"dropped"`. `maxBufferedBytes` (1 MiB by default) is checked before each `send`: once that many bytes wait for a client that stopped reading, the next `send` answers `"dropped"` and the host cuts the socket without a close frame, so the client and the route's `close` both see `1006`. A `send` that finds fewer waiting goes out whole, so a single message larger than the limit still reaches a client that reads, and the bytes waiting can reach the limit plus one message. A `send` on a closed socket answers `"dropped"` too, and an empty message is `"sent"`.
1422
+ Every limit (`maxMessageBytes`, `maxBufferedBytes`, `maxConnections`, `rate.messages`, `rate.perMs`) must be a positive integer; another value is refused at startup with an error naming the route and field.
1423
+ The handlers' return values are ignored and a returned promise is awaited, so `message: (socket, message) => socket.send(message)` is a complete echo.
1424
+ A handler that throws or rejects closes only its own socket, with `1011`, and logs the route and listener like a failing `handle`.
1425
+ When the host shuts down, every open socket is closed with `1001`, and the shutdown waits up to five seconds for the routes' `close` handlers before it stops the services.
1426
+
1407
1427
  <!-- example: examples/http.ts -->
1408
1428
  ```ts
1409
1429
  import { definePlugin } from "pi-roundtable";
@@ -1423,6 +1443,45 @@ export const health = definePlugin({
1423
1443
  ],
1424
1444
  }),
1425
1445
  });
1446
+
1447
+ /**
1448
+ * A route with `websocket` also takes upgrades. The Origin check and `accept` run before any
1449
+ * socket opens, so a browser on another site, or a client without a ticket, never connects.
1450
+ * A ticket opens one socket: `accept` spends it, so one that leaks from a log or the browser's
1451
+ * history opens nothing. Whoever issues the tickets should also let them expire within seconds.
1452
+ */
1453
+ export const echo = (tickets: Set<string>) =>
1454
+ definePlugin({
1455
+ name: "echo",
1456
+ setup: () => ({
1457
+ http: [
1458
+ {
1459
+ name: "echo",
1460
+ listener: "public",
1461
+ path: { exact: "/echo" },
1462
+ methods: ["GET"],
1463
+ handle: () => new Response("Upgrade Required", { status: 426 }),
1464
+ websocket: {
1465
+ origins: ["https://chat.example.com"],
1466
+ accept: (request) => {
1467
+ const ticket = URL.parse(request.url)?.searchParams.get("ticket");
1468
+ return ticket && tickets.delete(ticket)
1469
+ ? { data: { since: Date.now() } }
1470
+ : new Response("Unauthorized", { status: 401 });
1471
+ },
1472
+ maxMessageBytes: 4096,
1473
+ rate: { messages: 20, perMs: 10_000 },
1474
+ maxBufferedBytes: 64 * 1024,
1475
+ maxConnections: 50,
1476
+ message: (socket, message) => {
1477
+ // "dropped" means the client stopped reading and the host cut it; an echo has nothing to resend.
1478
+ socket.send(message);
1479
+ },
1480
+ },
1481
+ },
1482
+ ],
1483
+ }),
1484
+ });
1426
1485
  ```
1427
1486
  <!-- /example -->
1428
1487
 
@@ -1664,8 +1723,16 @@ Call `context.turns.run(input)` inside your claim's queue task to run a turn in
1664
1723
  It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
1665
1724
  It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
1666
1725
  Supply `reply(result)` to handle the reply yourself.
1667
- `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, and `reply`.
1668
- It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose agent server has not provided a runtime.
1726
+ `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, `reply`, and `conversation`.
1727
+ It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
1728
+
1729
+ Before each turn runs, `context.turns.run` records its conversation in the host's registry, `CONVERSATIONS`: at the first turn its key, surface, kind, visibility, owner, and title, and at every later turn only that it was active.
1730
+ `conversation: { visibility: "private" }` records it as the speaker's own (`principalId` is `speaker.id`); without it the conversation is `shared`.
1731
+ `conversation.title` names it at its first turn; `setTitle(key, title)` renames it later.
1732
+ A turn whose conversation cannot be recorded does not run, and the call rejects.
1733
+ The registry records and never refuses: who may speak in a conversation stays your claim's decision, which may read `get(key)` to check the owner.
1734
+ `list({ principal })` gives one principal's conversations and `list()` every one, the most recently active first; the web console lists them too.
1735
+ No session file moves, and a conversation from before the registry is recorded at its next turn.
1669
1736
 
1670
1737
  <!-- example: examples/study-room.ts -->
1671
1738
  ```ts
@@ -1884,6 +1951,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
1884
1951
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
1885
1952
  | `prompts(channel, speaker?)` | The owner's way to approve a held action or answer `ask_user` inside a running turn, as `OwnerPrompts`: `confirm` and `ask` | the action is held until the owner's next message |
1886
1953
  | `interim(channel)` | 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 | only the final reply is posted |
1954
+ | `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 }`. The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
1887
1955
 
1888
1956
  The host starts each surface as `surface:<prefix>` at the contributing plugin's place in the order, before that plugin's own services.
1889
1957
  It stops surfaces in reverse order, like other services.
@@ -2032,6 +2100,148 @@ export function fakeChat(surface: FakeSurface) {
2032
2100
 
2033
2101
  A test boots a host with the plugin, writes through the surface, and reads what came out (`examples/fake-surface.test.ts` does this with `Roundtable` and `silentLogger`, and also covers the refusals above).
2034
2102
 
2103
+ ### `adapters`: the chat networks the host talks through
2104
+
2105
+ `adapters` lists the chat networks the host talks through, each made by its adapter's factory.
2106
+ In 0.8 the one adapter is Discord: `discord()` from `pi-roundtable/discord` takes the same settings as the top-level `discord`, and a host configured either way assembles the same plugins in the same order.
2107
+ The top-level `discord` stays the form `roundtable init` writes; configure Discord once, in one place or the other.
2108
+ A chat network that comes as a plugin, such as pi-roundtable-webchat's `webChat()`, stays in `plugins`.
2109
+
2110
+ <!-- example: examples/discord-adapter.ts -->
2111
+ ```ts
2112
+ import type { RoundtableConfig } from "pi-roundtable";
2113
+ import { discord } from "pi-roundtable/discord";
2114
+
2115
+ /**
2116
+ * Discord as an adapter: `discord()` takes the same settings as the top-level `discord`, and the
2117
+ * host assembles the same plugins in the same order. Configure Discord in one place, not both.
2118
+ */
2119
+ export function withDiscordAdapter(
2120
+ env: (name: string) => string,
2121
+ dataDir: string,
2122
+ ): RoundtableConfig {
2123
+ return {
2124
+ owner: { id: env("OWNER_ID"), name: env("OWNER_NAME") },
2125
+ adapters: [
2126
+ discord({
2127
+ token: env("DISCORD_TOKEN"),
2128
+ guild: env("DISCORD_GUILD_ID"),
2129
+ entryChannel: env("DISCORD_ENTRY_CHANNEL_ID"),
2130
+ }),
2131
+ ],
2132
+ database: { url: env("DATABASE_URL") },
2133
+ dataDir,
2134
+ model: env("MODEL"),
2135
+ http: { publicUrl: env("PUBLIC_URL") },
2136
+ };
2137
+ }
2138
+ ```
2139
+ <!-- /example -->
2140
+
2141
+ `examples/discord-adapter.test.ts` checks that both forms assemble the same plugins.
2142
+ `roundtable doctor` reads a Discord adapter's settings as it reads the top-level `discord`.
2143
+ The types are `AdapterConfig` and `DiscordConfig` from `pi-roundtable`, and `DiscordAdapterConfig` from `pi-roundtable/discord`.
2144
+
2145
+ ### A host without Discord
2146
+
2147
+ Leave `discord` out of `roundtable.config.ts` and the host runs without it: no Discord plugin, no agent server, no agents, and no skills.
2148
+ The runtime plugin still builds the runtime, so every claim that runs turns through `context.turns` works, over the surfaces your plugins bring.
2149
+ `http` is optional too; without it the host opens no listener, and a route then names a listener that is not configured.
2150
+
2151
+ What needs Discord is refused or left out rather than failing later:
2152
+
2153
+ - `agents`, `skills` (anything but `false`), and `ops.agent` are configuration errors. `ops: { conversation: "<surface>:<id>" }` needs a chat surface that serves it, a claim that owns it and takes background turns, and a plugin that contributes the `owner` background target, or the host does not start; without Discord nothing contributes `owner` unless a plugin of yours does. The web chat takes no error reports in 0.8.
2154
+ - `notify_owner` is not registered, since there are no owner's messages to send to.
2155
+ - `schedule_*` and `delegate_task` are not registered: their runs are turns of the [background target](#backgroundtargets-whose-turn-a-schedule-or-delegated-task-is) named `owner`, which the agent server contributes, so without it none could start.
2156
+ A plugin that contributes `owner`, and a claim that takes its background turns, bring them back; they then work in a conversation a chat surface carries, posting their runs there, and refuse elsewhere.
2157
+ - `roundtable doctor` skips the Discord checks.
2158
+
2159
+ Pi's runtime still needs the `compact_session` tool in every session, from the Pi package pi-self-compact: load it from a plugin with `piPackages: ["pi-self-compact"]`, as the `plugins/self-compact.ts` of a project `roundtable init` creates does, or the preflight stops the start and says so.
2160
+ `roundtable init --adapter web` creates a host without Discord around the [web chat](#web-chat-pi-roundtable-webchat).
2161
+ A host that keeps Discord may write it as an adapter instead, as [`adapters`](#adapters-the-chat-networks-the-host-talks-through) describes.
2162
+
2163
+ <!-- example: examples/headless.ts -->
2164
+ ```ts
2165
+ import { definePlugin, type RoundtableConfig } from "pi-roundtable";
2166
+ import type { FakeSurface } from "./fake-surface.ts";
2167
+ import { studyRoom } from "./study-room.ts";
2168
+
2169
+ /** A plugin that brings a chat network and nothing else; the study room's claim answers its rooms. */
2170
+ export function chatNetwork(surface: FakeSurface) {
2171
+ return definePlugin({
2172
+ name: "chat-network",
2173
+ setup: () => ({ surfaces: [surface] }),
2174
+ });
2175
+ }
2176
+
2177
+ /**
2178
+ * A host without Discord: no `discord` key, so no Discord plugin, no agent server, no agents and
2179
+ * no skills; and no `http`, so no listener opens until a plugin needs one. Conversations come
2180
+ * through the plugins' own chat surfaces, and `context.turns` runs them on the host's runtime,
2181
+ * Pi's unless a plugin fills the `runtime` slot.
2182
+ */
2183
+ export function studyHall(
2184
+ surface: FakeSurface,
2185
+ where: { databaseUrl: string; dataDir: string },
2186
+ ): RoundtableConfig {
2187
+ return {
2188
+ name: "Study Hall",
2189
+ owner: { id: "owner", name: "Ada" },
2190
+ database: { url: where.databaseUrl },
2191
+ dataDir: where.dataDir,
2192
+ model: "anthropic/claude-sonnet-5-5",
2193
+ plugins: [chatNetwork(surface), studyRoom],
2194
+ };
2195
+ }
2196
+ ```
2197
+ <!-- /example -->
2198
+
2199
+ `examples/headless.test.ts` boots this configuration over PostgreSQL with the echo runtime in place of Pi, and a study room answers through the fake surface.
2200
+ `testHost({ discord: false })` boots the same kind of host for a plugin's tests.
2201
+
2202
+ ### Web chat: pi-roundtable-webchat
2203
+
2204
+ [pi-roundtable-webchat][webchat-package] is a chat network that comes as a plugin.
2205
+ `webChat({ verifier, access, personas, origins })` adds a chat surface whose conversations have keys `web:<conversation>`, the claim that runs each message as a turn of its conversation's persona through `context.turns`, and a REST API and a WebSocket under `/chat` on the host's `public` listener.
2206
+ People an OpenID Connect provider signs in open private conversations, see each turn's text and tools as it runs, and answer its approval cards.
2207
+ It needs no Discord: a host whose configuration has no `discord` and lists `webChat(...)` in `plugins` is a web-only assistant.
2208
+
2209
+ #### `roundtable init --adapter web`
2210
+
2211
+ `roundtable init --adapter web` writes a project without Discord around it: no `agents.ts` and no shared persona, a `roundtable.config.ts` that lists `webChat(...)` with an `oidcJwtVerifier`, an access map whose members and admins come from the token's roles, and one persona, `assistant`, whose prompt is `persona/assistant.md`.
2212
+ The persona's `selection` names its tools (the hello plugin's), so tools a later plugin adds, such as pi-web-access's, stay out until you add them by name.
2213
+ `package.json` pins pi-roundtable-webchat at the core's version and pi-self-compact, and `.env.example` asks for `OWNER_NAME`, `DATABASE_URL`, `MODEL`, `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL`, `CHAT_MEMBER_ROLES`, and `CHAT_ORIGINS`, with no Discord variable.
2214
+ The host listens on `127.0.0.1:3000`; serve it over HTTPS through a reverse proxy.
2215
+ `--adapter discord` is the default and writes the Discord project.
2216
+
2217
+ #### Protocol
2218
+
2219
+ A browser asks for a one-time ticket with `POST /chat/tickets` and `Authorization: Bearer <token>`, then opens `/chat/socket` offering the subprotocols `roundtable.webchat.v1` and `ticket.<ticket>`; another client sends the bearer token on the upgrade.
2220
+ The token never travels in a URL.
2221
+ Each WebSocket message is one JSON object with a `type`: the client sends `send`, `stop`, `approval`, `answer`, and `auth`; the server answers with `ready`, `accepted`, `typing`, `stoppable`, `progress`, `reply`, `failed`, `prompt`, `prompt_closed`, `reauth`, and `error`.
2222
+ `progress` carries the turn's [`TurnProgress`](#surfaces-a-chat-network-of-your-own) events, never the thinking and never a tool's full arguments; `reply` carries the answer in full markdown.
2223
+ The REST API lists a person's conversations, opens one, and reads its messages.
2224
+ The package README documents every frame, error code, close code, and limit.
2225
+
2226
+ #### Security model
2227
+
2228
+ - A conversation belongs to the person who opened it: the claim checks the [conversation registry](#services-what-plugins-provide-to-each-other) inside the conversation's queue before every turn, and only that person may list, read, write in, stop, or answer it.
2229
+ - Tokens are checked on every REST call, every upgrade, and every `auth` frame. By default only a person's access token passes: a token without a scope or app roles, or an app-only token, is refused. A socket closes when its token expires.
2230
+ - `origins` is required, and checked on every upgrade and every browser request.
2231
+ - Each person has bounded sockets, tickets, conversations opened per hour, and turns running or queued at once; frames are bounded in size and rate.
2232
+ - An approval card goes to the conversation's person only, at the tier the held call needs.
2233
+ - No token claim makes anyone the owner; owners are listed by speaker id, `oidc:<base64url(issuer)>:<subject>`.
2234
+
2235
+ #### Provider settings
2236
+
2237
+ The verifier is generic; what makes it safe is how it is pointed at your provider.
2238
+ The README's [provider settings][webchat-provider-settings] explain, with Microsoft Entra ID as the example, why to name people by a claim that stays the same across app registrations (`subjectClaim`), pin the tenant in `check`, accept access tokens only, refuse app-only tokens, merge issuers of one tenant only (`speakerIssuer`), and mind the guests `everyone` admits.
2239
+
2240
+ The web chat takes no attachments and no background turns yet: leave `schedule_*` and `delegate_task` out of a web persona's `selection`, and `web_search` and `fetch_content` unless people may make the host fetch any address, internal ones included.
2241
+
2242
+ [webchat-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md
2243
+ [webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
2244
+
2035
2245
  ### What plugins do not extend
2036
2246
 
2037
2247
  A plugin adds slash commands through the Discord plugin's registrar and never receives the composed commands (see [slash commands](#slash-commands-commandsadd)).
@@ -2058,10 +2268,9 @@ Inspect the ready-made implementations in the [codex-images][codex-images-templa
2058
2268
 
2059
2269
  The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, adds two more plugins, `mcpConnectors` and `remoteMcp`.
2060
2270
  The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
2061
- Install it with `bun add pi-roundtable-mcp` after the next lockstep release publishes the migrated package.
2062
- Until then, npm's MCP 0.4.1 requires core below 0.6.0 and is not compatible with core 0.7.x.
2271
+ Install it with `bun add pi-roundtable-mcp`.
2063
2272
 
2064
- Five official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
2273
+ Six official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
2065
2274
  The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
2066
2275
 
2067
2276
  - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
@@ -2069,6 +2278,7 @@ The [site guide source files][package-guides] describe installation, requirement
2069
2278
  - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
2070
2279
  - [pi-roundtable-sandbox][sandbox-package]: [site guide source][sandbox-guide] · sealed guest channels on native Linux Docker with a host-only credential broker and allow-listed tools.
2071
2280
  - [pi-roundtable-mcp][mcp-package]: [site guide source][mcp-guide] · MCP connectors through a gateway and remote MCP endpoints for agent turns and owner-granted Discord channel tools.
2281
+ - [pi-roundtable-webchat][webchat-package]: [site guide source][webchat-guide] · a WebSocket chat for people an OpenID Connect provider signs in, with private conversations, live progress, and approval cards; see [web chat](#web-chat-pi-roundtable-webchat).
2072
2282
 
2073
2283
  [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
2074
2284
  [coding-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/coding/README.md
@@ -2081,6 +2291,7 @@ The [site guide source files][package-guides] describe installation, requirement
2081
2291
  [web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
2082
2292
  [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
2083
2293
  [mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
2294
+ [webchat-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/webchat-package.mdx
2084
2295
  [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
2085
2296
  [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
2086
2297
  [web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
@@ -2170,7 +2381,7 @@ After setup, `conversations` routes to the plugin's claims, `surfaces` contains
2170
2381
  | `database` | A Bun `SQL` for `context.database()`; without one it throws `no database is configured` |
2171
2382
  | `providers` | Provider slots filled as if another plugin filled them |
2172
2383
  | `surfaces` | Chat surfaces besides the plugin's own, such as the fake surface of `examples/fake-surface.ts`: they are in `context.surfaces`, start after the plugin's services with the harness routing their messages to `conversations`, and stop with `stop()` |
2173
- | `services` | What the plugin reads from `context.services`: one `servicePair(KEY, { ... })` for each service, with the members you give it. `servicePair(AGENTS, { runtime })` is the runtime `context.turns` runs on. Reading a member you did not give throws a `PluginError` that names the option to add; a service you did not give reads as absent to `find`, and `get` says to give it, except for the ones below |
2384
+ | `services` | What the plugin reads from `context.services`: one `servicePair(KEY, { ... })` for each service, with the members you give it. `servicePair(RUNTIME, runtime)` is the runtime `context.turns` runs on, given whole; `servicePair(AGENTS, { runtime })` also works when no `RUNTIME` is given. Reading a member you did not give throws a `PluginError` that names the option to add; a service you did not give reads as absent to `find`, and `get` says to give it, except for the ones below |
2174
2385
  | `conversations` | Methods that replace the router's, such as `stop`, for a plugin that calls them |
2175
2386
  | `turns` | A `ConversationTurns` that replaces the default one |
2176
2387
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name: `{ "openai-codex": "key" }`. A provider not listed reads as having none, as on a host that is not logged in |
@@ -2239,7 +2450,7 @@ Only `commands` and `guard` are given; a plugin that reads another member of `DI
2239
2450
 
2240
2451
  `testPlugin` sets up your plugin alone.
2241
2452
  For tests that need the agent server, modules, stores, or the host's session-tool setup order, use `testHost(options?)`.
2242
- It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins for Discord and the runtime; gate the suite with `describeDb`:
2453
+ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins for Discord and the runtime, or without Discord; gate the suite with `describeDb`:
2243
2454
 
2244
2455
  | Option | What it gives the host |
2245
2456
  |---|---|
@@ -2247,7 +2458,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
2247
2458
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
2248
2459
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
2249
2460
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
2250
- | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()` |
2461
+ | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()`; `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration |
2251
2462
 
2252
2463
  It returns:
2253
2464
 
@@ -2266,6 +2477,15 @@ It returns:
2266
2477
 
2267
2478
  `holdChain(rules)` is in `pi-roundtable/kit`: the chain the host links from every plugin's hold rules, to test a rule set without a harness.
2268
2479
 
2480
+ ### `describeSurfaceContract`: what every chat surface keeps
2481
+
2482
+ A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
2483
+ `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, not the owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it.
2484
+ The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
2485
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, and, with a `stranger`, that the stranger's answer leaves the person's approval open.
2486
+ A check for a part the surface does not offer passes.
2487
+ `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
2488
+
2269
2489
  ## What happens when the bot starts and stops
2270
2490
 
2271
2491
  `roundtable start` checks Bun, `.env`, the configuration, the plugins, the model login, and the public URL without using the network.
@@ -2274,10 +2494,10 @@ The host's `run()` handles startup:
2274
2494
 
2275
2495
  1. Each plugin's `replaces` is applied: the plugin that provided a replaced service is dropped, and the replacement stands where it stood.
2276
2496
  Providers are then resolved: each slot from the plugin that fills it, or the core's default.
2277
- The agent server builds its runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
2497
+ The runtime plugin builds the runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
2278
2498
  2. The database is opened and every plugin's migrations run, in plugin order.
2279
2499
  3. Every plugin's `setup` runs, in plugin order.
2280
- The order is the built-ins (`memory`, `schedule-store`, `discord`, `modules`, `discord-admin`, `skills`, `agent-server`, `seeds`; an addon that is switched off is not there), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
2500
+ The order is the built-ins (`memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord`, `discord-admin`, `skills`, `agent-server`, or `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
2281
2501
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
2282
2502
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
2283
2503
  5. Every plugin's `preflight` runs, in plugin order.
@@ -2357,9 +2577,9 @@ These are the messages as the code writes them, with `<...>` where your names go
2357
2577
  | `plugin <b>: background target "<name>" is already registered by plugin <a>. Keep one target per name.` | Give each background target one plugin, or rename one of them |
2358
2578
  | `no plugin contributes the background target "<name>"` (a schedule's last status, or a skipped turn) | Contribute the target from the plugin whose claim serves it, or cancel the schedule |
2359
2579
  | `no persona is registered for the conversation kind "<kind>". A plugin adds one with personas: [...], or its claim must start conversations of a kind that has one.` (a failed turn) | Contribute a persona of that kind, or run the turn with a kind that has one |
2360
- | `no runtime provider is configured: the agent server builds the Pi runtime when no plugin fills the runtime slot` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; check `filled` first |
2580
+ | `no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; read `services.get(RUNTIME)` instead |
2361
2581
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
2362
- | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `discord`, `modules`, `discord-admin`, `skills`, `agent-server`, `seeds`, and `schedules` |
2582
+ | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
2363
2583
  | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
2364
2584
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
2365
2585
  | `route <name> is registered twice`, `routes <a> and <b> overlap on listener <id>` | Give each route its own name and a path no other route can take |
@@ -2405,6 +2625,9 @@ Migrations are idempotent, so start again once the cause is fixed.
2405
2625
  | `config model: expected <provider>/<id>, got "<value>". Write it like anthropic/claude-sonnet-5-5.` | Write the model as `<provider>/<id>` |
2406
2626
  | `config locale: expected a locale, en or zh-TW, got "<value>". Fix the value in roundtable.config.ts.` | Use `en` or `zh-TW` |
2407
2627
  | `config <key>: cannot read <path>: <reason>. Create the file or fix the path.` | Create the prompt file, or fix the path in `prompts` |
2628
+ | `config adapters[<n>]: unknown adapter "<name>". The adapters are discord; …` | Use `discord()` from `pi-roundtable/discord`; a chat network that comes as a plugin, such as `webChat()`, belongs in `plugins` |
2629
+ | `config adapters[<n>]: Discord is configured twice, …` | Keep the top-level `discord` or the Discord adapter, not both |
2630
+ | `required tools are not registered: compact_session. compact_session comes from the Pi package pi-self-compact: …` | `bun add pi-self-compact`, and load it from a plugin with `piPackages: ["pi-self-compact"]`, as a project `roundtable init` creates does in `plugins/self-compact.ts` |
2408
2631
 
2409
2632
  ### Missing settings
2410
2633
 
@@ -2488,6 +2711,7 @@ Import from the entries listed below; source area files are internal.
2488
2711
  | `AGENT_SERVER_PLUGIN` | `pi-roundtable` | value |
2489
2712
  | `AGENT_SERVER_PRIORITY` | `pi-roundtable` | value |
2490
2713
  | `AGENT_TEAM_SERVICE` | `pi-roundtable` | value |
2714
+ | `AdapterConfig` | `pi-roundtable` | type |
2491
2715
  | `Admission` | `pi-roundtable` | type |
2492
2716
  | `Agent` | `pi-roundtable` | type |
2493
2717
  | `AgentChange` | `pi-roundtable` | type |
@@ -2508,6 +2732,7 @@ Import from the entries listed below; source area files are internal.
2508
2732
  | `AvatarMode` | `pi-roundtable` | type |
2509
2733
  | `AvatarStudio` | `pi-roundtable` | type |
2510
2734
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
2735
+ | `CONVERSATIONS` | `pi-roundtable` | value |
2511
2736
  | `BackgroundTarget` | `pi-roundtable` | type |
2512
2737
  | `BackgroundTurn` | `pi-roundtable` | type |
2513
2738
  | `BackgroundTurns` | `pi-roundtable` | type |
@@ -2519,6 +2744,10 @@ Import from the entries listed below; source area files are internal.
2519
2744
  | `Contribution` | `pi-roundtable` | type |
2520
2745
  | `ConversationKind` | `pi-roundtable` | type |
2521
2746
  | `ConversationPort` | `pi-roundtable` | type |
2747
+ | `ConversationRecord` | `pi-roundtable` | type |
2748
+ | `ConversationRegistration` | `pi-roundtable` | type |
2749
+ | `ConversationRegistry` | `pi-roundtable` | type |
2750
+ | `ConversationVisibility` | `pi-roundtable` | type |
2522
2751
  | `ConversationTurnInput` | `pi-roundtable` | type |
2523
2752
  | `ConversationTurns` | `pi-roundtable` | type |
2524
2753
  | `DELEGATION` | `pi-roundtable` | value |
@@ -2529,6 +2758,7 @@ Import from the entries listed below; source area files are internal.
2529
2758
  | `DelegationOutcome` | `pi-roundtable` | type |
2530
2759
  | `DelegationRequest` | `pi-roundtable` | type |
2531
2760
  | `Delegator` | `pi-roundtable` | type |
2761
+ | `DiscordConfig` | `pi-roundtable` | type |
2532
2762
  | `DrainOptions` | `pi-roundtable` | type |
2533
2763
  | `EventHandlers` | `pi-roundtable` | type |
2534
2764
  | `EventSink` | `pi-roundtable` | type |
@@ -2597,10 +2827,12 @@ Import from the entries listed below; source area files are internal.
2597
2827
  | `RoundtableConfig` | `pi-roundtable` | type |
2598
2828
  | `RoundtableOptions` | `pi-roundtable` | type |
2599
2829
  | `RoundtablePlugin` | `pi-roundtable` | type |
2830
+ | `RouteSocket` | `pi-roundtable` | type |
2600
2831
  | `RuntimeDeps` | `pi-roundtable` | type |
2601
2832
  | `RuntimeFactory` | `pi-roundtable` | type |
2602
2833
  | `SCHEDULES` | `pi-roundtable` | value |
2603
2834
  | `PRECHECKS` | `pi-roundtable` | value |
2835
+ | `RUNTIME` | `pi-roundtable` | value |
2604
2836
  | `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
2605
2837
  | `Precheck` | `pi-roundtable` | type |
2606
2838
  | `PrecheckContext` | `pi-roundtable` | type |
@@ -2654,10 +2886,15 @@ Import from the entries listed below; source area files are internal.
2654
2886
  | `TransientTask` | `pi-roundtable` | type |
2655
2887
  | `TurnAttachments` | `pi-roundtable` | type |
2656
2888
  | `TurnEndEvent` | `pi-roundtable` | type |
2889
+ | `TurnProgress` | `pi-roundtable` | type |
2890
+ | `TurnProgressEvent` | `pi-roundtable` | type |
2657
2891
  | `TurnEvent` | `pi-roundtable` | type |
2658
2892
  | `TurnRequest` | `pi-roundtable` | type |
2659
2893
  | `TurnResult` | `pi-roundtable` | type |
2660
2894
  | `TurnSelection` | `pi-roundtable` | type |
2895
+ | `WebSocketAccept` | `pi-roundtable` | type |
2896
+ | `WebSocketRoute` | `pi-roundtable` | type |
2897
+ | `WebSocketSendResult` | `pi-roundtable` | type |
2661
2898
  | `Weekday` | `pi-roundtable` | type |
2662
2899
  | `attachReplyFile` | `pi-roundtable` | value |
2663
2900
  | `withReplyFiles` | `pi-roundtable` | value |
@@ -2684,6 +2921,11 @@ Import from the entries listed below; source area files are internal.
2684
2921
  | `TestPluginResult` | `pi-roundtable/testing` | type |
2685
2922
  | `TestStore` | `pi-roundtable/testing` | type |
2686
2923
  | `describeDb` | `pi-roundtable/testing` | value |
2924
+ | `describeSurfaceContract` | `pi-roundtable/testing` | value |
2925
+ | `checkSurfaceContract` | `pi-roundtable/testing` | value |
2926
+ | `SurfaceContractFailure` | `pi-roundtable/testing` | type |
2927
+ | `SurfaceContractSubject` | `pi-roundtable/testing` | type |
2928
+ | `SurfaceObservation` | `pi-roundtable/testing` | type |
2687
2929
  | `eagerText` | `pi-roundtable/testing` | value |
2688
2930
  | `fakeDiscord` | `pi-roundtable/testing` | value |
2689
2931
  | `fakePrecheck` | `pi-roundtable/testing` | value |
@@ -2844,6 +3086,7 @@ Import from the entries listed below; source area files are internal.
2844
3086
  | `ComposedCommands` | `pi-roundtable/discord` | type |
2845
3087
  | `DISCORD` | `pi-roundtable/discord` | value |
2846
3088
  | `DISCORD_ADMIN_TOOLS` | `pi-roundtable/discord` | value |
3089
+ | `DiscordAdapterConfig` | `pi-roundtable/discord` | type |
2847
3090
  | `DiscordConnection` | `pi-roundtable/discord` | type |
2848
3091
  | `DiscordServices` | `pi-roundtable/discord` | type |
2849
3092
  | `InteractionContribution` | `pi-roundtable/discord` | type |
@@ -2858,6 +3101,7 @@ Import from the entries listed below; source area files are internal.
2858
3101
  | `agentPanel` | `pi-roundtable/discord` | value |
2859
3102
  | `commandGuard` | `pi-roundtable/discord` | value |
2860
3103
  | `composeCommands` | `pi-roundtable/discord` | value |
3104
+ | `discord` | `pi-roundtable/discord` | value |
2861
3105
  | `ephemeralPanel` | `pi-roundtable/discord` | value |
2862
3106
  | `fetchManagedChannel` | `pi-roundtable/discord` | value |
2863
3107
  | `groupOption` | `pi-roundtable/discord` | value |
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import { $ } from "bun";
3
+ import { defineRoundtable, type RoundtableConfig } from "pi-roundtable";
4
+ import { silentLogger } from "pi-roundtable/testing";
5
+ import { withDiscordAdapter } from "./discord-adapter.ts";
6
+
7
+ const ENV: Record<string, string> = {
8
+ OWNER_ID: "100000000000000001",
9
+ OWNER_NAME: "Ada",
10
+ DISCORD_TOKEN: "token",
11
+ DISCORD_GUILD_ID: "900000000000000001",
12
+ DISCORD_ENTRY_CHANNEL_ID: "900000000000000002",
13
+ DATABASE_URL: "postgres://localhost/roundtable",
14
+ MODEL: "anthropic/claude-sonnet-5-5",
15
+ PUBLIC_URL: "https://bot.example.com",
16
+ };
17
+
18
+ test("a Discord adapter assembles the plugins the top-level discord does, in the same order", async () => {
19
+ const dataDir = `${Bun.env.TMPDIR ?? "/tmp"}/discord-adapter-${crypto.randomUUID()}`;
20
+ const adapted = withDiscordAdapter((name) => ENV[name] ?? "", dataDir);
21
+ const { adapters: _adapters, ...rest } = adapted;
22
+ const topLevel: RoundtableConfig = {
23
+ ...rest,
24
+ discord: {
25
+ token: "token",
26
+ guild: "900000000000000001",
27
+ entryChannel: "900000000000000002",
28
+ },
29
+ };
30
+ const names = async (config: RoundtableConfig) =>
31
+ (await defineRoundtable(config, { logger: silentLogger() })).plugins.map(
32
+ (plugin) => plugin.name,
33
+ );
34
+ try {
35
+ const plugins = await names(adapted);
36
+ expect(plugins).toContain("discord");
37
+ expect(plugins).toContain("agent-server");
38
+ expect(plugins).toEqual(await names(topLevel));
39
+ } finally {
40
+ await $`rm -rf ${dataDir}`;
41
+ }
42
+ });
@@ -0,0 +1,26 @@
1
+ import type { RoundtableConfig } from "pi-roundtable";
2
+ import { discord } from "pi-roundtable/discord";
3
+
4
+ /**
5
+ * Discord as an adapter: `discord()` takes the same settings as the top-level `discord`, and the
6
+ * host assembles the same plugins in the same order. Configure Discord in one place, not both.
7
+ */
8
+ export function withDiscordAdapter(
9
+ env: (name: string) => string,
10
+ dataDir: string,
11
+ ): RoundtableConfig {
12
+ return {
13
+ owner: { id: env("OWNER_ID"), name: env("OWNER_NAME") },
14
+ adapters: [
15
+ discord({
16
+ token: env("DISCORD_TOKEN"),
17
+ guild: env("DISCORD_GUILD_ID"),
18
+ entryChannel: env("DISCORD_ENTRY_CHANNEL_ID"),
19
+ }),
20
+ ],
21
+ database: { url: env("DATABASE_URL") },
22
+ dataDir,
23
+ model: env("MODEL"),
24
+ http: { publicUrl: env("PUBLIC_URL") },
25
+ };
26
+ }
@@ -98,7 +98,7 @@ export const createEchoRuntime: RuntimeFactory = (deps) => {
98
98
 
99
99
  /**
100
100
  * A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
101
- * agent server builds the Pi runtime. One plugin may fill it.
101
+ * runtime plugin builds the Pi runtime. One plugin may fill it.
102
102
  */
103
103
  export const echoRuntime = definePlugin({
104
104
  name: "echo-runtime",