pi-roundtable 0.5.2 → 0.6.1

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,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.1] - 2026-10-02
9
+
10
+ ### Fixed
11
+
12
+ - `add plugin` and `add package` kept a one-line `plugins` list on one line however long it grew, so a project's `biome check` failed once the line passed 80 columns. A list that would pass 80 columns is now put one element a line, as the formatter writes it; a list already over several lines keeps its layout, and one holding a comment stays as it was.
13
+
14
+ ## [0.6.0] - 2026-10-02
15
+
16
+ ### Added
17
+
18
+ - `roundtable add package <spec>` installs a Pi package with `bun add`, loads its extensions to find the tools they register, and writes `plugins/<name>.ts` and its test: the plugin lists the package in `piPackages`, selects its tools for every agent turn with `agentSelection`, and requires them with `requiredTools`. It gives no tool a tier, so a tool no other plugin tiers stays the owner's until the operator's `toolTiers` lowers it. It refuses before installing when the plugin file exists or the config cannot take the plugin, and after installing when the package declares no Pi extensions or they fail to load, naming the `bun remove` command.
19
+
20
+ ### Changed
21
+
22
+ - The `piPackages` guide and its example now say that loading a package only registers its tools: a turn uses the tools it selects, so a plugin also lists them in `agentSelection`. The example used to list `piPackages` alone, which loaded `pi-web-access` without any turn being able to call its tools.
23
+
8
24
  ## [0.5.2] - 2026-10-02
9
25
 
10
26
  ### Changed
package/README.md CHANGED
@@ -82,6 +82,7 @@ On `SIGTERM` or `SIGINT` it finishes running work before it stops.
82
82
  ## A plugin
83
83
 
84
84
  `roundtable add plugin <name>` creates `plugins/<name>.ts` and its test and lists it in `roundtable.config.ts`.
85
+ `roundtable add package <spec>` does the same for a Pi package from npm: it installs the package and writes a plugin that loads its extensions and selects its tools.
85
86
  A plugin is an object with a name and a `setup` function that returns what it adds; this one gives every agent a tool:
86
87
 
87
88
  ```ts
@@ -165,6 +166,7 @@ export default {
165
166
  | `roundtable doctor [--reachable]` | Checks the setup and says how to fix what is wrong |
166
167
  | `roundtable start` | Runs the checks that need no network, then the bot |
167
168
  | `roundtable add plugin <name>` | Adds `plugins/<name>.ts` and its test, and lists it in the config |
169
+ | `roundtable add package <spec>` | Installs a Pi package with `bun add` and adds a plugin that loads it and gives its tools to every agent turn |
168
170
 
169
171
  ## Changes and license
170
172
 
package/README.zh-TW.md CHANGED
@@ -76,6 +76,7 @@ Bun 會自己讀 `.env`,`.gitignore` 也已經擋掉它。
76
76
  ## 外掛
77
77
 
78
78
  `roundtable add plugin <name>` 會建立 `plugins/<name>.ts` 和它的測試,並把它列進 `roundtable.config.ts`。
79
+ `roundtable add package <spec>` 對 npm 上的 Pi package 做同樣的事:安裝套件,並寫好一個載入它的擴充、選用它的工具的外掛。
79
80
  外掛是一個有名稱和 `setup` 函式的物件,`setup` 回傳它要新增的東西;下面這個外掛給每個智慧體一個工具:
80
81
 
81
82
  ```ts
