pi-roundtable 0.3.0 → 0.5.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 CHANGED
@@ -5,6 +5,34 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-10-01
9
+
10
+ ### Changed
11
+
12
+ - `@earendil-works/pi-ai` and `@earendil-works/pi-coding-agent` are no longer pinned to one version: they take `>=0.99.2 <1`, so a host picks up a newer model table with `bun update @earendil-works/pi-ai @earendil-works/pi-coding-agent`, or its own direct dependency, without waiting for a pi-roundtable release. Pinned to 0.87.1 before, the host's model list stopped at the models that version knew. 0.99.2 is the lowest version the tests run against, and the lockfile keeps CI on it, so a host that updates further runs on versions this package was not tested with.
13
+ - `pi-mcp-adapter` is 4.0.0 (was 2.37.0). Version 2.37.0 declared support for pi-ai up to 0.87 only; 4.0.0 declares 0.99. It stays pinned. Checked by connecting a session built the way the core builds one to a local MCP server that requires a bearer token: the tool registered and a call returned its result.
14
+ - A judge or agent model that the newer table lists but the host has no login for fails with the provider's "Provider is not configured" error, where an unlisted model fails with "is not available".
15
+
16
+ ### Fixed
17
+
18
+ - Agents had no `skill_list` tool, although the `agent_create` description, the built-in `writing-skills` skill and the skill errors all tell them to call it. The `skills` addon now mounts it for agent sessions, at the member tier like its entry in the tool tiers. A host that mounted `skillListExtension` itself for agents (the kit still exports it) now offers the tool twice and should drop its own copy.
19
+
20
+ ## [0.4.0] - 2026-10-01
21
+
22
+ ### Added
23
+
24
+ - `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.
25
+ - `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.
26
+ - `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`.
27
+ - A documentation site at pi-roundtable.wayneh.tw, in English and Traditional Chinese.
28
+
29
+ ### Changed
30
+
31
+ - `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.
32
+ - 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.
33
+ - `PluginContext` has a new member, `apiKey`; a hand-written `PluginContext` (only the core's own and tests') needs it.
34
+ - The READMEs no longer say `/roundtable help` opens a control panel. The core registers `/roundtable schedule list` and `/roundtable schedule cancel` only.
35
+
8
36
  ## [0.3.0] - 2026-10-01
9
37
 
10
38
  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 help` opens the control panel.
70
+ Once it runs, the agents in `agents.ts` have their channels, and `/roundtable schedule list` shows their schedules.
71
71
  On `SIGTERM` or `SIGINT` it finishes running work before it stops.
72
72
 
73
73
  ## A plugin
package/README.zh-TW.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-roundtable
2
2
 
3
- [English](README.md) | 繁體中文
3
+ [English](./README.md) | 繁體中文
4
4
 
