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 +16 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/docs/plugins.md +22 -1
- package/examples/packages.test.ts +6 -1
- package/examples/packages.ts +9 -1
- package/package.json +1 -1
- package/src/cli/add-package.ts +108 -0
- package/src/cli/cli.ts +30 -1
- package/src/cli/config-edit.ts +53 -3
- package/src/cli/pi-packages.ts +79 -0
- package/src/cli/templates.ts +40 -1
- package/templates/README.md +1 -0
- package/templates/package/plugin.test.ts.tmpl +11 -0
- package/templates/package/plugin.ts.tmpl +18 -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.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
|
-
/**
|
|
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
|
});
|
package/examples/packages.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { definePlugin } from "pi-roundtable";
|
|
2
2
|
|
|
3
|
-
/**
|
|
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
|
@@ -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(
|
|
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);
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -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
|
|
165
|
-
*
|
|
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 =
|
|
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
|
+
};
|
package/src/cli/templates.ts
CHANGED
|
@@ -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
|
-
|
|
155
|
+
[PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR].some((sub) =>
|
|
156
|
+
path.startsWith(`${sub}/`),
|
|
157
|
+
)
|
|
119
158
|
? []
|
|
120
159
|
: [
|
|
121
160
|
{
|
package/templates/README.md
CHANGED
|
@@ -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
|
+
});
|