@@ -158,6 +159,7 @@ export default {
158
159
  | `roundtable doctor [--reachable]` | 檢查設定,並說明如何修正有問題的地方 |
159
160
  | `roundtable start` | 先執行不需要網路的檢查,再啟動 bot |
160
161
  | `roundtable add plugin <name>` | 新增 `plugins/<name>.ts` 和它的測試,並列進設定 |
162
+ | `roundtable add package <spec>` | 用 `bun add` 安裝 Pi package,並新增一個載入它、把它的工具交給每一輪 agent 的外掛 |
161
163
 
162
164
  ## 變更與授權
163
165
 
package/docs/plugins.md CHANGED
@@ -67,6 +67,8 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
67
67
  The name is lowercase words joined by dashes, such as `my-notes`.
68
68
  Two names are reserved for the [official plugins](#official-plugins): `codex-images` and `dice` copy a ready-made plugin into the project.
69
69
 
70
+ `roundtable add package <spec>` adds a Pi package from npm: it runs `bun add <spec>`, loads the package's extensions to find the tools they register, and writes `plugins/<name>.ts` and its test, named after the package without its scope, as [`piPackages`](#pipackages-pi-extensions-every-session-loads) describes.
71
+
70
72
  ## The plugin object
71
73
 
72
74
  ```ts
@@ -1268,6 +1270,17 @@ export function alwaysOn(tools: () => string[]) {
1268
1270
 
1269
1271
  List npm packages whose Pi extensions every conversation session should load, and install them in your project (`bun add pi-web-access`).
1270
1272
  Two plugins that name the same package load it once.
1273
+ Sessions load only these packages: the Pi packages in the host's own Pi settings, and its extension folders, stay out.
1274
+
1275
+ Loading a package registers its tools, but a turn uses only the tools it selects, so list them in [`agentSelection`](#agentselection-tools-every-agent-carries) too, and in `requiredTools` so startup fails when a package version drops one.
1276
+ A tool no plugin gives a tier is the owner's alone; the operator lowers it with `toolTiers` in `roundtable.config.ts`.
1277
+ Give a package's tools a tier in the plugin only when no other plugin does: the built-in `modules` plugin already gives `web_search`, `fetch_content`, and `get_search_content` the member tier, and a second plugin naming them is a `PluginError`.
1278
+
1279
+ `roundtable add package <spec>` writes such a plugin for you.
1280
+ It accepts a registry name, optionally scoped and versioned (`pi-web-access`, `@scope/name@1.2.3`), and refuses before installing when the config cannot take the plugin or `plugins/<name>.ts` exists.
1281
+ After `bun add`, it refuses a package whose `package.json` lists no extensions under `pi` and that has no `extensions` folder, and a package whose extensions fail to load; the package stays installed, and the message names the `bun remove` command.
1282
+ The plugin it writes lists every tool the package registers when it loads, gives none a tier, and its test checks that every listed tool is selected.
1283
+ A tool a package registers later, such as after a connection, is not found; add it to the list by hand.
1271
1284
 
1272
1285
  The built-in delegation worker also loads `pi-web-access` to search and read the web.
1273
1286
  `pi-roundtable` lists it as a peer dependency (`>=0.35.0 <0.36.0`), so `bun add pi-roundtable` installs it for you.
@@ -1278,11 +1291,19 @@ A project that has none installed stops at the `modules` plugin's setup with a `
1278
1291
  ```ts
1279
1292
  import { definePlugin } from "pi-roundtable";
1280
1293
 
1281
- /** Pi packages are npm packages whose Pi extensions every session loads; install each one in your project first. */
1294
+ /**
1295
+ * Pi packages are npm packages whose Pi extensions every session loads; install each one in your
1296
+ * project first. Loading a package registers its tools, and a turn uses only the tools it
1297
+ * selects, so the plugin selects them too. `roundtable add package <name>` writes this for you.
1298
+ */
1299
+ const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
1300
+
1282
1301
  export const webSearch = definePlugin({
1283
1302
  name: "web-search",
1284
1303
  setup: () => ({
1285
1304
  piPackages: ["pi-web-access"],
1305
+ agentSelection: () => ({ tools: WEB_TOOLS, groups: [] }),
1306
+ requiredTools: WEB_TOOLS,
1286
1307
  }),
1287
1308
  });
1288
1309
  ```
@@ -2,8 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { testPlugin } from "pi-roundtable/testing";
3
3
  import { webSearch } from "./packages.ts";
4
4
 
5
- test("the plugin asks every session to load pi-web-access", async () => {
5
+ test("the plugin asks every session to load pi-web-access and gives every agent turn its tools", async () => {
6
6
  const harness = await testPlugin(webSearch);
7
7
  expect(harness.contribution.piPackages).toEqual(["pi-web-access"]);
8
+ expect(harness.contribution.agentSelection?.().tools).toEqual([
9
+ "web_search",
10
+ "fetch_content",
11
+ "get_search_content",
12
+ ]);
8
13
  await harness.stop();
9
14
  });
@@ -1,9 +1,17 @@
1
1
  import { definePlugin } from "pi-roundtable";
2
2
 
3
- /** Pi packages are npm packages whose Pi extensions every session loads; install each one in your project first. */
3
+ /**
4
+ * Pi packages are npm packages whose Pi extensions every session loads; install each one in your
5
+ * project first. Loading a package registers its tools, and a turn uses only the tools it
6
+ * selects, so the plugin selects them too. `roundtable add package <name>` writes this for you.
7
+ */
8
+ const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
9
+
4
10
  export const webSearch = definePlugin({
5
11
  name: "web-search",
6
12
  setup: () => ({
7
13
  piPackages: ["pi-web-access"],
14
+ agentSelection: () => ({ tools: WEB_TOOLS, groups: [] }),
15
+ requiredTools: WEB_TOOLS,
8
16
  }),
9
17
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,108 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { addPluginToConfig, ConfigEditError } from "./config-edit.ts";
4
+ import type { PackagePorts } from "./pi-packages.ts";
5
+ import {
6
+ isPluginName,
7
+ pluginNames,
8
+ renderPackagePlugin,
9
+ writeRendered,
10
+ } from "./templates.ts";
11
+
12
+ const CONFIG = "roundtable.config.ts";
13
+
14
+ export interface AddPackageInputs {
15
+ cwd: string;
16
+ /** What `bun add` takes from the registry: a name, optionally scoped, optionally with `@<version>`. */
17
+ spec: string;
18
+ ports: PackagePorts;
19
+ /** Where the templates are; default the package's own. */
20
+ templates?: string;
21
+ }
22
+
23
+ export interface AddPackageReport {
24
+ ok: boolean;
25
+ problems: string[];
26
+ /** Paths written or changed, relative to `cwd`; `package.json` once the package is installed. */
27
+ changed: string[];
28
+ /** The tools the package registers, in the order its extensions register them. */
29
+ tools: string[];
30
+ }
31
+
32
+ const refused = (
33
+ problems: string[],
34
+ changed: string[] = [],
35
+ ): AddPackageReport => ({ ok: false, problems, changed, tools: [] });
36
+
37
+ /** A registry spec: an optional `@scope/`, the name, and an optional `@<version or tag>`. */
38
+ const SPEC = /^((?:@[a-z0-9][a-z0-9._-]*\/)?([a-z0-9][a-z0-9._-]*))(?:@(.+))?$/;
39
+
40
+ /**
41
+ * Installs the Pi package `spec`, finds the tools its extensions register, and writes
42
+ * `plugins/<name>.ts` and its test, named after the package without its scope: the plugin loads
43
+ * the package in every session and gives every agent turn its tools, which stay the owner's
44
+ * until the operator's `toolTiers` lowers one. Everything that can be checked without the package is checked before `bun add` runs,
45
+ * so those refusals leave the project untouched; a package that fails to load after it is
46
+ * installed is reported with the command that removes it.
47
+ */
48
+ export async function addPackage(
49
+ inputs: AddPackageInputs,
50
+ ): Promise<AddPackageReport> {
51
+ const { cwd, spec, ports } = inputs;
52
+ const parsed = SPEC.exec(spec);
53
+ if (!parsed?.[1] || !parsed[2])
54
+ return refused([
55
+ `${JSON.stringify(spec)} is not a package from the registry. Give its name, such as pi-web-access or @scope/name@1.2.3; install any other kind with bun add and list it in a plugin's piPackages by hand.`,
56
+ ]);
57
+ const pkg = parsed[1];
58
+ const name = parsed[2];
59
+ if (!isPluginName(name))
60
+ return refused([
61
+ `the plugin would be named ${JSON.stringify(name)}, which is not lowercase words joined by dashes. Install it with bun add and list it in a plugin's piPackages by hand.`,
62
+ ]);
63
+ const configPath = join(cwd, CONFIG);
64
+ if (!existsSync(configPath))
65
+ return refused([
66
+ `${CONFIG} is not in ${cwd}. Run this in the project directory, or create the project with \`roundtable init\`.`,
67
+ ]);
68
+ const names = pluginNames(name);
69
+ const paths = [`plugins/${name}.ts`, `plugins/${name}.test.ts`];
70
+ const existing = paths.filter((path) => existsSync(join(cwd, path)));
71
+ if (existing.length > 0)
72
+ return refused([
73
+ `${existing.join(" and ")} already ${existing.length === 1 ? "exists" : "exist"}. Remove the file, or list ${pkg} in that plugin's piPackages by hand.`,
74
+ ]);
75
+ let edited: string;
76
+ try {
77
+ edited = addPluginToConfig(readFileSync(configPath, "utf8"), names, CONFIG);
78
+ } catch (error) {
79
+ if (error instanceof ConfigEditError) return refused([error.message]);
80
+ throw error;
81
+ }
82
+ const installed = await ports.install(cwd, spec);
83
+ if (!installed.ok)
84
+ return refused([
85
+ `bun add ${spec} failed; nothing was written.`,
86
+ ...(installed.output ? [installed.output] : []),
87
+ ]);
88
+ let tools: string[];
89
+ try {
90
+ tools = await ports.tools(cwd, pkg);
91
+ } catch (error) {
92
+ return refused(
93
+ [
94
+ `${error instanceof Error ? error.message : String(error)} No plugin was written; run \`bun remove ${pkg}\` to uninstall it.`,
95
+ ],
96
+ ["package.json"],
97
+ );
98
+ }
99
+ const files = renderPackagePlugin(names, pkg, tools, inputs.templates);
100
+ writeRendered(cwd, files);
101
+ writeFileSync(configPath, edited);
102
+ return {
103
+ ok: true,
104
+ problems: [],
105
+ changed: ["package.json", ...files.map((file) => file.path), CONFIG],
106
+ tools,
107
+ };
108
+ }
package/src/cli/cli.ts CHANGED
@@ -2,12 +2,14 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import type { DefinedRoundtable } from "../core/define-roundtable.ts";
4
4
  import { Roundtable } from "../core/host.ts";
5
+ import { addPackage } from "./add-package.ts";
5
6
  import { addPlugin } from "./add-plugin.ts";
6
7
  import type { BunFacts } from "./checks/bun.ts";
7
8
  import { postgres } from "./checks/database.ts";
8
9
  import { doctor } from "./doctor.ts";
9
10
  import { fetchHttp } from "./http.ts";
10
11
  import { init } from "./init.ts";
12
+ import { type PackagePorts, piPackagePorts } from "./pi-packages.ts";
11
13
  import { loadConfigFile, type Ports } from "./project.ts";
12
14
  import { formatOutcomes } from "./report.ts";
13
15
  import { assemble, providerLogin } from "./runtime.ts";
@@ -21,6 +23,8 @@ export interface CliEnvironment {
21
23
  bun: () => BunFacts;
22
24
  version: () => string;
23
25
  ports: Ports;
26
+ /** Installs Pi packages and reads the tools they register, for `add package`. */
27
+ packages: PackagePorts;
24
28
  launch(defined: DefinedRoundtable): Promise<void>;
25
29
  out(line: string): void;
26
30
  err(line: string): void;
@@ -34,6 +38,8 @@ const USAGE = `roundtable: a Discord agent server on Pi
34
38
  roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
35
39
  the names ${OFFICIAL_PLUGINS.join(" and ")} are reserved for the official plugins,
36
40
  which are copied in ready to run instead of the template
41
+ roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
42
+ loads it and gives its tools to every agent turn
37
43
  `;
38
44
 
39
45
  /** The package's own version and the Bun range it needs, from the package.json beside the source. */
@@ -74,6 +80,7 @@ export function processEnvironment(): CliEnvironment {
74
80
  define: assemble,
75
81
  login: providerLogin,
76
82
  },
83
+ packages: piPackagePorts,
77
84
  launch: async (defined) => {
78
85
  const roundtable = new Roundtable(defined.options, defined.plugins);
79
86
  roundtable.listen();
@@ -122,8 +129,30 @@ export async function runCli(
122
129
  }
123
130
  if (command === "add") {
124
131
  const [what, name, ...extra] = rest;
132
+ if (what === "package" && name !== undefined && extra.length === 0) {
133
+ const report = await addPackage({
134
+ cwd: io.cwd,
135
+ spec: name,
136
+ ports: io.packages,
137
+ });
138
+ if (!report.ok) {
139
+ for (const line of report.problems) io.err(line);
140
+ return 1;
141
+ }
142
+ io.out(`Added the Pi package ${name}:`);
143
+ for (const path of report.changed) io.out(` ${path}`);
144
+ io.out(
145
+ report.tools.length > 0
146
+ ? `Its tools: ${report.tools.join(", ")}. One no plugin gives a tier is the owner's alone; lower it with toolTiers in roundtable.config.ts.`
147
+ : "It registers no tools when it loads; the plugin still loads it in every session.",
148
+ );
149
+ return 0;
150
+ }
125
151
  if (what !== "plugin" || name === undefined || extra.length > 0)
126
- return usage(io, "usage: roundtable add plugin <name>");
152
+ return usage(
153
+ io,
154
+ "usage: roundtable add plugin <name>, or roundtable add package <spec>",
155
+ );
127
156
  const report = addPlugin({ cwd: io.cwd, name });
128
157
  if (!report.ok) {
129
158
  for (const line of report.problems) io.err(line);
@@ -115,6 +115,50 @@ function pluginList(object: Node): Node | undefined {
115
115
  interface Insertion {
116
116
  at: number;
117
117
  text: string;
118
+ /** How many characters from `at` the text replaces; none when it only inserts. */
119
+ replaces?: number;
120
+ }
121
+
122
+ /** The width the formatter gives a line: a tab counts as its default indent width of 2. */
123
+ const LINE_WIDTH = 80;
124
+ const width = (line: string): number => line.replaceAll("\t", " ").length;
125
+
126
+ /**
127
+ * The one-line list with `ident` added, put one element a line when the line it sits on would
128
+ * pass the formatter's width, as the project's formatter would; undefined when the list already
129
+ * spans lines, holds anything besides its elements and commas, or still fits.
130
+ */
131
+ function expandedList(
132
+ source: string,
133
+ list: Node,
134
+ elements: readonly Node[],
135
+ ident: string,
136
+ ): Insertion | undefined {
137
+ const inner = source.slice(list.start + 1, list.end - 1);
138
+ if (inner.includes("\n")) return undefined;
139
+ const items = elements.map((element) =>
140
+ source.slice(element.start, element.end),
141
+ );
142
+ // The text around and between the elements, which must be only commas and spaces.
143
+ const edges = [
144
+ list.start + 1,
145
+ ...elements.flatMap((element) => [element.start, element.end]),
146
+ list.end - 1,
147
+ ];
148
+ let gaps = "";
149
+ for (let index = 0; index < edges.length; index += 2)
150
+ gaps += source.slice(edges[index], edges[index + 1]);
151
+ if (!/^[\s,]*$/.test(gaps)) return undefined;
152
+ const lineStart = source.lastIndexOf("\n", list.start) + 1;
153
+ const lineEnd = source.indexOf("\n", list.end);
154
+ const line = `${source.slice(lineStart, list.start)}[${[...items, ident].join(", ")}]${source.slice(list.end, lineEnd === -1 ? undefined : lineEnd)}`;
155
+ if (width(line) <= LINE_WIDTH) return undefined;
156
+ const indent = /^[ \t]*/.exec(source.slice(lineStart, list.start))?.[0] ?? "";
157
+ return {
158
+ at: list.start,
159
+ replaces: list.end - list.start,
160
+ text: `[\n${[...items, ident].map((item) => `${indent}\t${item},\n`).join("")}${indent}]`,
161
+ };
118
162
  }
119
163
 
120
164
  /** The text to add so `ident` joins the list, in the list's own layout. */
@@ -122,6 +166,8 @@ function listInsertion(source: string, list: Node, ident: string): Insertion {
122
166
  const elements = (list.elements as (Node | null)[]).filter(
123
167
  (element): element is Node => element !== null,
124
168
  );
169
+ const expanded = expandedList(source, list, elements, ident);
170
+ if (expanded) return expanded;
125
171
  const last = elements.at(-1);
126
172
  if (!last) return { at: list.start + 1, text: ident };
127
173
  const between = source.slice(last.end, list.end - 1);
@@ -161,8 +207,9 @@ function importInsertion(
161
207
 
162
208
  /**
163
209
  * Adds the plugin's import line and lists it in the `plugins` array of the default export, in the
164
- * file's own layout. It refuses, changing nothing, a file that does not parse, a default export
165
- * with no `plugins` list it can find, and a name already bound.
210
+ * file's own layout; a one-line list that would pass 80 columns is put one element a line. It
211
+ * refuses, changing nothing, a file that does not parse, a default export with no `plugins` list
212
+ * it can find, and a name already bound.
166
213
  */
167
214
  export function addPluginToConfig(
168
215
  source: string,
@@ -192,7 +239,10 @@ export function addPluginToConfig(
192
239
  ].sort((a, b) => b.at - a.at);
193
240
  let edited = source;
194
241
  for (const edit of edits)
195
- edited = edited.slice(0, edit.at) + edit.text + edited.slice(edit.at);
242
+ edited =
243
+ edited.slice(0, edit.at) +
244
+ edit.text +
245
+ edited.slice(edit.at + (edit.replaces ?? 0));
196
246
  // A result that does not parse is a bug here; never write it.
197
247
  parseModule(edited, file);
198
248
  return edited;
@@ -0,0 +1,79 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, sep } from "node:path";
4
+ import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
5
+ import { packageDir } from "../core/shared/package-dir.ts";
6
+
7
+ /** What `add package` asks of the world outside the project's files; tests replace both. */
8
+ export interface PackagePorts {
9
+ /** Installs `spec` into the project at `cwd`, as `bun add` does. */
10
+ install(cwd: string, spec: string): Promise<{ ok: boolean; output: string }>;
11
+ /**
12
+ * The tools the Pi extensions of the installed package `name` register when they load, looked
13
+ * up from the project at `cwd`. Throws when the package has no Pi extension or one fails to load.
14
+ */
15
+ tools(cwd: string, name: string): Promise<string[]>;
16
+ }
17
+
18
+ async function bunAdd(cwd: string, spec: string) {
19
+ const child = Bun.spawn(["bun", "add", spec], {
20
+ cwd,
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ });
24
+ const [stdout, stderr, code] = await Promise.all([
25
+ new Response(child.stdout).text(),
26
+ new Response(child.stderr).text(),
27
+ child.exited,
28
+ ]);
29
+ return { ok: code === 0, output: `${stdout}${stderr}`.trim() };
30
+ }
31
+
32
+ /** Whether the package says it holds Pi extensions: a `pi.extensions` list, or an `extensions` folder. */
33
+ function declaresExtensions(dir: string): boolean {
34
+ const manifest: { pi?: { extensions?: unknown } } = JSON.parse(
35
+ readFileSync(join(dir, "package.json"), "utf8"),
36
+ );
37
+ const listed = manifest.pi?.extensions;
38
+ return Array.isArray(listed)
39
+ ? listed.length > 0
40
+ : existsSync(join(dir, "extensions"));
41
+ }
42
+
43
+ /**
44
+ * Loads the package's extensions as a session would, from an empty Pi agent directory so no
45
+ * extension of the user's own joins in, and keeps only the ones inside the package.
46
+ */
47
+ async function registeredTools(cwd: string, name: string): Promise<string[]> {
48
+ const dir = packageDir(name, join(cwd, "package.json"));
49
+ // Pi would load a plain package's index file as an extension; a library is not a Pi package.
50
+ if (!declaresExtensions(dir))
51
+ throw new Error(
52
+ `${name} is not a Pi package: its package.json lists no extensions under "pi", and it has no extensions folder.`,
53
+ );
54
+ const agentDir = mkdtempSync(join(tmpdir(), "roundtable-package-"));
55
+ try {
56
+ const loaded = await discoverAndLoadExtensions([dir], cwd, agentDir);
57
+ const failed = loaded.errors.filter((error) => error.path.startsWith(dir));
58
+ if (failed.length > 0)
59
+ throw new Error(
60
+ `${name} failed to load: ${failed.map((error) => error.error).join("; ")}`,
61
+ );
62
+ const own = loaded.extensions.filter((extension) =>
63
+ `${extension.resolvedPath}${sep}`.startsWith(`${dir}${sep}`),
64
+ );
65
+ if (own.length === 0)
66
+ throw new Error(`${name} declares Pi extensions, but none loaded.`);
67
+ return [
68
+ ...new Set(own.flatMap((extension) => [...extension.tools.keys()])),
69
+ ];
70
+ } finally {
71
+ rmSync(agentDir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ /** The real installer and loader. */
76
+ export const piPackagePorts: PackagePorts = {
77
+ install: bunAdd,
78
+ tools: registeredTools,
79
+ };
@@ -16,6 +16,9 @@ const PLUGIN_DIR = "plugin";
16
16
  /** The directory of the official plugins, one directory each, named as `add plugin` names them. */
17
17
  const OFFICIAL_DIR = "official";
18
18
 
19
+ /** The directory of the plugin `add package` writes around a Pi package. */
20
+ const PACKAGE_DIR = "package";
21
+
19
22
  /** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
20
23
  export const OFFICIAL_PLUGINS = ["codex-images", "dice"] as const;
21
24
 
@@ -102,6 +105,40 @@ export function renderPlugin(
102
105
  }));
103
106
  }
104
107
 
108
+ /** The `TOOLS` constant as the project's formatter writes it: on one line when it fits in 80 columns. */
109
+ function toolsConstant(tools: readonly string[]): string {
110
+ const items = tools.map((tool) => JSON.stringify(tool));
111
+ const line = `const TOOLS: string[] = [${items.join(", ")}];`;
112
+ return line.length <= 80
113
+ ? line
114
+ : `const TOOLS: string[] = [\n${items.map((item) => `\t${item},\n`).join("")}];`;
115
+ }
116
+
117
+ /** The plugin `add package` writes around the Pi package `pkg`, and its test, with the tools the package registers. */
118
+ export function renderPackagePlugin(
119
+ names: PluginNames,
120
+ pkg: string,
121
+ tools: readonly string[],
122
+ dir = TEMPLATES_DIR,
123
+ ): Rendered[] {
124
+ const values = {
125
+ NAME: names.name,
126
+ IDENT: names.ident,
127
+ PACKAGE: pkg,
128
+ TOOLS: toolsConstant(tools),
129
+ };
130
+ return [
131
+ ["plugin.ts.tmpl", `plugins/${names.name}.ts`],
132
+ ["plugin.test.ts.tmpl", `plugins/${names.name}.test.ts`],
133
+ ].map(([file, path]) => ({
134
+ path: path as string,
135
+ content: fill(
136
+ readFileSync(join(dir, PACKAGE_DIR, file as string), "utf8"),
137
+ values,
138
+ ),
139
+ }));
140
+ }
141
+
105
142
  /** Every file of a new project: the skeleton and the `hello` plugin, paths relative to the project. */
106
143
  export function renderProject(
107
144
  substitutions: Substitutions,
@@ -115,7 +152,9 @@ export function renderProject(
115
152
  const skeleton = filesUnder(dir)
116
153
  .map((file) => relative(dir, file))
117
154
  .flatMap((path) =>
118
- path.startsWith(`${PLUGIN_DIR}/`) || path.startsWith(`${OFFICIAL_DIR}/`)
155
+ [PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR].some((sub) =>
156
+ path.startsWith(`${sub}/`),
157
+ )
119
158
  ? []
120
159
  : [
121
160
  {
@@ -8,4 +8,5 @@ A Discord agent server built on [pi-roundtable](https://www.npmjs.com/package/pi
8
8
 
9
9
  `roundtable.config.ts` holds the settings and lists the plugins; `plugins/` holds yours.
10
10
  Add another with `bunx roundtable add plugin <name>`, and test it with `bun test`.
11
+ Add a Pi package from npm with `bunx roundtable add package <name>`; it installs the package and writes the plugin that loads it.
11
12
  The plugin guide is in `node_modules/pi-roundtable/docs/plugins.md` and on the package's page.
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { __IDENT__ } from "./__NAME__.ts";
4
+
5
+ test("__NAME__ loads __PACKAGE__ and gives every agent turn its tools", async () => {
6
+ const harness = await testPlugin(__IDENT__);
7
+ const { piPackages, agentSelection, requiredTools } = harness.contribution;
8
+ expect(piPackages).toEqual(["__PACKAGE__"]);
9
+ expect(agentSelection?.().tools).toEqual([...(requiredTools ?? [])]);
10
+ await harness.stop();
11
+ });
@@ -0,0 +1,18 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** The tools __PACKAGE__ registered when `roundtable add package` installed it. */
4
+ __TOOLS__
5
+
6
+ /**
7
+ * Loads the Pi package __PACKAGE__ in every conversation session and gives every agent turn its
8
+ * tools. A tool no plugin gives a tier is the owner's alone; lower one with `toolTiers` in
9
+ * roundtable.config.ts, such as `toolTiers: { web_search: "member" }`.
10
+ */
11
+ export const __IDENT__ = definePlugin({
12
+ name: "__NAME__",
13
+ setup: () => ({
14
+ piPackages: ["__PACKAGE__"],
15
+ agentSelection: () => ({ tools: [...TOOLS], groups: [] }),
16
+ requiredTools: TOOLS,
17
+ }),
18
+ });