5
5
  一個建立在 [Pi](https://github.com/earendil-works/pi) 上的 Discord 智慧體(agent)伺服器。
6
6
  你會在一個 Discord 伺服器裡得到一組 AI 智慧體:每個智慧體擁有一個頻道和一段對話,彼此共用工具與記憶,你用 TypeScript 寫外掛(plugin)來擴充這個 bot。
@@ -67,7 +67,7 @@ Bun 會自行載入 `.env`,`.gitignore` 也已讓它不進 Git。
67
67
  ### `start`
68
68
 
69
69
  `bunx roundtable start` 先執行不需要網路的檢查,其中任何一項失敗就停下來,並印出與 `doctor` 相同的訊息;全部通過則啟動 bot。
70
- 啟動後,`agents.ts` 裡的智慧體都有了自己的頻道,`/roundtable help` 會開啟控制面板。
70
+ 啟動後,`agents.ts` 裡的智慧體都有了自己的頻道,`/roundtable schedule list` 可以列出它們的排程。
71
71
  收到 `SIGTERM` 或 `SIGINT` 時,它會先讓進行中的工作完成再停止。
72
72
 
73
73
  ## 外掛
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` |
@@ -287,7 +289,7 @@ They are on by default, so a bot that says nothing about them has all three.
287
289
  | Addon | Plugin | Switch in `roundtable.config.ts` | What it adds | When it is off |
288
290
  |---|---|---|---|---|
289
291
  | Memory | `memory` (provides `MEMORY`) | `memory: false` | The memory table, the `memory_add`, `memory_search` and `memory_remove` tools, and the memory block of every system prompt | No memory tools and no block; the table is left as it is |
290
- | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
292
+ | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_list`, `skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
291
293
  | Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
292
294
 
293
295
  A switched-off addon's tables and rows are never touched: turning it on again finds them as they were.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,11 +45,11 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@babel/parser": "7.29.9",
48
- "@earendil-works/pi-ai": "0.87.1",
49
- "@earendil-works/pi-coding-agent": "0.87.1",
48
+ "@earendil-works/pi-ai": ">=0.99.2 <1",
49
+ "@earendil-works/pi-coding-agent": ">=0.99.2 <1",
50
50
  "canvas": "3.2.3",
51
51
  "discord.js": "14.27.0",
52
- "pi-mcp-adapter": "2.37.0",
52
+ "pi-mcp-adapter": "4.0.0",
53
53
  "pino": "10.3.1",
54
54
  "typebox": "1.3.34",
55
55
  "unpdf": "1.8.1"
@@ -31,9 +31,10 @@ const refused = (...problems: string[]): AddPluginReport => ({
31
31
  });
32
32
 
33
33
  /**
34
- * Creates `plugins/<name>.ts` and its test from the `hello` template and lists the plugin in
35
- * `roundtable.config.ts`. Everything is checked and rendered before the first write, so a refusal
36
- * leaves the project untouched.
34
+ * Creates `plugins/<name>.ts` and its test and lists the plugin in `roundtable.config.ts`: the
35
+ * copy of an official plugin when `name` is one, else a plugin made from the `hello` template.
36
+ * Everything is checked and rendered before the first write, so a refusal leaves the project
37
+ * untouched.
37
38
  */
38
39
  export function addPlugin(inputs: AddPluginInputs): AddPluginReport {
39
40
  const { cwd, name } = inputs;
package/src/cli/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { loadConfigFile, type Ports } from "./project.ts";
12
12
  import { formatOutcomes } from "./report.ts";
13
13
  import { assemble, providerLogin } from "./runtime.ts";
14
14
  import { start } from "./start.ts";
15
+ import { OFFICIAL_PLUGINS } from "./templates.ts";
15
16
 
16
17
  /** What the command line reads from its surroundings; tests replace every part. */
17
18
  export interface CliEnvironment {
@@ -31,6 +32,8 @@ const USAGE = `roundtable: a Discord agent server on Pi
31
32
  roundtable doctor [--reachable] check the setup and say how to fix what is wrong
32
33
  roundtable start run the checks that need no network, then the bot
33
34
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
35
+ the names ${OFFICIAL_PLUGINS.join(" and ")} are reserved for the official plugins,
36
+ which are copied in ready to run instead of the template
34
37
  `;
35
38
 
36
39
  /** The package's own version and the Bun range it needs, from the package.json beside the source. */
@@ -13,6 +13,16 @@ export const TEMPLATES_DIR = join(import.meta.dir, "../../templates");
13
13
  /** The directory of the plugin template, rendered once for `hello` and again for every `add plugin`. */
14
14
  const PLUGIN_DIR = "plugin";
15
15
 
16
+ /** The directory of the official plugins, one directory each, named as `add plugin` names them. */
17
+ const OFFICIAL_DIR = "official";
18
+
19
+ /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
20
+ export const OFFICIAL_PLUGINS = ["codex-images", "dice"] as const;
21
+
22
+ /** Whether `name` is one of the official plugins. */
23
+ export const isOfficialPlugin = (name: string): boolean =>
24
+ (OFFICIAL_PLUGINS as readonly string[]).includes(name);
25
+
16
26
  /** What a template file may say, replaced when it is rendered. */
17
27
  export interface Substitutions {
18
28
  /** The project's package name. */
@@ -71,19 +81,22 @@ function fill(text: string, values: Record<string, string>): string {
71
81
  });
72
82
  }
73
83
 
74
- /** The plugin and its test, written for `names`. */
84
+ /** The plugin and its test, written for `names`: the official plugin of that name, or else the `hello` template. */
75
85
  export function renderPlugin(
76
86
  names: PluginNames,
77
87
  dir = TEMPLATES_DIR,
78
88
  ): Rendered[] {
79
89
  const values = { NAME: names.name, IDENT: names.ident, TOOL: names.tool };
90
+ const source = isOfficialPlugin(names.name)
91
+ ? join(OFFICIAL_DIR, names.name)
92
+ : PLUGIN_DIR;
80
93
  return [
81
94
  ["plugin.ts", `plugins/${names.name}.ts`],
82
95
  ["plugin.test.ts.tmpl", `plugins/${names.name}.test.ts`],
83
- ].map(([source, path]) => ({
96
+ ].map(([file, path]) => ({
84
97
  path: path as string,
85
98
  content: fill(
86
- readFileSync(join(dir, PLUGIN_DIR, source as string), "utf8"),
99
+ readFileSync(join(dir, source, file as string), "utf8"),
87
100
  values,
88
101
  ),
89
102
  }));
@@ -101,7 +114,11 @@ export function renderProject(
101
114
  };
102
115
  const skeleton = filesUnder(dir)
103
116
  .map((file) => relative(dir, file))
104
- .filter((path) => !path.startsWith(`${PLUGIN_DIR}/`))
117
+ .filter(
118
+ (path) =>
119
+ !path.startsWith(`${PLUGIN_DIR}/`) &&
120
+ !path.startsWith(`${OFFICIAL_DIR}/`),
121
+ )
105
122
  .map((path) => ({
106
123
  path: targetOf(path),
107
124
  content: fill(readFileSync(join(dir, path), "utf8"), values),
@@ -4,6 +4,7 @@ import { SkillStore } from "../modules/skills/skill-store.ts";
4
4
  import {
5
5
  SKILL_LIST_TOOL,
6
6
  SKILL_TOOLS,
7
+ skillListExtension,
7
8
  skillToolsExtension,
8
9
  } from "../modules/skills/skill-tools.ts";
9
10
  import type { RoundtablePlugin } from "../plugin.ts";
@@ -57,6 +58,7 @@ export function skillsPlugin(
57
58
  services.provide(SKILLS, built);
58
59
  return {
59
60
  sessionTools: [
61
+ agentOnly("skill-list", () => skillListExtension(built)),
60
62
  agentOnly("skill-tools", () =>
61
63
  skillToolsExtension(built, (name) => {
62
64
  // The agent server is set up after this plugin and read when a session is built.
@@ -64,7 +66,10 @@ export function skillsPlugin(
64
66
  }),
65
67
  ),
66
68
  ],
67
- agentSelection: () => ({ tools: SKILL_TOOLS, groups: [] }),
69
+ agentSelection: () => ({
70
+ tools: [SKILL_LIST_TOOL, ...SKILL_TOOLS],
71
+ groups: [],
72
+ }),
68
73
  toolTiers: SKILL_TIERS,
69
74
  };
70
75
  },
@@ -27,7 +27,10 @@ import {
27
27
  /** How prompts refer back to the owner. */
28
28
  export type Pronouns = "he" | "she" | "they";
29
29
 
30
- /** Who holds a tier besides the owner: user ids, role ids, and for members everyone. */
30
+ /**
31
+ * Who holds a tier besides the owner: user ids, role ids, or everyone. `everyone` works under any
32
+ * tier it is written in, so under `admins` it makes every author an admin.
33
+ */
31
34
  export interface TierConfig {
32
35
  users?: readonly string[];
33
36
  roles?: readonly string[];
@@ -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,
@@ -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 {
@@ -75,7 +75,10 @@ export interface SpeakerPolicy {
75
75
  resolve(author: SpeakerFacts): Speaker | undefined;
76
76
  }
77
77
 
78
- /** Who holds one tier: user ids, role ids, and (for members) everyone. */
78
+ /**
79
+ * Who holds one tier: user ids, role ids, or everyone. `everyone` works under any tier it is
80
+ * written in, so under `admins` it makes every author an admin.
81
+ */
79
82
  export interface TierMembers {
80
83
  users?: readonly string[];
81
84
  roles?: readonly string[];
@@ -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 roundtable = new Roundtable(defined.options, defined.plugins);
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;
@@ -102,6 +102,7 @@
102
102
  "skill_group_delete",
103
103
  "skill_group_set",
104
104
  "skill_link",
105
+ "skill_list",
105
106
  "skill_unlink",
106
107
  "skill_update"
107
108
  ],
@@ -125,6 +126,7 @@
125
126
  "skill_group_delete",
126
127
  "skill_group_set",
127
128
  "skill_link",
129
+ "skill_list",
128
130
  "skill_unlink",
129
131
  "skill_update",
130
132
  "write"
@@ -187,6 +189,7 @@
187
189
  "skill_group_delete",
188
190
  "skill_group_set",
189
191
  "skill_link",
192
+ "skill_list",
190
193
  "skill_unlink",
191
194
  "skill_update"
192
195
  ],
@@ -209,6 +212,7 @@
209
212
  "skill_group_delete",
210
213
  "skill_group_set",
211
214
  "skill_link",
215
+ "skill_list",
212
216
  "skill_unlink",
213
217
  "skill_update",
214
218
  "write"
@@ -280,6 +284,7 @@
280
284
  "skill_group_delete": "admin",
281
285
  "skill_group_set": "admin",
282
286
  "skill_link": "admin",
287
+ "skill_list": "member",
283
288
  "skill_unlink": "admin",
284
289
  "skill_update": "admin",
285
290
  "web_search": "member",
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],
@@ -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,223 @@
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(".");
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
+ /** The one image an `image_generation_call` item carries; anything else is not an image. */
62
+ function imageOf(value: unknown): Uint8Array | undefined {
63
+ const item = object(value);
64
+ if (item.type !== "image_generation_call") return undefined;
65
+ if (
66
+ item.status !== "completed" ||
67
+ typeof item.result !== "string" ||
68
+ !item.result
69
+ ) {
70
+ const { result: _result, ...detail } = item;
71
+ throw new ImageNotGeneratedError(
72
+ `Codex image generation did not complete (status ${String(item.status)}): ${JSON.stringify(detail).slice(0, 300)}`,
73
+ );
74
+ }
75
+ if (item.result.length > MAX_IMAGE_BASE64)
76
+ throw new Error("the image exceeds 32 MiB");
77
+ return new Uint8Array(Buffer.from(item.result, "base64"));
78
+ }
79
+
80
+ /** The frames of a server-sent event stream: the text between blank lines. */
81
+ async function* frames(
82
+ body: ReadableStream<Uint8Array>,
83
+ ): AsyncGenerator<string> {
84
+ const decoder = new TextDecoder();
85
+ let buffer = "";
86
+ for await (const chunk of body) {
87
+ buffer += decoder.decode(chunk, { stream: true });
88
+ let match = /\r?\n\r?\n/.exec(buffer);
89
+ while (match) {
90
+ yield buffer.slice(0, match.index);
91
+ buffer = buffer.slice(match.index + match[0].length);
92
+ match = /\r?\n\r?\n/.exec(buffer);
93
+ }
94
+ }
95
+ if (buffer.trim()) yield buffer;
96
+ }
97
+
98
+ /** The event a frame carries, or undefined for a frame without data. */
99
+ function eventOf(frame: string): Record<string, unknown> | undefined {
100
+ const data = frame
101
+ .split(/\r?\n/)
102
+ .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trim()] : []))
103
+ .join("\n");
104
+ if (!data || data === "[DONE]") return undefined;
105
+ try {
106
+ return object(JSON.parse(data));
107
+ } catch {
108
+ throw new Error(`Codex sent a malformed event: ${data.slice(0, 200)}`);
109
+ }
110
+ }
111
+
112
+ /** Reads the events until the response completes, and returns its one image. */
113
+ async function parseImageStream(response: Response): Promise<Uint8Array> {
114
+ if (!response.body) throw new Error("Codex returned no body");
115
+ let image: Uint8Array | undefined;
116
+ for await (const frame of frames(response.body)) {
117
+ const event = eventOf(frame);
118
+ switch (event?.type) {
119
+ case "error":
120
+ case "response.failed": {
121
+ const error = object(object(event.response).error ?? event.error);
122
+ throw new Error(
123
+ `Codex failed: ${String(error.message ?? error.code ?? "unknown error")}`,
124
+ );
125
+ }
126
+ case "response.incomplete":
127
+ throw new Error("the Codex response was incomplete");
128
+ case "response.output_item.done":
129
+ image ??= imageOf(event.item);
130
+ break;
131
+ case "response.completed": {
132
+ const output = object(event.response).output;
133
+ if (Array.isArray(output))
134
+ for (const item of output) image ??= imageOf(item);
135
+ if (!image)
136
+ throw new ImageNotGeneratedError("Codex answered without an image");
137
+ return image;
138
+ }
139
+ default:
140
+ break;
141
+ }
142
+ }
143
+ throw new Error("the Codex stream ended before completion");
144
+ }
145
+
146
+ async function generateImage(
147
+ prompt: string,
148
+ references: readonly ReferenceImage[],
149
+ token: string,
150
+ model: string,
151
+ fetchImpl: CodexFetch,
152
+ ): Promise<Uint8Array> {
153
+ const response = await fetchImpl(CODEX_RESPONSES_URL, {
154
+ method: "POST",
155
+ redirect: "error",
156
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
157
+ headers: {
158
+ authorization: `Bearer ${token}`,
159
+ "chatgpt-account-id": chatGptAccountId(token),
160
+ originator: "pi",
161
+ "openai-beta": "responses=experimental",
162
+ accept: "text/event-stream",
163
+ "content-type": "application/json",
164
+ },
165
+ body: JSON.stringify({
166
+ model,
167
+ store: false,
168
+ stream: true,
169
+ instructions:
170
+ "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.",
171
+ input: [
172
+ {
173
+ role: "user",
174
+ content: [
175
+ { type: "input_text", text: prompt },
176
+ ...references.map((image) => ({
177
+ type: "input_image",
178
+ image_url: `data:${image.mimeType};base64,${image.data}`,
179
+ })),
180
+ ],
181
+ },
182
+ ],
183
+ tools: [{ type: "image_generation", output_format: "png" }],
184
+ tool_choice: "auto",
185
+ parallel_tool_calls: false,
186
+ text: { verbosity: "low" },
187
+ }),
188
+ });
189
+ if (!response.ok) {
190
+ const detail = (await response.text()).slice(0, 300);
191
+ throw new Error(`Codex answered ${response.status}: ${detail}`);
192
+ }
193
+ return parseImageStream(response);
194
+ }
195
+
196
+ 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.`;
197
+
198
+ /** The plugin, with its request function and model replaceable for a test. */
199
+ export function createCodexImages(options: CodexImagesOptions = {}) {
200
+ const { fetch: fetchImpl = fetch, model = CODEX_MODEL } = options;
201
+ let apiKey: ((provider: string) => Promise<string | undefined>) | undefined;
202
+ // The login refreshes its token, so each image reads it again instead of keeping the first.
203
+ const token = async (): Promise<string> => {
204
+ const value = await apiKey?.(CODEX_PROVIDER);
205
+ if (!value) throw new PluginError(`plugin codex-images: ${NO_LOGIN}`);
206
+ return value;
207
+ };
208
+ return definePlugin({
209
+ name: "codex-images",
210
+ providers: {
211
+ // The images slot draws an agent's avatar from a prompt and reference pictures.
212
+ images: async (prompt, references) =>
213
+ generateImage(prompt, references, await token(), model, fetchImpl),
214
+ },
215
+ async setup(context) {
216
+ apiKey = context.apiKey;
217
+ await token();
218
+ return {};
219
+ },
220
+ });
221
+ }
222
+
223
+ 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,195 @@
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
+ default:
45
+ return rule satisfies never;
46
+ }
47
+ }
48
+
49
+ /** One term rolled: what it shows, and what it adds before its sign applies. */
50
+ interface Rolled {
51
+ shown: string;
52
+ value: number;
53
+ dice: number;
54
+ }
55
+
56
+ /** A plain number term. */
57
+ function rollNumber(term: string): Rolled {
58
+ if (term.length > 7 || Number(term) > MAX_MODIFIER)
59
+ return refuse(`The number ${term} is over ${MAX_MODIFIER}.`);
60
+ return { shown: String(Number(term)), value: Number(term), dice: 0 };
61
+ }
62
+
63
+ /** How a fate die shows: + for 1, - for -1, and 0. */
64
+ function fateSymbol(face: number): string {
65
+ if (face > 0) return "+";
66
+ if (face < 0) return "-";
67
+ return "0";
68
+ }
69
+
70
+ /** `count` fate dice, each one of -1, 0, or +1. */
71
+ function rollFate(count: number, random: Random): Rolled {
72
+ const faces = Array.from(
73
+ { length: count },
74
+ () => Math.floor(random() * 3) - 1,
75
+ );
76
+ const symbols = faces.map(fateSymbol);
77
+ return {
78
+ shown: `[${symbols.join(", ")}]`,
79
+ value: faces.reduce((sum, face) => sum + face, 0),
80
+ dice: count,
81
+ };
82
+ }
83
+
84
+ /** `count` dice of `sides` sides, with an optional keep or drop rule that removes some of them. */
85
+ function rollPool(
86
+ term: string,
87
+ count: number,
88
+ sides: number,
89
+ rule: { kind: KeepDrop; n: number } | undefined,
90
+ random: Random,
91
+ ): Rolled {
92
+ if (sides < 2 || sides > MAX_SIDES)
93
+ return refuse(`"${term}" needs between 2 and ${MAX_SIDES} sides.`);
94
+ const rolls = Array.from(
95
+ { length: count },
96
+ () => Math.floor(random() * sides) + 1,
97
+ );
98
+ if (rule && (rule.n < 1 || rule.n >= count))
99
+ return refuse(
100
+ `"${term}" must keep or drop at least 1 and fewer than ${count} dice.`,
101
+ );
102
+ const dropped = new Set(rule ? removed(rolls, rule.kind, rule.n) : []);
103
+ const shown = rolls.map((value, index) =>
104
+ dropped.has(index) ? `(${value})` : String(value),
105
+ );
106
+ return {
107
+ shown: `[${shown.join(", ")}]`,
108
+ value: rolls.reduce(
109
+ (sum, value, index) => sum + (dropped.has(index) ? 0 : value),
110
+ 0,
111
+ ),
112
+ dice: count,
113
+ };
114
+ }
115
+
116
+ /** How many dice a term rolls: the count it writes, else 4 for fate dice and 1 for the rest. */
117
+ function diceCount(countText: string | undefined, fate: string | undefined) {
118
+ if (countText) return Number(countText);
119
+ return fate ? FATE_DEFAULT : 1;
120
+ }
121
+
122
+ function rollTerm(term: string, random: Random): Rolled {
123
+ if (/^\d+$/.test(term)) return rollNumber(term);
124
+ const match = TERM.exec(term);
125
+ if (!match) return refuse(`"${term}" is not a dice term.`);
126
+ const [, countText, fate, sidesText, rule, nText] = match;
127
+ const count = diceCount(countText, fate);
128
+ if (count < 1) return refuse(`"${term}" rolls no dice.`);
129
+ if (count > MAX_DICE)
130
+ return refuse(`"${term}" rolls more than ${MAX_DICE} dice.`);
131
+ if (fate) return rollFate(count, random);
132
+ return rollPool(
133
+ term,
134
+ count,
135
+ Number(sidesText),
136
+ rule ? { kind: rule as KeepDrop, n: Number(nText) } : undefined,
137
+ random,
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Rolls an expression of dice terms and numbers joined by `+` and `-`, and writes the result as
143
+ * text such as `2d6+3: [3, 5] + 3 = 11`. A term is `NdS`, an optional keep or drop rule
144
+ * (`k`/`kh` keep the highest, `kl` the lowest, `d`/`dl` drop the lowest, `dh` the highest, each
145
+ * with a count), `NdF` fate dice (default 4), or a whole number. Dropped dice show in parentheses.
146
+ * An invalid or oversized expression throws a `ToolRefusal` that says what to fix.
147
+ */
148
+ export function roll(expression: string, random: Random = Math.random): string {
149
+ const compact = expression.replace(/\s+/g, "");
150
+ if (!compact) return refuse("The expression is empty.");
151
+ if (compact.length > MAX_LENGTH)
152
+ return refuse(`The expression is over ${MAX_LENGTH} characters.`);
153
+ const parts = [...compact.toLowerCase().matchAll(/([+-]?)([^+-]+)/g)];
154
+ if (parts.map((part) => part[0]).join("") !== compact.toLowerCase())
155
+ return refuse(`"${compact}" is not a sum of dice and numbers.`);
156
+ let total = 0;
157
+ let dice = 0;
158
+ let line = "";
159
+ for (const [index, [, sign = "", term = ""]] of parts.entries()) {
160
+ if (index > 0 && !sign)
161
+ return refuse(`"${compact}" is not a sum of dice and numbers.`);
162
+ const rolled = rollTerm(term, random);
163
+ dice += rolled.dice;
164
+ if (dice > MAX_DICE)
165
+ return refuse(`The expression rolls more than ${MAX_DICE} dice in all.`);
166
+ total += sign === "-" ? -rolled.value : rolled.value;
167
+ const lead = sign === "-" ? "-" : "";
168
+ line += index === 0 ? `${lead}${rolled.shown}` : ` ${sign} ${rolled.shown}`;
169
+ }
170
+ return `${compact}: ${line} = ${total}`;
171
+ }
172
+
173
+ /** The plugin, with the random source replaceable for a test. */
174
+ export function createDice(random: Random = Math.random) {
175
+ return definePlugin({
176
+ name: "dice",
177
+ setup: () => ({
178
+ tools: [
179
+ defineTool({
180
+ name: "roll_dice",
181
+ 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.`,
182
+ parameters: Type.Object({
183
+ expression: Type.String({
184
+ description: "For example 2d6+3 or 4d6k3",
185
+ }),
186
+ }),
187
+ minTier: "member",
188
+ run: ({ expression }) => roll(expression, random),
189
+ }),
190
+ ],
191
+ }),
192
+ });
193
+ }
194
+
195
+ export const dice = createDice();