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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/docs/plugins.md +100 -5
- package/examples/shared-services.test.ts +28 -0
- package/examples/shared-services.ts +31 -0
- package/package.json +6 -3
- package/src/cli/add-plugin.ts +4 -3
- package/src/cli/cli.ts +3 -0
- package/src/cli/templates.ts +21 -4
- package/src/core/contract/services.ts +7 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/host.ts +7 -0
- package/src/core/http/listeners.ts +5 -25
- package/src/core/modules/delegation/web-research-worker.ts +14 -1
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/contributions.ts +1 -0
- package/src/core/registry/services.ts +73 -1
- package/src/core/runtime/session-factory.ts +1 -1
- package/src/core/shared/package-dir.ts +8 -5
- package/src/core/shared/unix-server.ts +26 -0
- package/src/core/testing/partial.ts +21 -0
- package/src/core/testing/recording-logger.ts +44 -0
- package/src/core/testing/test-host.ts +14 -1
- package/src/discord/index.ts +1 -1
- package/src/kit/channels.ts +1 -1
- package/src/kit/domain.ts +1 -1
- package/src/kit/holds.ts +1 -1
- package/src/kit/index.ts +2 -1
- package/src/kit/judging.ts +1 -1
- package/src/kit/memory.ts +1 -1
- package/src/kit/mirror.ts +1 -1
- package/src/kit/presentation.ts +1 -1
- package/src/kit/process.ts +5 -0
- package/src/kit/shell.ts +1 -1
- package/src/kit/skills.ts +1 -1
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/kit/tools.ts +1 -1
- package/src/kit/worker.ts +1 -1
- package/src/testing.ts +16 -2
- package/templates/agents.ts +5 -0
- package/templates/official/codex-images/plugin.test.ts.tmpl +118 -0
- package/templates/official/codex-images/plugin.ts +215 -0
- package/templates/official/dice/plugin.test.ts.tmpl +105 -0
- 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
|
|
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
|
|
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
|
|
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`
|
|
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` |
|
|
37
|
-
| `pi-roundtable/discord` |
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|
package/src/cli/add-plugin.ts
CHANGED
|
@@ -31,9 +31,10 @@ const refused = (...problems: string[]): AddPluginReport => ({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Creates `plugins/<name>.ts` and its test
|
|
35
|
-
*
|
|
36
|
-
* leaves the project
|
|
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. */
|
package/src/cli/templates.ts
CHANGED
|
@@ -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(([
|
|
96
|
+
].map(([file, path]) => ({
|
|
84
97
|
path: path as string,
|
|
85
98
|
content: fill(
|
|
86
|
-
readFileSync(join(dir,
|
|
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(
|
|
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
|
|
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(
|
|
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(
|