pi-roundtable 0.3.0 → 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 +16 -0
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/docs/plugins.md +24 -0
- package/package.json +1 -1
- 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/define-roundtable.ts +2 -0
- package/src/core/host.ts +6 -0
- package/src/core/plugin.ts +6 -0
- package/src/core/testing/test-host.ts +14 -1
- package/src/testing.ts +9 -0
- 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,22 @@ 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
|
+
|
|
8
24
|
## [0.3.0] - 2026-10-01
|
|
9
25
|
|
|
10
26
|
Found by running the first consuming host on the published 0.2.1; each item answers one finding of its friction log.
|
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
|
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
|
## 外掛
|
package/docs/plugins.md
CHANGED
|
@@ -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` |
|
|
@@ -1722,6 +1724,26 @@ A plugin that still has one is refused where it is written (`definePlugin`) or w
|
|
|
1722
1724
|
| `RoundtablePlugin.agentServer()` and the `agentServer(outcome)` event | A service with `startInBackground`, and the `serviceStarted` event handler |
|
|
1723
1725
|
| `RoundtablePlugin.stopTurn(channel)` | `stop(channel)` on the `ChannelClaim` that owns the channel |
|
|
1724
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
|
+
|
|
1725
1747
|
## Testing a plugin
|
|
1726
1748
|
|
|
1727
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.
|
|
@@ -1756,6 +1778,7 @@ It does not run migrations (see [`migrations`](#migrations-and-contextdatabase-t
|
|
|
1756
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 |
|
|
1757
1779
|
| `conversations` | Methods that replace the router's, such as `stop`, for a plugin that calls them |
|
|
1758
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 |
|
|
1759
1782
|
| `forwardJoinMs` | How long the router holds a bare forward for the message that follows it (the host option `conversations.forwardJoinMs`) |
|
|
1760
1783
|
|
|
1761
1784
|
The harness supplies what the host would, so a claim or a background turn behaves as it does there:
|
|
@@ -1820,6 +1843,7 @@ Only `commands` and `guard` are given; a plugin that reads another member of `DI
|
|
|
1820
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` |
|
|
1821
1844
|
| `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
|
|
1822
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 |
|
|
1823
1847
|
| `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()` |
|
|
1824
1848
|
|
|
1825
1849
|
It returns:
|
package/package.json
CHANGED
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),
|
|
@@ -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. */
|
|
@@ -311,6 +316,7 @@ export class Roundtable {
|
|
|
311
316
|
);
|
|
312
317
|
return this.#registry.dashboard;
|
|
313
318
|
},
|
|
319
|
+
apiKey: async (provider) => this.#options.apiKey?.(provider),
|
|
314
320
|
},
|
|
315
321
|
this.#tiers,
|
|
316
322
|
this.#services,
|
package/src/core/plugin.ts
CHANGED
|
@@ -268,6 +268,12 @@ export interface PluginContext {
|
|
|
268
268
|
providers: ResolvedProviders;
|
|
269
269
|
/** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
|
|
270
270
|
dashboard(): readonly string[];
|
|
271
|
+
/**
|
|
272
|
+
* The credential the host's model login holds for a provider, such as `openai-codex`: the same
|
|
273
|
+
* login the agents use. Resolves to `undefined` when the host has none for that provider, and
|
|
274
|
+
* never throws for that. The value is a secret: keep it out of logs and error messages.
|
|
275
|
+
*/
|
|
276
|
+
apiKey(provider: string): Promise<string | undefined>;
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
export interface RoundtablePlugin {
|
|
@@ -38,6 +38,11 @@ export interface TestHostOptions {
|
|
|
38
38
|
plugins?: readonly RoundtablePlugin[];
|
|
39
39
|
/** The runtime every turn runs on; by default one that answers "" and builds no Pi session. */
|
|
40
40
|
runtime?: AgentRuntime;
|
|
41
|
+
/**
|
|
42
|
+
* The credentials `context.apiKey` returns, by provider name; a provider not listed reads as
|
|
43
|
+
* having none, whatever login the machine holds.
|
|
44
|
+
*/
|
|
45
|
+
apiKeys?: Readonly<Record<string, string>>;
|
|
41
46
|
/** What the stand-in Discord hands out. */
|
|
42
47
|
discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
|
|
43
48
|
}
|
|
@@ -210,7 +215,15 @@ export async function testHost(
|
|
|
210
215
|
],
|
|
211
216
|
};
|
|
212
217
|
const defined = await defineRoundtable(config, { logger: silentLogger() });
|
|
213
|
-
const
|
|
218
|
+
const apiKeys = options.apiKeys ?? {};
|
|
219
|
+
const roundtable = new Roundtable(
|
|
220
|
+
{
|
|
221
|
+
...defined.options,
|
|
222
|
+
apiKey: async (provider) =>
|
|
223
|
+
Object.hasOwn(apiKeys, provider) ? apiKeys[provider] : undefined,
|
|
224
|
+
},
|
|
225
|
+
defined.plugins,
|
|
226
|
+
);
|
|
214
227
|
await roundtable.run();
|
|
215
228
|
if (!captured) throw new Error("the probe was not set up");
|
|
216
229
|
const context = captured;
|
package/src/testing.ts
CHANGED
|
@@ -174,6 +174,11 @@ export interface TestPluginOptions {
|
|
|
174
174
|
conversations?: Partial<ConversationPort>;
|
|
175
175
|
/** Replaces `context.turns`, which by default runs turns over the runtime and the surfaces. */
|
|
176
176
|
turns?: ConversationTurns;
|
|
177
|
+
/**
|
|
178
|
+
* The credentials `context.apiKey` returns, by provider name; a provider not listed reads as
|
|
179
|
+
* having none, as on a host that is not logged in to it.
|
|
180
|
+
*/
|
|
181
|
+
apiKeys?: Readonly<Record<string, string>>;
|
|
177
182
|
/** How long the router holds a bare forward for the message that follows it; the host option `conversations.forwardJoinMs`. */
|
|
178
183
|
forwardJoinMs?: number;
|
|
179
184
|
}
|
|
@@ -414,6 +419,10 @@ export async function testPlugin(
|
|
|
414
419
|
},
|
|
415
420
|
providers,
|
|
416
421
|
dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
|
|
422
|
+
apiKey: async (provider) =>
|
|
423
|
+
Object.hasOwn(options.apiKeys ?? {}, provider)
|
|
424
|
+
? options.apiKeys?.[provider]
|
|
425
|
+
: undefined,
|
|
417
426
|
};
|
|
418
427
|
const registry = await collectContributions(
|
|
419
428
|
[plugin],
|
package/templates/agents.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AgentSeed } from "pi-roundtable";
|
|
2
2
|
|
|
3
|
+
// Ids come from .env, which Bun loads on its own.
|
|
4
|
+
const env = (name: string): string => process.env[name] ?? "";
|
|
5
|
+
|
|
3
6
|
// The first team. An agent that is already stored is never overwritten, so edit agents in Discord afterwards.
|
|
4
7
|
export const agents: AgentSeed[] = [
|
|
5
8
|
{
|
|
@@ -8,5 +11,7 @@ export const agents: AgentSeed[] = [
|
|
|
8
11
|
prompt:
|
|
9
12
|
"You are Guide, a friendly assistant. Answer briefly and ask when a request is unclear.",
|
|
10
13
|
avatarPrompt: "A friendly lighthouse keeper with a warm lantern",
|
|
14
|
+
// The agent in the entry channel is the coordinator: it answers there and can create the others.
|
|
15
|
+
channelId: env("DISCORD_ENTRY_CHANNEL_ID"),
|
|
11
16
|
},
|
|
12
17
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PluginError } from "pi-roundtable";
|
|
3
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import {
|
|
5
|
+
type CodexFetch,
|
|
6
|
+
codexImages,
|
|
7
|
+
createCodexImages,
|
|
8
|
+
ImageNotGeneratedError,
|
|
9
|
+
} from "./codex-images.ts";
|
|
10
|
+
|
|
11
|
+
/** A login token shaped like the real one: a JWT whose claims name the ChatGPT account. */
|
|
12
|
+
function token(account: string): string {
|
|
13
|
+
const claims = {
|
|
14
|
+
"https://api.openai.com/auth": { chatgpt_account_id: account },
|
|
15
|
+
};
|
|
16
|
+
return `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
|
20
|
+
|
|
21
|
+
/** A server-sent event stream of the given events. */
|
|
22
|
+
function stream(...events: unknown[]): Response {
|
|
23
|
+
const text = events
|
|
24
|
+
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
|
25
|
+
.join("");
|
|
26
|
+
return new Response(text, {
|
|
27
|
+
headers: { "content-type": "text/event-stream" },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const drawn = (result: string, status = "completed") => ({
|
|
32
|
+
type: "response.output_item.done",
|
|
33
|
+
item: { type: "image_generation_call", status, result },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const completed = { type: "response.completed", response: { output: [] } };
|
|
37
|
+
|
|
38
|
+
/** The plugin over a fake network that answers every request with `answer`, recording the requests. */
|
|
39
|
+
function over(answer: () => Response) {
|
|
40
|
+
const requests: { url: string; init: RequestInit }[] = [];
|
|
41
|
+
const fetch: CodexFetch = async (url, init) => {
|
|
42
|
+
requests.push({ url, init });
|
|
43
|
+
return answer();
|
|
44
|
+
};
|
|
45
|
+
return { plugin: createCodexImages({ fetch }), requests };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("codex-images fills the images slot with the image Codex returns", async () => {
|
|
49
|
+
const secret = token("acct-1");
|
|
50
|
+
const { plugin, requests } = over(() =>
|
|
51
|
+
stream(drawn(Buffer.from(PNG).toString("base64")), completed),
|
|
52
|
+
);
|
|
53
|
+
const harness = await testPlugin(plugin, {
|
|
54
|
+
apiKeys: { "openai-codex": secret },
|
|
55
|
+
});
|
|
56
|
+
const image = await plugin.providers?.images?.("a fox", [
|
|
57
|
+
{ data: "AAAA", mimeType: "image/png" },
|
|
58
|
+
]);
|
|
59
|
+
expect([...(image ?? [])]).toEqual([...PNG]);
|
|
60
|
+
const [request] = requests;
|
|
61
|
+
expect(request?.url).toBe("https://chatgpt.com/backend-api/codex/responses");
|
|
62
|
+
const headers = request?.init.headers as Record<string, string>;
|
|
63
|
+
expect(headers.authorization).toBe(`Bearer ${secret}`);
|
|
64
|
+
expect(headers["chatgpt-account-id"]).toBe("acct-1");
|
|
65
|
+
const body = JSON.parse(String(request?.init.body));
|
|
66
|
+
expect(body.tools).toEqual([
|
|
67
|
+
{ type: "image_generation", output_format: "png" },
|
|
68
|
+
]);
|
|
69
|
+
expect(body.input[0].content).toEqual([
|
|
70
|
+
{ type: "input_text", text: "a fox" },
|
|
71
|
+
{ type: "input_image", image_url: "data:image/png;base64,AAAA" },
|
|
72
|
+
]);
|
|
73
|
+
await harness.stop();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("codex-images says so when Codex runs the tool and returns no image", async () => {
|
|
77
|
+
const refused = over(() => stream(drawn("", "failed"), completed));
|
|
78
|
+
const harness = await testPlugin(refused.plugin, {
|
|
79
|
+
apiKeys: { "openai-codex": token("acct-1") },
|
|
80
|
+
});
|
|
81
|
+
await expect(refused.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
|
|
82
|
+
ImageNotGeneratedError,
|
|
83
|
+
);
|
|
84
|
+
await harness.stop();
|
|
85
|
+
const silent = over(() => stream(completed));
|
|
86
|
+
const quiet = await testPlugin(silent.plugin, {
|
|
87
|
+
apiKeys: { "openai-codex": token("acct-1") },
|
|
88
|
+
});
|
|
89
|
+
await expect(silent.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
|
|
90
|
+
"Codex answered without an image",
|
|
91
|
+
);
|
|
92
|
+
await quiet.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("codex-images reports an HTTP error with its status, and never the token", async () => {
|
|
96
|
+
const secret = token("acct-1");
|
|
97
|
+
const { plugin } = over(() => new Response("rate limited", { status: 429 }));
|
|
98
|
+
const harness = await testPlugin(plugin, {
|
|
99
|
+
apiKeys: { "openai-codex": secret },
|
|
100
|
+
});
|
|
101
|
+
const failure = await plugin.providers?.images?.("a fox", []).then(
|
|
102
|
+
() => undefined,
|
|
103
|
+
(error: Error) => error,
|
|
104
|
+
);
|
|
105
|
+
expect(failure?.message).toBe("Codex answered 429: rate limited");
|
|
106
|
+
expect(failure?.message).not.toContain(secret);
|
|
107
|
+
await harness.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("codex-images refuses to start without an openai-codex login", async () => {
|
|
111
|
+
const failure = await testPlugin(codexImages).then(
|
|
112
|
+
() => undefined,
|
|
113
|
+
(error: unknown) => error,
|
|
114
|
+
);
|
|
115
|
+
expect(failure).toBeInstanceOf(PluginError);
|
|
116
|
+
expect((failure as PluginError).message).toContain("openai-codex");
|
|
117
|
+
expect((failure as PluginError).message).toContain("Log in");
|
|
118
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Warning: this plugin uses ChatGPT's undocumented Codex backend, signed in with the owner's own
|
|
2
|
+
// ChatGPT subscription (the `openai-codex` login), to draw images. OpenAI has not published it
|
|
3
|
+
// as an API, so it can stop working without notice, and OpenAI's terms for the subscription
|
|
4
|
+
// apply to it. Use it only where you accept that risk.
|
|
5
|
+
|
|
6
|
+
import { definePlugin, PluginError, type ReferenceImage } from "pi-roundtable";
|
|
7
|
+
|
|
8
|
+
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
9
|
+
/** The Pi provider whose login pays for the images. */
|
|
10
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
11
|
+
/** Routes the Codex request; the backend picks the image model itself. */
|
|
12
|
+
const CODEX_MODEL = "gpt-6-sol";
|
|
13
|
+
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
16
|
+
const MAX_IMAGE_BASE64 = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Codex ran the image tool but returned no image, usually because it refused the prompt.
|
|
20
|
+
* Asking again with the same prompt fails again.
|
|
21
|
+
*/
|
|
22
|
+
export class ImageNotGeneratedError extends Error {
|
|
23
|
+
override name = "ImageNotGeneratedError";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The part of `fetch` the plugin uses, so a test can stand in for the network. */
|
|
27
|
+
export type CodexFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export interface CodexImagesOptions {
|
|
30
|
+
/** The request function; default the global `fetch`. */
|
|
31
|
+
fetch?: CodexFetch;
|
|
32
|
+
/** The model that routes the request; default `gpt-6-sol`. */
|
|
33
|
+
model?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The ChatGPT account the login token belongs to, from the token's claims. */
|
|
37
|
+
function chatGptAccountId(token: string): string {
|
|
38
|
+
const payload = token.split(".")[1];
|
|
39
|
+
if (!payload) throw new Error("the Codex token is not a JWT");
|
|
40
|
+
let claims: Record<string, unknown>;
|
|
41
|
+
try {
|
|
42
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error("the Codex token's claims are not JSON");
|
|
45
|
+
}
|
|
46
|
+
const auth = claims[JWT_CLAIM_PATH] as
|
|
47
|
+
| { chatgpt_account_id?: unknown }
|
|
48
|
+
| undefined;
|
|
49
|
+
const id = auth?.chatgpt_account_id;
|
|
50
|
+
if (typeof id !== "string" || !id)
|
|
51
|
+
throw new Error("the Codex token has no ChatGPT account");
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function object(value: unknown): Record<string, unknown> {
|
|
56
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
57
|
+
? (value as Record<string, unknown>)
|
|
58
|
+
: {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads the server-sent events until the response completes, and returns its one image. */
|
|
62
|
+
async function parseImageStream(response: Response): Promise<Uint8Array> {
|
|
63
|
+
if (!response.body) throw new Error("Codex returned no body");
|
|
64
|
+
let image: Uint8Array | undefined;
|
|
65
|
+
let completed = false;
|
|
66
|
+
const take = (value: unknown) => {
|
|
67
|
+
const item = object(value);
|
|
68
|
+
if (item.type !== "image_generation_call" || image) return;
|
|
69
|
+
if (
|
|
70
|
+
item.status !== "completed" ||
|
|
71
|
+
typeof item.result !== "string" ||
|
|
72
|
+
!item.result
|
|
73
|
+
) {
|
|
74
|
+
const { result: _result, ...detail } = item;
|
|
75
|
+
throw new ImageNotGeneratedError(
|
|
76
|
+
`Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (item.result.length > MAX_IMAGE_BASE64)
|
|
80
|
+
throw new Error("the image exceeds 32 MiB");
|
|
81
|
+
image = new Uint8Array(Buffer.from(item.result, "base64"));
|
|
82
|
+
};
|
|
83
|
+
const handle = (frame: string) => {
|
|
84
|
+
const data = frame
|
|
85
|
+
.split(/\r?\n/)
|
|
86
|
+
.filter((line) => line.startsWith("data:"))
|
|
87
|
+
.map((line) => line.slice(5).trim())
|
|
88
|
+
.join("\n");
|
|
89
|
+
if (!data || data === "[DONE]") return;
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(data);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
|
|
95
|
+
}
|
|
96
|
+
const event = object(parsed);
|
|
97
|
+
switch (event.type) {
|
|
98
|
+
case "error":
|
|
99
|
+
case "response.failed": {
|
|
100
|
+
const error = object(object(event.response).error ?? event.error);
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Codex failed: ${String(error.message ?? error.code ?? "unknown error")}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
case "response.incomplete":
|
|
106
|
+
throw new Error("the Codex response was incomplete");
|
|
107
|
+
case "response.output_item.done":
|
|
108
|
+
take(event.item);
|
|
109
|
+
break;
|
|
110
|
+
case "response.completed": {
|
|
111
|
+
const output = object(event.response).output;
|
|
112
|
+
if (!image && Array.isArray(output))
|
|
113
|
+
for (const item of output) take(item);
|
|
114
|
+
completed = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let buffer = "";
|
|
121
|
+
for await (const chunk of response.body) {
|
|
122
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
123
|
+
let match = /\r?\n\r?\n/.exec(buffer);
|
|
124
|
+
while (match && !completed) {
|
|
125
|
+
handle(buffer.slice(0, match.index));
|
|
126
|
+
buffer = buffer.slice(match.index + match[0].length);
|
|
127
|
+
match = /\r?\n\r?\n/.exec(buffer);
|
|
128
|
+
}
|
|
129
|
+
if (completed) break;
|
|
130
|
+
}
|
|
131
|
+
if (!completed && buffer.trim()) handle(buffer);
|
|
132
|
+
if (!completed) throw new Error("the Codex stream ended before completion");
|
|
133
|
+
if (!image)
|
|
134
|
+
throw new ImageNotGeneratedError("Codex answered without an image");
|
|
135
|
+
return image;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function generateImage(
|
|
139
|
+
prompt: string,
|
|
140
|
+
references: readonly ReferenceImage[],
|
|
141
|
+
token: string,
|
|
142
|
+
model: string,
|
|
143
|
+
fetchImpl: CodexFetch,
|
|
144
|
+
): Promise<Uint8Array> {
|
|
145
|
+
const response = await fetchImpl(CODEX_RESPONSES_URL, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
redirect: "error",
|
|
148
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
149
|
+
headers: {
|
|
150
|
+
authorization: `Bearer ${token}`,
|
|
151
|
+
"chatgpt-account-id": chatGptAccountId(token),
|
|
152
|
+
originator: "pi",
|
|
153
|
+
"openai-beta": "responses=experimental",
|
|
154
|
+
accept: "text/event-stream",
|
|
155
|
+
"content-type": "application/json",
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
model,
|
|
159
|
+
store: false,
|
|
160
|
+
stream: true,
|
|
161
|
+
instructions:
|
|
162
|
+
"You are generating bitmap image assets. For this request, call the image_generation tool exactly once. Do not answer with only text unless image generation is unavailable.",
|
|
163
|
+
input: [
|
|
164
|
+
{
|
|
165
|
+
role: "user",
|
|
166
|
+
content: [
|
|
167
|
+
{ type: "input_text", text: prompt },
|
|
168
|
+
...references.map((image) => ({
|
|
169
|
+
type: "input_image",
|
|
170
|
+
image_url: `data:${image.mimeType};base64,${image.data}`,
|
|
171
|
+
})),
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
tools: [{ type: "image_generation", output_format: "png" }],
|
|
176
|
+
tool_choice: "auto",
|
|
177
|
+
parallel_tool_calls: false,
|
|
178
|
+
text: { verbosity: "low" },
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const detail = (await response.text()).slice(0, 300);
|
|
183
|
+
throw new Error(`Codex answered ${response.status}: ${detail}`);
|
|
184
|
+
}
|
|
185
|
+
return parseImageStream(response);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NO_LOGIN = `the host has no login for ${CODEX_PROVIDER}, the provider that pays for the images. Log in to the ${CODEX_PROVIDER} provider with Pi so the agent directory's auth.json holds it, or remove this plugin.`;
|
|
189
|
+
|
|
190
|
+
/** The plugin, with its request function and model replaceable for a test. */
|
|
191
|
+
export function createCodexImages(options: CodexImagesOptions = {}) {
|
|
192
|
+
const { fetch: fetchImpl = fetch, model = CODEX_MODEL } = options;
|
|
193
|
+
let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
|
|
194
|
+
// The login refreshes its token, so each image reads it again instead of keeping the first.
|
|
195
|
+
const token = async (): Promise<string> => {
|
|
196
|
+
const value = await apiKey?.(CODEX_PROVIDER);
|
|
197
|
+
if (!value) throw new PluginError(`plugin codex-images: ${NO_LOGIN}`);
|
|
198
|
+
return value;
|
|
199
|
+
};
|
|
200
|
+
return definePlugin({
|
|
201
|
+
name: "codex-images",
|
|
202
|
+
providers: {
|
|
203
|
+
// The images slot draws an agent's avatar from a prompt and reference pictures.
|
|
204
|
+
images: async (prompt, references) =>
|
|
205
|
+
generateImage(prompt, references, await token(), model, fetchImpl),
|
|
206
|
+
},
|
|
207
|
+
async setup(context) {
|
|
208
|
+
apiKey = context.apiKey;
|
|
209
|
+
await token();
|
|
210
|
+
return {};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const codexImages = createCodexImages();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
3
|
+
import { createDice, dice, type Random, roll } from "./dice.ts";
|
|
4
|
+
|
|
5
|
+
/** A random source that answers with the faces given, as `[face, sides]` for each die in turn. */
|
|
6
|
+
function dieFaces(...rolled: [face: number, sides: number][]): Random {
|
|
7
|
+
const queue = rolled.map(([face, sides]) => (face - 1) / sides + 0.0001);
|
|
8
|
+
return () => {
|
|
9
|
+
const next = queue.shift();
|
|
10
|
+
if (next === undefined)
|
|
11
|
+
throw new Error("the test rolled more dice than it gave");
|
|
12
|
+
return next;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The faces given, for dice that all have `sides` sides. */
|
|
17
|
+
const faces = (sides: number, ...rolled: number[]): Random =>
|
|
18
|
+
dieFaces(...rolled.map((face): [number, number] => [face, sides]));
|
|
19
|
+
|
|
20
|
+
test("a die rolls from 1 to its sides, and a number adds itself", () => {
|
|
21
|
+
expect(roll("2d6+3", faces(6, 3, 5))).toBe("2d6+3: [3, 5] + 3 = 11");
|
|
22
|
+
expect(roll("d20", faces(20, 20))).toBe("d20: [20] = 20");
|
|
23
|
+
expect(roll(" 1D4 ", faces(4, 1))).toBe("1D4: [1] = 1");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("several groups add and subtract in order", () => {
|
|
27
|
+
expect(roll("2d6-1d4+2", dieFaces([4, 6], [2, 6], [1, 4]))).toBe(
|
|
28
|
+
"2d6-1d4+2: [4, 2] - [1] + 2 = 7",
|
|
29
|
+
);
|
|
30
|
+
expect(roll("-d6", faces(6, 2))).toBe("-d6: -[2] = -2");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("keep and drop rules remove dice and show them in parentheses", () => {
|
|
34
|
+
const six = (...rolled: number[]) => faces(6, ...rolled);
|
|
35
|
+
expect(roll("4d6k3", six(5, 1, 6, 3))).toBe("4d6k3: [5, (1), 6, 3] = 14");
|
|
36
|
+
expect(roll("4d6kh3", six(5, 1, 6, 3))).toBe("4d6kh3: [5, (1), 6, 3] = 14");
|
|
37
|
+
expect(roll("4d6kl1", six(5, 1, 6, 3))).toBe(
|
|
38
|
+
"4d6kl1: [(5), 1, (6), (3)] = 1",
|
|
39
|
+
);
|
|
40
|
+
expect(roll("4d6d1", six(5, 1, 6, 3))).toBe("4d6d1: [5, (1), 6, 3] = 14");
|
|
41
|
+
expect(roll("4d6dl1", six(5, 1, 6, 3))).toBe("4d6dl1: [5, (1), 6, 3] = 14");
|
|
42
|
+
expect(roll("4d6dh1", six(5, 1, 6, 3))).toBe("4d6dh1: [5, 1, (6), 3] = 9");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("fate dice add -1, 0, and +1 and default to four", () => {
|
|
46
|
+
const random = (() => {
|
|
47
|
+
const queue = [0.1, 0.5, 0.9, 0.9];
|
|
48
|
+
return () => queue.shift() ?? 0;
|
|
49
|
+
})();
|
|
50
|
+
expect(roll("dF", random)).toBe("dF: [-, 0, +, +] = 1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a bad expression is refused with what to fix", () => {
|
|
54
|
+
for (const bad of [
|
|
55
|
+
"",
|
|
56
|
+
" ",
|
|
57
|
+
"abc",
|
|
58
|
+
"2d",
|
|
59
|
+
"d1",
|
|
60
|
+
"0d6",
|
|
61
|
+
"2d6+",
|
|
62
|
+
"2d6++1",
|
|
63
|
+
"2d6 d4",
|
|
64
|
+
"4d6k4",
|
|
65
|
+
"4d6k0",
|
|
66
|
+
"4d6d9",
|
|
67
|
+
"1d6x",
|
|
68
|
+
])
|
|
69
|
+
expect(() => roll(bad, () => 0.5)).toThrow("Write an expression such as");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the caps on dice, sides, and length are refused", () => {
|
|
73
|
+
expect(() => roll("101d6", () => 0.5)).toThrow("more than 100 dice");
|
|
74
|
+
expect(() => roll("60d6+60d6", () => 0.5)).toThrow(
|
|
75
|
+
"more than 100 dice in all",
|
|
76
|
+
);
|
|
77
|
+
expect(() => roll("1d1001", () => 0.5)).toThrow("between 2 and 1000 sides");
|
|
78
|
+
expect(() => roll("d6+1000001", () => 0.5)).toThrow("is over 1000000");
|
|
79
|
+
expect(() => roll(`1+${"1+".repeat(120)}1`, () => 0.5)).toThrow(
|
|
80
|
+
"over 200 characters",
|
|
81
|
+
);
|
|
82
|
+
expect(roll("100d2", () => 0.99)).toContain("= 200");
|
|
83
|
+
expect(roll("1d1000", () => 0.9999)).toBe("1d1000: [1000] = 1000");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the roll_dice tool is for members and answers a bad expression as text", async () => {
|
|
87
|
+
const harness = await testPlugin(createDice(faces(6, 3, 5)));
|
|
88
|
+
expect(harness.tools).toEqual(["roll_dice"]);
|
|
89
|
+
expect(harness.tiers.minTier("roll_dice")).toBe("member");
|
|
90
|
+
expect(await harness.runTool("roll_dice", { expression: "2d6+3" })).toBe(
|
|
91
|
+
"2d6+3: [3, 5] + 3 = 11",
|
|
92
|
+
);
|
|
93
|
+
expect(await harness.runTool("roll_dice", { expression: "nope" })).toContain(
|
|
94
|
+
"is not a dice term",
|
|
95
|
+
);
|
|
96
|
+
await harness.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the ready-made plugin rolls with a real random source", async () => {
|
|
100
|
+
const harness = await testPlugin(dice);
|
|
101
|
+
expect(await harness.runTool("roll_dice", { expression: "d6" })).toMatch(
|
|
102
|
+
/^d6: \[[1-6]\] = [1-6]$/,
|
|
103
|
+
);
|
|
104
|
+
await harness.stop();
|
|
105
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { definePlugin, defineTool, ToolRefusal } from "pi-roundtable";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
/** Returns a number from 0 up to, not including, 1, like `Math.random`. */
|
|
5
|
+
export type Random = () => number;
|
|
6
|
+
|
|
7
|
+
const MAX_DICE = 100;
|
|
8
|
+
const MAX_SIDES = 1000;
|
|
9
|
+
const MAX_LENGTH = 200;
|
|
10
|
+
const MAX_MODIFIER = 1_000_000;
|
|
11
|
+
const FATE_DEFAULT = 4;
|
|
12
|
+
|
|
13
|
+
type KeepDrop = "k" | "kh" | "kl" | "d" | "dh" | "dl";
|
|
14
|
+
|
|
15
|
+
const TERM = /^(\d*)d(?:(f)|(\d+)(?:(kh|kl|dh|dl|k|d)(\d+))?)$/;
|
|
16
|
+
|
|
17
|
+
const refuse = (problem: string): never => {
|
|
18
|
+
throw new ToolRefusal(
|
|
19
|
+
`${problem} Write an expression such as 2d6+3, d20, 4d6k3, or 4dF.`,
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The indexes a keep or drop rule removes; `rolls` has more than `n` dice. */
|
|
24
|
+
function removed(
|
|
25
|
+
rolls: readonly number[],
|
|
26
|
+
rule: KeepDrop,
|
|
27
|
+
n: number,
|
|
28
|
+
): number[] {
|
|
29
|
+
const ascending = rolls
|
|
30
|
+
.map((value, index) => ({ value, index }))
|
|
31
|
+
.sort((a, b) => a.value - b.value || a.index - b.index)
|
|
32
|
+
.map((die) => die.index);
|
|
33
|
+
switch (rule) {
|
|
34
|
+
case "k":
|
|
35
|
+
case "kh":
|
|
36
|
+
return ascending.slice(0, rolls.length - n);
|
|
37
|
+
case "kl":
|
|
38
|
+
return ascending.slice(n);
|
|
39
|
+
case "d":
|
|
40
|
+
case "dl":
|
|
41
|
+
return ascending.slice(0, n);
|
|
42
|
+
case "dh":
|
|
43
|
+
return ascending.slice(rolls.length - n);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One term rolled: what it shows, and what it adds before its sign applies. */
|
|
48
|
+
interface Rolled {
|
|
49
|
+
shown: string;
|
|
50
|
+
value: number;
|
|
51
|
+
dice: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rollTerm(term: string, random: Random): Rolled {
|
|
55
|
+
if (/^\d+$/.test(term)) {
|
|
56
|
+
if (term.length > 7 || Number(term) > MAX_MODIFIER)
|
|
57
|
+
return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
|
|
58
|
+
return { shown: String(Number(term)), value: Number(term), dice: 0 };
|
|
59
|
+
}
|
|
60
|
+
const match = TERM.exec(term);
|
|
61
|
+
if (!match) return refuse(`"${term}" is not a dice term.`);
|
|
62
|
+
const [, countText, fate, sidesText, rule, nText] = match;
|
|
63
|
+
const count = countText ? Number(countText) : fate ? FATE_DEFAULT : 1;
|
|
64
|
+
if (count < 1) return refuse(`"${term}" rolls no dice.`);
|
|
65
|
+
if (count > MAX_DICE)
|
|
66
|
+
return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
|
|
67
|
+
if (fate) {
|
|
68
|
+
const faces = Array.from(
|
|
69
|
+
{ length: count },
|
|
70
|
+
() => Math.floor(random() * 3) - 1,
|
|
71
|
+
);
|
|
72
|
+
const symbols = faces.map((face) =>
|
|
73
|
+
face > 0 ? "+" : face < 0 ? "-" : "0",
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
shown: `[${symbols.join(", ")}]`,
|
|
77
|
+
value: faces.reduce((sum, face) => sum + face, 0),
|
|
78
|
+
dice: count,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const sides = Number(sidesText);
|
|
82
|
+
if (sides < 2 || sides > MAX_SIDES)
|
|
83
|
+
return refuse(`"${term}" needs between 2 and ${MAX_SIDES} sides.`);
|
|
84
|
+
const rolls = Array.from(
|
|
85
|
+
{ length: count },
|
|
86
|
+
() => Math.floor(random() * sides) + 1,
|
|
87
|
+
);
|
|
88
|
+
const dropped = new Set<number>();
|
|
89
|
+
if (rule) {
|
|
90
|
+
const n = Number(nText);
|
|
91
|
+
if (n < 1 || n >= count)
|
|
92
|
+
return refuse(
|
|
93
|
+
`"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
|
|
94
|
+
);
|
|
95
|
+
for (const index of removed(rolls, rule as KeepDrop, n)) dropped.add(index);
|
|
96
|
+
}
|
|
97
|
+
const shown = rolls.map((value, index) =>
|
|
98
|
+
dropped.has(index) ? `(${value})` : String(value),
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
shown: `[${shown.join(", ")}]`,
|
|
102
|
+
value: rolls.reduce(
|
|
103
|
+
(sum, value, index) => sum + (dropped.has(index) ? 0 : value),
|
|
104
|
+
0,
|
|
105
|
+
),
|
|
106
|
+
dice: count,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Rolls an expression of dice terms and numbers joined by `+` and `-`, and writes the result as
|
|
112
|
+
* text such as `2d6+3: [3, 5] + 3 = 11`. A term is `NdS`, an optional keep or drop rule
|
|
113
|
+
* (`k`/`kh` keep the highest, `kl` the lowest, `d`/`dl` drop the lowest, `dh` the highest, each
|
|
114
|
+
* with a count), `NdF` fate dice (default 4), or a whole number. Dropped dice show in parentheses.
|
|
115
|
+
* An invalid or oversized expression throws a `ToolRefusal` that says what to fix.
|
|
116
|
+
*/
|
|
117
|
+
export function roll(expression: string, random: Random = Math.random): string {
|
|
118
|
+
const compact = expression.replace(/\s+/g, "");
|
|
119
|
+
if (!compact) return refuse("The expression is empty.");
|
|
120
|
+
if (compact.length > MAX_LENGTH)
|
|
121
|
+
return refuse(`The expression is over ${MAX_LENGTH} characters.`);
|
|
122
|
+
const parts = [...compact.toLowerCase().matchAll(/([+-]?)([^+-]+)/g)];
|
|
123
|
+
if (parts.map((part) => part[0]).join("") !== compact.toLowerCase())
|
|
124
|
+
return refuse(`"${compact}" is not a sum of dice and numbers.`);
|
|
125
|
+
let total = 0;
|
|
126
|
+
let dice = 0;
|
|
127
|
+
let line = "";
|
|
128
|
+
for (const [index, [, sign = "", term = ""]] of parts.entries()) {
|
|
129
|
+
if (index > 0 && !sign)
|
|
130
|
+
return refuse(`"${compact}" is not a sum of dice and numbers.`);
|
|
131
|
+
const rolled = rollTerm(term, random);
|
|
132
|
+
dice += rolled.dice;
|
|
133
|
+
if (dice > MAX_DICE)
|
|
134
|
+
return refuse(`The expression rolls more than ${MAX_DICE} dice in all.`);
|
|
135
|
+
total += sign === "-" ? -rolled.value : rolled.value;
|
|
136
|
+
line +=
|
|
137
|
+
index === 0
|
|
138
|
+
? `${sign === "-" ? "-" : ""}${rolled.shown}`
|
|
139
|
+
: ` ${sign} ${rolled.shown}`;
|
|
140
|
+
}
|
|
141
|
+
return `${compact}: ${line} = ${total}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The plugin, with the random source replaceable for a test. */
|
|
145
|
+
export function createDice(random: Random = Math.random) {
|
|
146
|
+
return definePlugin({
|
|
147
|
+
name: "dice",
|
|
148
|
+
setup: () => ({
|
|
149
|
+
tools: [
|
|
150
|
+
defineTool({
|
|
151
|
+
name: "roll_dice",
|
|
152
|
+
description: `Roll dice and report each die and the total. The expression joins terms with + and -: NdS rolls N dice of S sides (2d6, d20), a count after k/kh keeps the highest dice (4d6k3), kl keeps the lowest, d/dl drops the lowest, dh drops the highest, NdF rolls N fate dice (default 4), and a plain number adds itself (2d6+1d4-1). At most ${MAX_DICE} dice in all and ${MAX_SIDES} sides per die. Dropped dice show in parentheses.`,
|
|
153
|
+
parameters: Type.Object({
|
|
154
|
+
expression: Type.String({
|
|
155
|
+
description: "For example 2d6+3 or 4d6k3",
|
|
156
|
+
}),
|
|
157
|
+
}),
|
|
158
|
+
minTier: "member",
|
|
159
|
+
run: ({ expression }) => roll(expression, random),
|
|
160
|
+
}),
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const dice = createDice();
|