pi-roundtable 0.2.1 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/README.zh-TW.md +2 -2
  4. package/docs/plugins.md +100 -5
  5. package/examples/shared-services.test.ts +28 -0
  6. package/examples/shared-services.ts +31 -0
  7. package/package.json +6 -3
  8. package/src/cli/add-plugin.ts +4 -3
  9. package/src/cli/cli.ts +3 -0
  10. package/src/cli/templates.ts +21 -4
  11. package/src/core/contract/services.ts +7 -0
  12. package/src/core/define-roundtable.ts +2 -0
  13. package/src/core/host.ts +7 -0
  14. package/src/core/http/listeners.ts +5 -25
  15. package/src/core/modules/delegation/web-research-worker.ts +14 -1
  16. package/src/core/plugin.ts +14 -0
  17. package/src/core/registry/contributions.ts +1 -0
  18. package/src/core/registry/services.ts +73 -1
  19. package/src/core/runtime/session-factory.ts +1 -1
  20. package/src/core/shared/package-dir.ts +8 -5
  21. package/src/core/shared/unix-server.ts +26 -0
  22. package/src/core/testing/partial.ts +21 -0
  23. package/src/core/testing/recording-logger.ts +44 -0
  24. package/src/core/testing/test-host.ts +14 -1
  25. package/src/discord/index.ts +1 -1
  26. package/src/kit/channels.ts +1 -1
  27. package/src/kit/domain.ts +1 -1
  28. package/src/kit/holds.ts +1 -1
  29. package/src/kit/index.ts +2 -1
  30. package/src/kit/judging.ts +1 -1
  31. package/src/kit/memory.ts +1 -1
  32. package/src/kit/mirror.ts +1 -1
  33. package/src/kit/presentation.ts +1 -1
  34. package/src/kit/process.ts +5 -0
  35. package/src/kit/shell.ts +1 -1
  36. package/src/kit/skills.ts +1 -1
  37. package/src/kit/support.ts +1 -1
  38. package/src/kit/threads.ts +1 -1
  39. package/src/kit/tools.ts +1 -1
  40. package/src/kit/worker.ts +1 -1
  41. package/src/testing.ts +16 -2
  42. package/templates/agents.ts +5 -0
  43. package/templates/official/codex-images/plugin.test.ts.tmpl +118 -0
  44. package/templates/official/codex-images/plugin.ts +215 -0
  45. package/templates/official/dice/plugin.test.ts.tmpl +105 -0
  46. package/templates/official/dice/plugin.ts +166 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - `PluginContext.apiKey(provider)`: the credential the host's model login holds for a provider, such as `openai-codex`, or `undefined` when it holds none. It resolves through the same login the agents use, so a plugin that calls a provider's API needs no login of its own. The value is a secret; keep it out of logs and error messages.
13
+ - `testPlugin` and `testHost` take `apiKeys`, the credentials `context.apiKey` returns by provider. Without it every provider reads as having none, whatever login the machine holds.
14
+ - `roundtable add plugin codex-images` and `roundtable add plugin dice` copy an official plugin into the project and list it in `roundtable.config.ts`, the way `add plugin` copies the `hello` template. `codex-images` fills the `images` slot through the owner's ChatGPT login and the Codex backend, which OpenAI does not document for this use, so it can stop working without notice. `dice` adds a `roll_dice` tool for dice expressions such as `4d6k3`.
15
+ - A documentation site at pi-roundtable.wayneh.tw, in English and Traditional Chinese.
16
+
17
+ ### Changed
18
+
19
+ - `codex-images` and `dice` are reserved names for `add plugin`: it copies the official plugin for them and keeps the `hello` template for every other name.
20
+ - The project `init` writes seeds Guide into the entry channel, so Guide is the coordinator on the first start. Agents that are already stored keep the channel they have.
21
+ - `PluginContext` has a new member, `apiKey`; a hand-written `PluginContext` (only the core's own and tests') needs it.
22
+ - The READMEs no longer say `/roundtable help` opens a control panel. The core registers `/roundtable schedule list` and `/roundtable schedule cancel` only.
23
+
24
+ ## [0.3.0] - 2026-10-01
25
+
26
+ Found by running the first consuming host on the published 0.2.1; each item answers one finding of its friction log.
27
+
28
+ ### Added
29
+
30
+ - `RoundtablePlugin.requires`: the services a plugin reads with `get` in its setup. The host checks the list before any migration or setup and refuses a key no registered plugin provides, one a plugin registered after it provides, and one the plugin provides itself, naming the plugins and the fix.
31
+ - `Services.lazy(KEY)`: a function that returns the service once every plugin is set up, for a plugin that needs a service of one registered after it. Calling it during setup throws a `NotLinkedError`; the host refuses to boot, naming the plugin, when no registered plugin provides the key. A plugin no longer has to keep a shared object that the later plugin fills in. `testPlugin` checks `requires` against the `services` option and answers `lazy` readers after setup.
32
+ - `pi-roundtable/kit`: `packageDir`, the installed package's folder as Pi's `additionalExtensionPaths` takes it (`packageDir(name, import.meta.url)`; the second argument is the file the package is looked up from, so a host finds its own dependencies even when `pi-roundtable` is linked apart from them), and `serveUnix`, an HTTP server on a unix socket without Bun's idle timeout (`serveUnix(socketPath, fetch, { error? })`). They are the core's own, so a worker process of a host no longer copies them.
33
+ - `pi-roundtable/testing`: `partial`, a typed stand-in (`partial<Port>({ ... })`) with only the members the test gives, which names any other member it is asked for; `recordingLogger` with `RecordingLogger` and `RecordedLog`, a logger whose `lines` keep each call's level, fields, and message. Neither needs an `as unknown as` cast.
34
+ - A guide section on developing the core and a host together with `bun link`.
35
+
36
+ ### Changed
37
+
38
+ - `pi-web-access` is a peer dependency (`>=0.35.0 <0.36.0`) instead of a dependency, and a development dependency at 0.35.0. `bun add pi-roundtable` still installs it; a host that depends on its own build of it now has one copy, with no `overrides` entry. A project without it fails at the `modules` plugin's setup with a `PluginError` that names the command to run.
39
+ - `pi-roundtable/kit` and `pi-roundtable/discord` are versioned like the main entry: before 1.0, a breaking change to an exported name comes in a minor release and is listed in the changelog. The signature report already guarded every name; the "unstable, not covered by semver" label is gone.
40
+ - `Services` has a new member, `lazy`; a hand-written `Services` (only the core's own and tests') needs it.
41
+ - The guide's mention of the first consumer by name is gone.
42
+
8
43
  ## [0.2.1] - 2026-10-01
9
44
 
10
45
  ### Added
package/README.md CHANGED
@@ -67,7 +67,7 @@ A fresh project fails only on the credentials you have not entered yet, and says
67
67
  ### `start`
68
68
 
69
69
  `bunx roundtable start` runs the checks that need no network, stops with the same message `doctor` prints when one fails, and otherwise starts the bot.
70
- Once it runs, the agents in `agents.ts` have their channels, and `/roundtable help` opens the control panel.
70
+ Once it runs, the agents in `agents.ts` have their channels, and `/roundtable schedule list` shows their schedules.
71
71
  On `SIGTERM` or `SIGINT` it finishes running work before it stops.
72
72
 
73
73
  ## A plugin
@@ -111,7 +111,7 @@ test("hello greets", async () => {
111
111
 
112
112
  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.
113
113
  Its examples live in [`examples/`](examples), and the test suite runs each of them.
114
- `pi-roundtable/kit` supplies claim, tool and presentation helpers and type-only names for the context’s existing services, and `pi-roundtable/discord` supplies the slash-command registrar, owner-command and panel helpers, and the agent panel, and is the entry that names discord.js types (`pi-roundtable/testing` names a few, through `testHost`'s composed commands); both are unstable before 1.0 and not covered by semver.
114
+ `pi-roundtable/kit` supplies claim, tool and presentation helpers and type-only names for the context’s existing services, and `pi-roundtable/discord` supplies the slash-command registrar, owner-command and panel helpers, and the agent panel, and is the entry that names discord.js types (`pi-roundtable/testing` names a few, through `testHost`'s composed commands); both are versioned like the main entry: before 1.0 a breaking change comes in a minor release and is listed in the changelog.
115
115
 
116
116
  ## Settings
117
117
 
package/README.zh-TW.md CHANGED
@@ -67,7 +67,7 @@ Bun 會自行載入 `.env`,`.gitignore` 也已讓它不進 Git。
67
67
  ### `start`
68
68
 
69
69
  `bunx roundtable start` 先執行不需要網路的檢查,其中任何一項失敗就停下來,並印出與 `doctor` 相同的訊息;全部通過則啟動 bot。
70
- 啟動後,`agents.ts` 裡的智慧體都有了自己的頻道,`/roundtable help` 會開啟控制面板。
70
+ 啟動後,`agents.ts` 裡的智慧體都有了自己的頻道,`/roundtable schedule list` 可以列出它們的排程。
71
71
  收到 `SIGTERM` 或 `SIGINT` 時,它會先讓進行中的工作完成再停止。
72
72
 
73
73
  ## 外掛
@@ -111,7 +111,7 @@ test("hello greets", async () => {
111
111
 
112
112
  [外掛指南](docs/plugins.md)(英文)說明外掛能新增的每個部分(工具、提示詞區段、智慧體、事件、服務、migration、provider、斜線指令、HTTP 路由等等)、啟動與停止的順序,以及每一種啟動錯誤和它的修正方式。
113
113
  指南裡的範例放在 [`examples/`](examples),測試套件會執行每一個範例。
114
- `pi-roundtable/kit` 提供頻道認領(claim)、工具與呈現用的輔助函式,以及 context 現有服務的純型別名稱;`pi-roundtable/discord` 提供斜線指令註冊器、擁有者指令與面板的輔助函式,以及智慧體面板,是會用到 discord.js 型別的入口(`pi-roundtable/testing` 也透過 `testHost` 組合出的指令用到少數幾個)。這兩個入口在 1.0 之前都不穩定,不受語意化版本(semver)保證。
114
+ `pi-roundtable/kit` 提供頻道認領(claim)、工具與呈現用的輔助函式,以及 context 現有服務的純型別名稱;`pi-roundtable/discord` 提供斜線指令註冊器、擁有者指令與面板的輔助函式,以及智慧體面板,是會用到 discord.js 型別的入口(`pi-roundtable/testing` 也透過 `testHost` 組合出的指令用到少數幾個)。這兩個入口的版本規則與主入口相同:1.0 之前,不相容的變更會放在次版本(minor)發佈,並列在變更記錄中。
115
115
 
116
116
  ## 設定
117
117
 
package/docs/plugins.md CHANGED
@@ -33,13 +33,13 @@ Everything a plugin author needs comes from four entries, and nothing else can b
33
33
  |---|---|
34
34
  | `pi-roundtable` | `definePlugin`, `defineTool`, `defineRoundtable`, `ToolRefusal`, `PluginError`, `NotLinkedError`, `Roundtable`, and the types (`Tier`, `Speaker`, `Contribution`, `PluginContext`, and so on) |
35
35
  | `pi-roundtable/testing` | Fixtures for testing plugins, without Discord or a database unless the test explicitly opens one; `testHost` names a few discord.js types (`ComposedCommands`, `CommandGuard`, `InteractionModule`, `RootOption`) so a test can drive the composed slash commands |
36
- | `pi-roundtable/kit` | Unstable helpers for channel claims, tools, presentation, and naming existing core parts |
37
- | `pi-roundtable/discord` | Unstable, and the entry built on discord.js types: the `DISCORD` service (slash commands, the owner guard), owner-command and panel helpers, the agent panel, and the channel-operation tables |
36
+ | `pi-roundtable/kit` | Helpers for channel claims, tools, presentation, worker processes, and naming existing core parts |
37
+ | `pi-roundtable/discord` | The entry built on discord.js types: the `DISCORD` service (slash commands, the owner guard), owner-command and panel helpers, the agent panel, and the channel-operation tables |
38
38
 
39
39
  ### Advanced building blocks
40
40
 
41
41
  Start with the main entry and the context's built-in services.
42
- The kit is unstable before 1.0 and is not covered by semver.
42
+ The kit and Discord entries are versioned like the main entry: before 1.0 a breaking change to any exported name comes in a minor release and is listed in the changelog, and a test compares every exported signature with a recorded report.
43
43
  Use `pi-roundtable/kit` for channel claims, tool and presentation helpers, and the types of the core's existing parts.
44
44
  Use `pi-roundtable/discord` for everything that touches Discord: slash commands, owner-command modules and panels, the agent panel, and the channel-operation tables.
45
45
  The main and kit entries name no discord.js type (a test checks their declarations), so a plugin that does not talk to Discord never depends on it.
@@ -62,6 +62,7 @@ A plugin that runs Pi itself, a coding worker or a container with no network, ta
62
62
 
63
63
  `roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
64
64
  The name is lowercase words joined by dashes, such as `my-notes`.
65
+ Two names are reserved for the [official plugins](#official-plugins): `codex-images` and `dice` copy a ready-made plugin into the project instead of the template.
65
66
 
66
67
  ## The plugin object
67
68
 
@@ -96,6 +97,7 @@ A plugin whose `setup` returns `{}` and that has no migrations, providers, or ho
96
97
  | `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
97
98
  | `events` | Where the core reports turns and team changes to every plugin's handlers |
98
99
  | `providers` | Each provider slot, from the plugin that fills it or the core's default; `providers.filled` is the set of slots a plugin fills |
100
+ | `apiKey(provider)` | The credential the host's model login holds for a provider such as `openai-codex`, the same login the agents use. It resolves to `undefined` when the host has none for that provider and never throws for that. The value is a secret: keep it out of logs and error messages. Read it when you use it rather than keeping it, since a login may refresh its token |
99
101
  | `queue` | The one channel queue that every conversation and channel operation shares |
100
102
  | `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
101
103
  | `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` |
@@ -127,10 +129,16 @@ A plugin lists the keys it provides in `provides` and provides each from `setup`
127
129
  |---|---|
128
130
  | `services.get(KEY)` | The service, or a `PluginError` that names the key and the plugin to register first when it is not provided yet |
129
131
  | `services.find(KEY)` | The service, or `undefined` when no registered plugin declares it, such as an addon that is off; it throws like `get` when a plugin declares it but has not set up yet, because that is order, not absence |
132
+ | `services.lazy(KEY)` | A function that returns the service once every plugin is set up, for a service whose plugin is registered after this one; call it from a service's `start`, a handler, or another callback that runs after startup, since calling it during setup throws a `NotLinkedError`. The host refuses to boot, naming your plugin, when no registered plugin provides the key |
130
133
  | `services.provide(KEY, value)` | Only from setup, only for a key the plugin declares in `provides`, once per key |
131
134
 
132
135
  A plugin that declares a key and does not provide it is refused when its `setup` returns, and two plugins that declare one key are refused before any setup.
133
136
 
137
+ A plugin that reads a service with `get` during its setup lists the key in `requires`, so a wrong order is refused before any migration or setup, with both plugins named, and the plugin states what it needs in one place (`noteCounter` below).
138
+ A key in `requires` must be provided by a plugin registered before this one.
139
+ A service you read with `find` (an addon that may be off) stays out of `requires`.
140
+ When the other plugin has to come after yours, because it reads what yours provides, read its service with `services.lazy` instead of keeping the object in a variable that the later plugin fills in (`earlyNoteReader` below).
141
+
134
142
  The built-in plugins provide these, from the main entry:
135
143
 
136
144
  | Key | Port | Provided by | What it is |
@@ -147,7 +155,7 @@ Your plugins run after the built-ins, so they can read every key above.
147
155
  The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, and `threads` (see [`commands.add`](#slash-commands-commandsadd)).
148
156
 
149
157
  A plugin of yours shares a service the same way: the key is a constant you export, the port is an interface you export, and plugins registered after yours read it.
150
- A plugin registered before yours cannot, and says so: `service <id> is not provided yet; plugin <yours> provides it. Register plugin <yours> before plugin <reader>.`
158
+ A plugin registered before yours cannot read it during its setup, and says so (it reads the service with `services.lazy` instead, from a callback that runs after startup): `service <id> is not provided yet; plugin <yours> provides it. Register plugin <yours> before plugin <reader>.`
151
159
 
152
160
  <!-- example: examples/shared-services.ts -->
153
161
  ```ts
@@ -195,6 +203,37 @@ export function noteReader(
195
203
  });
196
204
  }
197
205
 
206
+ /** A plugin that reads the service in `setup` lists it in `requires`: a wrong order stops the start, naming both plugins. */
207
+ export function noteCounter(onCount: (count: number) => void) {
208
+ return definePlugin({
209
+ name: "note-counter",
210
+ requires: [NOTE_INDEX],
211
+ setup: ({ services }) => {
212
+ const index = services.get(NOTE_INDEX);
213
+ return {
214
+ services: [
215
+ { name: "note-counter", start: () => onCount(index.all().length) },
216
+ ],
217
+ };
218
+ },
219
+ });
220
+ }
221
+
222
+ /** A plugin registered before the notes reads them with `lazy`, from a callback that runs after startup. */
223
+ export function earlyNoteReader(onNotes: (notes: readonly string[]) => void) {
224
+ return definePlugin({
225
+ name: "early-note-reader",
226
+ setup: ({ services }) => {
227
+ const index = services.lazy(NOTE_INDEX);
228
+ return {
229
+ services: [
230
+ { name: "early-note-reader", start: () => onNotes(index().all()) },
231
+ ],
232
+ };
233
+ },
234
+ });
235
+ }
236
+
198
237
  /** A plugin that provides the same key and lists it in `replaces` takes the place of the one before it. */
199
238
  export function shoutingNotes() {
200
239
  const stored: string[] = [];
@@ -1184,6 +1223,9 @@ export function alwaysOn(tools: () => string[]) {
1184
1223
  Names of npm packages, installed in your project (`bun add pi-web-access`), whose Pi extensions every conversation session loads.
1185
1224
  Two plugins that name the same package load it once.
1186
1225
 
1226
+ `pi-web-access` is also what the built-in delegation worker loads to search and read the web, so `pi-roundtable` lists it as a peer dependency (`>=0.35.0 <0.36.0`): `bun add pi-roundtable` installs it for you, and a project that depends on its own build of it, such as a fork, gets that one copy for both the worker and `piPackages`, with no `overrides` entry.
1227
+ A project that has none installed stops at the `modules` plugin's setup with a `PluginError` that names the command to run.
1228
+
1187
1229
  <!-- example: examples/packages.ts -->
1188
1230
  ```ts
1189
1231
  import { definePlugin } from "pi-roundtable";
@@ -1682,6 +1724,26 @@ A plugin that still has one is refused where it is written (`definePlugin`) or w
1682
1724
  | `RoundtablePlugin.agentServer()` and the `agentServer(outcome)` event | A service with `startInBackground`, and the `serviceStarted` event handler |
1683
1725
  | `RoundtablePlugin.stopTurn(channel)` | `stop(channel)` on the `ChannelClaim` that owns the channel |
1684
1726
 
1727
+ ## Official plugins
1728
+
1729
+ The package ships two plugins you can copy into a project and change.
1730
+ `roundtable add plugin codex-images` and `roundtable add plugin dice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`, the way any `add plugin` does.
1731
+ The copy is yours: edit it freely, and `add plugin` refuses to overwrite it when the file already exists.
1732
+ Both names are reserved, so `add plugin codex-images` never makes a template plugin of that name.
1733
+
1734
+ | Plugin | What it does | What it needs |
1735
+ |---|---|---|
1736
+ | `codex-images` | Fills the [`images` slot](#providers-replace-a-part-the-core-runs-on), so agents can draw avatars from a prompt and reference pictures | A login to the `openai-codex` provider; setup throws a `PluginError` that says so when the host has none |
1737
+ | `dice` | Adds the `roll_dice` tool for members: `2d6+3`, `4d6k3` (keep or drop the highest or lowest dice), several groups, and fate dice `dF`, answered as text such as `2d6+3: [3, 5] + 3 = 11` | Nothing; it takes at most 100 dice in all and 1000 sides per die |
1738
+
1739
+ `codex-images` takes the login through [`context.apiKey("openai-codex")`](#the-context).
1740
+ It sends the request to ChatGPT's Codex backend, which OpenAI does not document for this use, with the owner's own ChatGPT subscription login.
1741
+ It can stop working without notice, and OpenAI's terms for the subscription apply.
1742
+ Use it only where you accept that risk; the file begins with the same warning.
1743
+
1744
+ Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source.
1745
+ Both files export a `create...` function (`createCodexImages`, `createDice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
1746
+
1685
1747
  ## Testing a plugin
1686
1748
 
1687
1749
  Two harnesses, by what the test needs: `testPlugin(plugin, options?)` sets one plugin up alone, against a fake context, and is the default; [`testHost`](#testhost-the-built-in-plugins-and-yours-over-postgresql) boots the built-in plugins and yours together over PostgreSQL, for a test that depends on them or on the order the host sets things up in.
@@ -1716,6 +1778,7 @@ It does not run migrations (see [`migrations`](#migrations-and-contextdatabase-t
1716
1778
  | `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 |
1717
1779
  | `conversations` | Methods that replace the router's, such as `stop`, for a plugin that calls them |
1718
1780
  | `turns` | A `ConversationTurns` that replaces the default one |
1781
+ | `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 |
1719
1782
  | `forwardJoinMs` | How long the router holds a bare forward for the message that follows it (the host option `conversations.forwardJoinMs`) |
1720
1783
 
1721
1784
  The harness supplies what the host would, so a claim or a background turn behaves as it does there:
@@ -1724,6 +1787,9 @@ The harness supplies what the host would, so a claim or a background turn behave
1724
1787
  - Once `AGENTS` is given, its `approvals` is the real confirmation judge over `providers.judge` when you pass a judge, so a held action is approved or declined as the agent server decides.
1725
1788
  - Giving `AGENTS` a `team` puts the agent server's own claim in the router, for the `owner`, so the plugin's claims are tested against the agent channels as on a host; its `owner` background target comes with it.
1726
1789
 
1790
+ A plugin under test that lists a key in `requires` is refused unless the `services` option gives it (or the plugin provides it), the way the host refuses a key no plugin provides.
1791
+ A `services.lazy` reader answers once `testPlugin` returns, from the services you gave.
1792
+
1727
1793
  A plugin that fills the `runtime` slot needs none of these for its own runtime: the harness builds it from the slot (with a silent logger, a one-owner identity, in-memory held actions, and one stand-in agent setting) and puts it under `AGENTS`'s `runtime`.
1728
1794
 
1729
1795
  A test for the tools example:
@@ -1763,6 +1829,8 @@ Use it only with your own store class in a gated database test and always close
1763
1829
  `useTestLocale()` resets the process-wide locale to English and time zone to UTC; call it after a test that changes either, not from a running plugin.
1764
1830
  `testPlugin`'s options take `env` (a partial `HostEnv`) for the `context.env` the plugin sees; it defaults to `en` and `UTC`.
1765
1831
  `OWNER_SPEAKER`, `fakeThreads`, and `silentLogger` supply neutral stand-ins for owner turns, dispatch threads, and logging.
1832
+ `recordingLogger()` is a logger that keeps what it is asked to write: its `lines` hold each call's `level`, `fields` (those of a `child` included), and `message`, for a test of what the code logs.
1833
+ `partial<Port>({ ... })` is a stand-in for a port that your code takes as an argument rather than reads from a service: it has the members you give and nothing else, and reading another member throws an error that names it, so the test needs no `as unknown as Port` cast.
1766
1834
  `fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
1767
1835
  Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
1768
1836
 
@@ -1775,6 +1843,7 @@ Only `commands` and `guard` are given; a plugin that reads another member of `DI
1775
1843
  | `config` | Over a test configuration (an owner, a guild, a temporary `dataDir`, the test database, one agent): any of the `RoundtableConfig` keys, such as `skills: false` |
1776
1844
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
1777
1845
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
1846
+ | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
1778
1847
  | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()` |
1779
1848
 
1780
1849
  It returns:
@@ -1827,7 +1896,7 @@ On `SIGTERM` or `SIGINT` the bot stops serving new work last:
1827
1896
  4. Services stop in the reverse of the order they started.
1828
1897
  5. The database pool closes.
1829
1898
 
1830
- `shutdown()` returns the exit code, `0` or `1` when a listener, a service, or the pool failed to stop, and every call shares the one shutdown; only `listen()`, which the command line and Merlin call, exits the process with it.
1899
+ `shutdown()` returns the exit code, `0` or `1` when a listener, a service, or the pool failed to stop, and every call shares the one shutdown; only `listen()`, which the command line and a host's own entry point call, exits the process with it.
1831
1900
 
1832
1901
  ## Errors and their fixes
1833
1902
 
@@ -1901,6 +1970,10 @@ Your plugins always run after the built-ins, so the built-in keys show this only
1901
1970
  Pass what you need in the harness's `services` option, or test that part elsewhere.
1902
1971
  `find(KEY)` is `undefined` for a service nobody declares, in the host and in the harness.
1903
1972
 
1973
+ A key in `requires` is checked before any migration or setup, and a wrong order stops the start with `plugin <yours>: requires service <id>, which plugin <name> provides after it. Register plugin <name> before plugin <yours>, or read the service with services.lazy(KEY) from a callback that runs after startup.`
1974
+ Calling the function `services.lazy(KEY)` returned during setup throws `service <id> is read through lazy() once every plugin is set up. Call it from a service's start or from a handler, not during setup.`
1975
+ A `lazy` key that no registered plugin provides stops the start once every plugin is set up: `plugin <yours>: services.lazy reads service <id>, which no registered plugin provides.`
1976
+
1904
1977
  ### A setup or a migration that throws
1905
1978
 
1906
1979
  ```text
@@ -1970,6 +2043,22 @@ These are existing compatibility limits, not flags the package overrides in your
1970
2043
  With `skipLibCheck: false`, the example consumer instead reports 97 dependency-declaration errors, so keep it enabled for this configuration.
1971
2044
  Discord-facing signatures, which are in `pi-roundtable/discord` and, for the composed slash commands, in `pi-roundtable/testing`, use the package's pinned `discord.js` types; use those compatible types for panel rows and interaction handlers. `discord.js` is a regular dependency of the package, so a project that imports either entry installs it with `pi-roundtable`.
1972
2045
 
2046
+ ## Developing the core and a host together
2047
+
2048
+ A host pins an exact `pi-roundtable` version, so a change to the core reaches it only after a release.
2049
+ To try a core change in a host first, link the checkout:
2050
+
2051
+ ```sh
2052
+ # in the pi-roundtable checkout
2053
+ bun link
2054
+ # in the host
2055
+ bun link pi-roundtable
2056
+ ```
2057
+
2058
+ The host then imports the checkout's source, so the core's edits show at once and the host's `bun run typecheck` and `bun test` run against them.
2059
+ A linked checkout resolves its own dependencies from its own `node_modules`, so a host's `overrides` do not reach it: a host that depends on its own build of `pi-web-access` has two copies while linked.
2060
+ When the change is done, release the core, set the host's `pi-roundtable` to the new exact version, and run `bun install` to replace the link with the published package; the host's checks run once more against what was published.
2061
+
1973
2062
  ## Name-to-entry index
1974
2063
 
1975
2064
  Type-only exports require `import type` when `verbatimModuleSyntax` is enabled.
@@ -2160,6 +2249,10 @@ The source area files are not package subpaths.
2160
2249
  | `fakeThreads` | `pi-roundtable/testing` | value |
2161
2250
  | `openTestStore` | `pi-roundtable/testing` | value |
2162
2251
  | `servicePair` | `pi-roundtable/testing` | value |
2252
+ | `partial` | `pi-roundtable/testing` | value |
2253
+ | `recordingLogger` | `pi-roundtable/testing` | value |
2254
+ | `RecordingLogger` | `pi-roundtable/testing` | type |
2255
+ | `RecordedLog` | `pi-roundtable/testing` | type |
2163
2256
  | `silentLogger` | `pi-roundtable/testing` | value |
2164
2257
  | `testDatabaseUrl` | `pi-roundtable/testing` | value |
2165
2258
  | `testHost` | `pi-roundtable/testing` | value |
@@ -2204,6 +2297,8 @@ The source area files are not package subpaths.
2204
2297
  | `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
2205
2298
  | `SHELL_TOOLS` | `pi-roundtable/kit` | value |
2206
2299
  | `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
2300
+ | `packageDir` | `pi-roundtable/kit` | value |
2301
+ | `serveUnix` | `pi-roundtable/kit` | value |
2207
2302
  | `ScheduleToolContext` | `pi-roundtable/kit` | type |
2208
2303
  | `ScheduleToolName` | `pi-roundtable/kit` | type |
2209
2304
  | `ScheduleToolSpec` | `pi-roundtable/kit` | type |
@@ -2,8 +2,10 @@ import { expect, test } from "bun:test";
2
2
  import { definePlugin, PluginError, Roundtable } from "pi-roundtable";
3
3
  import { servicePair, silentLogger, testPlugin } from "pi-roundtable/testing";
4
4
  import {
5
+ earlyNoteReader,
5
6
  NOTE_INDEX,
6
7
  type NoteIndex,
8
+ noteCounter,
7
9
  noteReader,
8
10
  notes,
9
11
  shoutingNotes,
@@ -72,3 +74,29 @@ test("a provider that does not provide what it lists is refused after setup", as
72
74
  }),
73
75
  ).rejects.toBeInstanceOf(PluginError);
74
76
  });
77
+
78
+ test("a plugin that requires the service is refused when the notes come after it, before any setup", async () => {
79
+ const roundtable = new Roundtable({ logger: silentLogger() }, [
80
+ noteCounter(() => undefined),
81
+ notes(),
82
+ ]);
83
+ await expect(roundtable.run()).rejects.toThrow(
84
+ "plugin note-counter: requires service my-notes.index, which plugin my-notes provides after it.",
85
+ );
86
+ await roundtable.shutdown("test");
87
+ });
88
+
89
+ test("a plugin that requires the service counts the notes written before startup", async () => {
90
+ const counts: number[] = [];
91
+ await withHost(
92
+ [notes(), writer, noteCounter((count) => counts.push(count))],
93
+ () => expect(counts).toEqual([1]),
94
+ );
95
+ });
96
+
97
+ test("a plugin registered before the notes reads them lazily, once the host started", async () => {
98
+ const seen: (readonly string[])[] = [];
99
+ await withHost([earlyNoteReader((list) => seen.push(list)), notes()], () =>
100
+ expect(seen).toEqual([[]]),
101
+ );
102
+ });
@@ -42,6 +42,37 @@ export function noteReader(
42
42
  });
43
43
  }
44
44
 
45
+ /** A plugin that reads the service in `setup` lists it in `requires`: a wrong order stops the start, naming both plugins. */
46
+ export function noteCounter(onCount: (count: number) => void) {
47
+ return definePlugin({
48
+ name: "note-counter",
49
+ requires: [NOTE_INDEX],
50
+ setup: ({ services }) => {
51
+ const index = services.get(NOTE_INDEX);
52
+ return {
53
+ services: [
54
+ { name: "note-counter", start: () => onCount(index.all().length) },
55
+ ],
56
+ };
57
+ },
58
+ });
59
+ }
60
+
61
+ /** A plugin registered before the notes reads them with `lazy`, from a callback that runs after startup. */
62
+ export function earlyNoteReader(onNotes: (notes: readonly string[]) => void) {
63
+ return definePlugin({
64
+ name: "early-note-reader",
65
+ setup: ({ services }) => {
66
+ const index = services.lazy(NOTE_INDEX);
67
+ return {
68
+ services: [
69
+ { name: "early-note-reader", start: () => onNotes(index().all()) },
70
+ ],
71
+ };
72
+ },
73
+ });
74
+ }
75
+
45
76
  /** A plugin that provides the same key and lists it in `replaces` takes the place of the one before it. */
46
77
  export function shoutingNotes() {
47
78
  const stored: string[] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,14 +50,17 @@
50
50
  "canvas": "3.2.3",
51
51
  "discord.js": "14.27.0",
52
52
  "pi-mcp-adapter": "2.37.0",
53
- "pi-web-access": "0.35.0",
54
53
  "pino": "10.3.1",
55
54
  "typebox": "1.3.34",
56
55
  "unpdf": "1.8.1"
57
56
  },
57
+ "peerDependencies": {
58
+ "pi-web-access": ">=0.35.0 <0.36.0"
59
+ },
58
60
  "devDependencies": {
59
61
  "typescript": "7.0.2",
60
62
  "@biomejs/biome": "2.5.14",
61
- "@types/bun": "1.4.2"
63
+ "@types/bun": "1.4.2",
64
+ "pi-web-access": "0.35.0"
62
65
  }
63
66
  }
@@ -31,9 +31,10 @@ const refused = (...problems: string[]): AddPluginReport => ({
31
31
  });
32
32
 
33
33
  /**
34
- * Creates `plugins/<name>.ts` and its test from the `hello` template and lists the plugin in
35
- * `roundtable.config.ts`. Everything is checked and rendered before the first write, so a refusal
36
- * leaves the project untouched.
34
+ * Creates `plugins/<name>.ts` and its test and lists the plugin in `roundtable.config.ts`: the
35
+ * copy of an official plugin when `name` is one, else a plugin made from the `hello` template.
36
+ * Everything is checked and rendered before the first write, so a refusal leaves the project
37
+ * untouched.
37
38
  */
38
39
  export function addPlugin(inputs: AddPluginInputs): AddPluginReport {
39
40
  const { cwd, name } = inputs;
package/src/cli/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { loadConfigFile, type Ports } from "./project.ts";
12
12
  import { formatOutcomes } from "./report.ts";
13
13
  import { assemble, providerLogin } from "./runtime.ts";
14
14
  import { start } from "./start.ts";
15
+ import { OFFICIAL_PLUGINS } from "./templates.ts";
15
16
 
16
17
  /** What the command line reads from its surroundings; tests replace every part. */
17
18
  export interface CliEnvironment {
@@ -31,6 +32,8 @@ const USAGE = `roundtable: a Discord agent server on Pi
31
32
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
32
33
  roundtable start run the checks that need no network, then the bot
33
34
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
35
+ the names ${OFFICIAL_PLUGINS.join(" and ")} are reserved for the official plugins,
36
+ which are copied in ready to run instead of the template
34
37
  `;
35
38
 
36
39
  /** The package's own version and the Bun range it needs, from the package.json beside the source. */
@@ -13,6 +13,16 @@ export const TEMPLATES_DIR = join(import.meta.dir, "../../templates");
13
13
  /** The directory of the plugin template, rendered once for `hello` and again for every `add plugin`. */
14
14
  const PLUGIN_DIR = "plugin";
15
15
 
16
+ /** The directory of the official plugins, one directory each, named as `add plugin` names them. */
17
+ const OFFICIAL_DIR = "official";
18
+
19
+ /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
20
+ export const OFFICIAL_PLUGINS = ["codex-images", "dice"] as const;
21
+
22
+ /** Whether `name` is one of the official plugins. */
23
+ export const isOfficialPlugin = (name: string): boolean =>
24
+ (OFFICIAL_PLUGINS as readonly string[]).includes(name);
25
+
16
26
  /** What a template file may say, replaced when it is rendered. */
17
27
  export interface Substitutions {
18
28
  /** The project's package name. */
@@ -71,19 +81,22 @@ function fill(text: string, values: Record<string, string>): string {
71
81
  });
72
82
  }
73
83
 
74
- /** The plugin and its test, written for `names`. */
84
+ /** The plugin and its test, written for `names`: the official plugin of that name, or else the `hello` template. */
75
85
  export function renderPlugin(
76
86
  names: PluginNames,
77
87
  dir = TEMPLATES_DIR,
78
88
  ): Rendered[] {
79
89
  const values = { NAME: names.name, IDENT: names.ident, TOOL: names.tool };
90
+ const source = isOfficialPlugin(names.name)
91
+ ? join(OFFICIAL_DIR, names.name)
92
+ : PLUGIN_DIR;
80
93
  return [
81
94
  ["plugin.ts", `plugins/${names.name}.ts`],
82
95
  ["plugin.test.ts.tmpl", `plugins/${names.name}.test.ts`],
83
- ].map(([source, path]) => ({
96
+ ].map(([file, path]) => ({
84
97
  path: path as string,
85
98
  content: fill(
86
- readFileSync(join(dir, PLUGIN_DIR, source as string), "utf8"),
99
+ readFileSync(join(dir, source, file as string), "utf8"),
87
100
  values,
88
101
  ),
89
102
  }));
@@ -101,7 +114,11 @@ export function renderProject(
101
114
  };
102
115
  const skeleton = filesUnder(dir)
103
116
  .map((file) => relative(dir, file))
104
- .filter((path) => !path.startsWith(`${PLUGIN_DIR}/`))
117
+ .filter(
118
+ (path) =>
119
+ !path.startsWith(`${PLUGIN_DIR}/`) &&
120
+ !path.startsWith(`${OFFICIAL_DIR}/`),
121
+ )
105
122
  .map((path) => ({
106
123
  path: targetOf(path),
107
124
  content: fill(readFileSync(join(dir, path), "utf8"), values),
@@ -37,6 +37,13 @@ export interface Services {
37
37
  * Throws like `get` when a plugin declares it but has not been set up yet: that is order, not absence.
38
38
  */
39
39
  find<T>(key: ServiceKey<T>): T | undefined;
40
+ /**
41
+ * A reader for a service whose plugin may be set up after this one: call it from a service's
42
+ * `start`, a handler, or any callback that runs after startup, not during setup, where it throws
43
+ * a NotLinkedError. The host checks before startup that a registered plugin provides the key and
44
+ * refuses to boot with a PluginError naming this plugin when none does.
45
+ */
46
+ lazy<T>(key: ServiceKey<T>): () => T;
40
47
  /**
41
48
  * Provides a service the plugin declares in `provides`; only while the plugin's setup runs, and
42
49
  * once per key.
@@ -99,6 +99,7 @@ export async function defineRoundtable(
99
99
  authPath: join(config.agentDir, "auth.json"),
100
100
  modelsPath: join(config.agentDir, "models.json"),
101
101
  }));
102
+ const registry = new ModelRegistry(modelRuntime);
102
103
  const errorReporter = config.ops
103
104
  ? new ErrorReporter({ opsAgent: config.ops.agent, app: name })
104
105
  : undefined;
@@ -156,6 +157,7 @@ export async function defineRoundtable(
156
157
  ...(overrides.listeners ?? []),
157
158
  ],
158
159
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
160
+ apiKey: (provider) => registry.getApiKeyForProvider(provider),
159
161
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
160
162
  },
161
163
  plugins: [
package/src/core/host.ts CHANGED
@@ -66,6 +66,11 @@ export interface RoundtableOptions {
66
66
  };
67
67
  /** The model the default judge asks, when no plugin provides a judge. */
68
68
  judgeModel?: JudgeModel;
69
+ /**
70
+ * The credential the model login holds for a provider, or undefined when it holds none; behind
71
+ * `PluginContext.apiKey`. Without it every provider reads as having no credential.
72
+ */
73
+ apiKey?: (provider: string) => Promise<string | undefined>;
69
74
  /** What each tool needs: the operator's settings, with the plugins' tools added when the host links. */
70
75
  toolTiers?: ToolTierTable;
71
76
  /** The database the plugins' migrations and stores use; the host owns its one pool. */
@@ -278,6 +283,7 @@ export class Roundtable {
278
283
  // A plugin that replaces a service takes the place of the one that provided it.
279
284
  this.#active = replaceServices(this.#plugins);
280
285
  this.#services = new ServiceRegistry(this.#active);
286
+ this.#services.checkRequires();
281
287
  const providers = resolveProviders(this.#active, this.#options.judgeModel);
282
288
  await this.#migrate();
283
289
  this.#registry = await collectContributions(
@@ -310,6 +316,7 @@ export class Roundtable {
310
316
  );
311
317
  return this.#registry.dashboard;
312
318
  },
319
+ apiKey: async (provider) => this.#options.apiKey?.(provider),
313
320
  },
314
321
  this.#tiers,
315
322
  this.#services,
@@ -1,7 +1,8 @@
1
- import { chmodSync, rmSync } from "node:fs";
1
+ import { chmodSync } from "node:fs";
2
2
  import type { Server } from "bun";
3
3
  import { PluginError } from "../errors.ts";
4
4
  import type { Logger } from "../log.ts";
5
+ import { serveUnix } from "../shared/unix-server.ts";
5
6
 
6
7
  /** A handler a plugin attaches to one of the host's configured listeners. */
7
8
  export interface HttpRoute {
@@ -96,29 +97,6 @@ export async function routeRequest(
96
97
  }
97
98
  }
98
99
 
99
- /**
100
- * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
101
- * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
102
- * types reject the option there, hence the cast. A stale socket file is removed first.
103
- * (`shared/unix-server.ts` keeps its own copy for standalone worker images.)
104
- */
105
- function serveUnix(
106
- socketPath: string,
107
- fetch: (request: Request) => Response | Promise<Response>,
108
- ): Server<undefined> {
109
- rmSync(socketPath, { force: true });
110
- const options = {
111
- unix: socketPath,
112
- idleTimeout: 0,
113
- fetch,
114
- error: serverError,
115
- };
116
- // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
117
- return Bun.serve(
118
- options as unknown as Parameters<typeof Bun.serve>[0],
119
- ) as Server<undefined>;
120
- }
121
-
122
100
  /** Serves HTTP on a TCP port with the same idle setting as the unix sockets. */
123
101
  function serveTcp(
124
102
  port: number,
@@ -164,7 +142,9 @@ export class HttpListeners {
164
142
  const handle = (request: Request) =>
165
143
  routeRequest(routes, request, listener.id, this.#logger);
166
144
  if ("socketPath" in listener) {
167
- this.#servers.push(serveUnix(listener.socketPath, handle));
145
+ this.#servers.push(
146
+ serveUnix(listener.socketPath, handle, { error: serverError }),
147
+ );
168
148
  chmodSync(listener.socketPath, listener.mode ?? 0o660);
169
149
  } else {
170
150
  this.#servers.push(