pi-roundtable 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/docs/plugins.md +100 -5
- package/examples/shared-services.test.ts +28 -0
- package/examples/shared-services.ts +31 -0
- package/package.json +6 -3
- package/src/cli/add-plugin.ts +4 -3
- package/src/cli/cli.ts +3 -0
- package/src/cli/templates.ts +21 -4
- package/src/core/contract/services.ts +7 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/host.ts +7 -0
- package/src/core/http/listeners.ts +5 -25
- package/src/core/modules/delegation/web-research-worker.ts +14 -1
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/contributions.ts +1 -0
- package/src/core/registry/services.ts +73 -1
- package/src/core/runtime/session-factory.ts +1 -1
- package/src/core/shared/package-dir.ts +8 -5
- package/src/core/shared/unix-server.ts +26 -0
- package/src/core/testing/partial.ts +21 -0
- package/src/core/testing/recording-logger.ts +44 -0
- package/src/core/testing/test-host.ts +14 -1
- package/src/discord/index.ts +1 -1
- package/src/kit/channels.ts +1 -1
- package/src/kit/domain.ts +1 -1
- package/src/kit/holds.ts +1 -1
- package/src/kit/index.ts +2 -1
- package/src/kit/judging.ts +1 -1
- package/src/kit/memory.ts +1 -1
- package/src/kit/mirror.ts +1 -1
- package/src/kit/presentation.ts +1 -1
- package/src/kit/process.ts +5 -0
- package/src/kit/shell.ts +1 -1
- package/src/kit/skills.ts +1 -1
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/kit/tools.ts +1 -1
- package/src/kit/worker.ts +1 -1
- package/src/testing.ts +16 -2
- package/templates/agents.ts +5 -0
- package/templates/official/codex-images/plugin.test.ts.tmpl +118 -0
- package/templates/official/codex-images/plugin.ts +215 -0
- package/templates/official/dice/plugin.test.ts.tmpl +105 -0
- package/templates/official/dice/plugin.ts +166 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SessionManager,
|
|
7
7
|
SettingsManager,
|
|
8
8
|
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { PluginError } from "../../errors.ts";
|
|
9
10
|
import {
|
|
10
11
|
formatModelRef,
|
|
11
12
|
type ModelRef,
|
|
@@ -20,6 +21,18 @@ const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
|
|
|
20
21
|
const WORKER_PROMPT =
|
|
21
22
|
"You are a research worker. Another agent handed you the task below and will pass your report on. Do it with web search and page reading, preferring primary sources. Report in the task's language, self-contained, with a source link for each claim, and say what you could not verify.";
|
|
22
23
|
|
|
24
|
+
/** The folder of `pi-web-access`, a peer dependency the host installs, or a PluginError that says how. */
|
|
25
|
+
function webAccessDir(): string {
|
|
26
|
+
try {
|
|
27
|
+
return packageDir("pi-web-access");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new PluginError(
|
|
30
|
+
"the delegation worker loads pi-web-access, which this project does not have installed. It is a peer dependency of pi-roundtable: run `bun add pi-web-access@0.35.0` (or your own build of it, which the worker then uses too).",
|
|
31
|
+
{ cause: error },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export interface WebResearchWorkerOptions {
|
|
24
37
|
/** Shared with the rest of the host, so the Codex login refreshes in one place. */
|
|
25
38
|
modelRuntime: ModelRuntime;
|
|
@@ -36,7 +49,7 @@ export class WebResearchWorker {
|
|
|
36
49
|
|
|
37
50
|
constructor(options: WebResearchWorkerOptions) {
|
|
38
51
|
this.#options = options;
|
|
39
|
-
this.#webAccessPath =
|
|
52
|
+
this.#webAccessPath = webAccessDir();
|
|
40
53
|
mkdirSync(options.workDir, { recursive: true });
|
|
41
54
|
}
|
|
42
55
|
|
package/src/core/plugin.ts
CHANGED
|
@@ -268,6 +268,12 @@ export interface PluginContext {
|
|
|
268
268
|
providers: ResolvedProviders;
|
|
269
269
|
/** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
|
|
270
270
|
dashboard(): readonly string[];
|
|
271
|
+
/**
|
|
272
|
+
* The credential the host's model login holds for a provider, such as `openai-codex`: the same
|
|
273
|
+
* login the agents use. Resolves to `undefined` when the host has none for that provider, and
|
|
274
|
+
* never throws for that. The value is a secret: keep it out of logs and error messages.
|
|
275
|
+
*/
|
|
276
|
+
apiKey(provider: string): Promise<string | undefined>;
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
export interface RoundtablePlugin {
|
|
@@ -281,6 +287,14 @@ export interface RoundtablePlugin {
|
|
|
281
287
|
* list before any setup, and refuses the plugin when setup returns without providing one.
|
|
282
288
|
*/
|
|
283
289
|
provides?: readonly ServiceKey<unknown>[];
|
|
290
|
+
/**
|
|
291
|
+
* The services this plugin's setup reads with `get`, so the host can check them before any
|
|
292
|
+
* setup and any migration: a key no registered plugin provides, or one provided by a plugin
|
|
293
|
+
* registered after this one, is a PluginError that names both plugins and the fix. A service
|
|
294
|
+
* read only after startup, from a callback, is `services.lazy` instead, which does not depend
|
|
295
|
+
* on order. Leave out a service read with `find`, since that one may be absent.
|
|
296
|
+
*/
|
|
297
|
+
requires?: readonly ServiceKey<unknown>[];
|
|
284
298
|
/**
|
|
285
299
|
* Services this plugin replaces. The host drops the plugin that provides them and sets this one
|
|
286
300
|
* up where it stood, so it may read what the plugins before that place provide and nothing
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServiceKey, Services } from "../contract/services.ts";
|
|
2
|
-
import { PluginError } from "../errors.ts";
|
|
2
|
+
import { NotLinkedError, PluginError } from "../errors.ts";
|
|
3
3
|
import type { RoundtablePlugin } from "../plugin.ts";
|
|
4
4
|
|
|
5
5
|
/** The host's words for reading a service no plugin declares; `testPlugin` says how to give one instead. */
|
|
@@ -29,6 +29,22 @@ function declaredBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
|
|
|
29
29
|
return provides;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** The services a plugin requires, checked to be a list of keys; empty when it requires none. */
|
|
33
|
+
function requiredBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
|
|
34
|
+
const { requires } = plugin;
|
|
35
|
+
if (requires === undefined) return [];
|
|
36
|
+
if (!Array.isArray(requires))
|
|
37
|
+
throw new PluginError(
|
|
38
|
+
`plugin ${plugin.name}: requires must be a list of service keys made with serviceKey().`,
|
|
39
|
+
);
|
|
40
|
+
for (const key of requires)
|
|
41
|
+
if (typeof key?.id !== "string" || key.id === "")
|
|
42
|
+
throw new PluginError(
|
|
43
|
+
`plugin ${plugin.name}: requires has an entry that is not a service key; make each with serviceKey("<id>").`,
|
|
44
|
+
);
|
|
45
|
+
return requires;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** The services a plugin replaces, checked to be a list of keys; empty when it replaces none. */
|
|
33
49
|
function replacedBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
|
|
34
50
|
const { replaces } = plugin;
|
|
@@ -127,10 +143,16 @@ export class ServiceRegistry {
|
|
|
127
143
|
#setting: RoundtablePlugin | undefined;
|
|
128
144
|
/** The plugins that have read a service, such as one that adds its commands through it. */
|
|
129
145
|
readonly #readers = new Set<RoundtablePlugin>();
|
|
146
|
+
readonly #plugins: readonly RoundtablePlugin[];
|
|
147
|
+
/** What each plugin asked `lazy` for, to refuse a key nobody provides once every plugin is set up. */
|
|
148
|
+
readonly #lazy: { plugin: RoundtablePlugin; key: ServiceKey<unknown> }[] = [];
|
|
149
|
+
/** Whether every plugin is set up, from which a `lazy` reader returns its service. */
|
|
150
|
+
#settled = false;
|
|
130
151
|
|
|
131
152
|
/** `advice` says how to get a service no plugin declares; the host's is to register one that does. */
|
|
132
153
|
constructor(plugins: readonly RoundtablePlugin[], advice = HOST_ADVICE) {
|
|
133
154
|
this.#advice = advice;
|
|
155
|
+
this.#plugins = plugins;
|
|
134
156
|
for (const plugin of plugins)
|
|
135
157
|
for (const key of declaredBy(plugin)) {
|
|
136
158
|
const other = this.#declaredBy.get(key.id);
|
|
@@ -147,6 +169,45 @@ export class ServiceRegistry {
|
|
|
147
169
|
this.#values.set(key.id, value);
|
|
148
170
|
}
|
|
149
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Refuses a plugin whose `requires` names a service no registered plugin or test provides, or
|
|
174
|
+
* one a plugin registered after it provides. It runs before any migration or setup, so a wrong
|
|
175
|
+
* order fails at the start with both plugins named.
|
|
176
|
+
*/
|
|
177
|
+
checkRequires(): void {
|
|
178
|
+
this.#plugins.forEach((plugin, index) => {
|
|
179
|
+
for (const key of requiredBy(plugin)) {
|
|
180
|
+
if (this.#values.has(key.id)) continue;
|
|
181
|
+
const provider = this.#declaredBy.get(key.id);
|
|
182
|
+
if (!provider)
|
|
183
|
+
throw new PluginError(
|
|
184
|
+
`plugin ${plugin.name}: requires service ${key.id}, which no registered plugin provides. ${key.absent ?? this.#advice}`,
|
|
185
|
+
);
|
|
186
|
+
if (provider === plugin)
|
|
187
|
+
throw new PluginError(
|
|
188
|
+
`plugin ${plugin.name}: requires service ${key.id}, which it provides itself. A plugin cannot require what it provides.`,
|
|
189
|
+
);
|
|
190
|
+
if (this.#plugins.indexOf(provider) > index)
|
|
191
|
+
throw new PluginError(
|
|
192
|
+
`plugin ${plugin.name}: requires service ${key.id}, which plugin ${provider.name} provides after it. Register plugin ${provider.name} before plugin ${plugin.name}, or read the service with services.lazy(KEY) from a callback that runs after startup.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Called once every plugin is set up: `lazy` readers start answering, and a key a plugin asked
|
|
200
|
+
* for that nobody provides is refused with the plugin named.
|
|
201
|
+
*/
|
|
202
|
+
settle(): void {
|
|
203
|
+
for (const { plugin, key } of this.#lazy)
|
|
204
|
+
if (!this.#values.has(key.id))
|
|
205
|
+
throw new PluginError(
|
|
206
|
+
`plugin ${plugin.name}: services.lazy reads service ${key.id}, which no registered plugin provides. ${key.absent ?? this.#advice}`,
|
|
207
|
+
);
|
|
208
|
+
this.#settled = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
150
211
|
/** The service, for the host's own reads; throws like a plugin's `get`. */
|
|
151
212
|
get<T>(key: ServiceKey<T>): T {
|
|
152
213
|
return this.#read(key, undefined, true) as T;
|
|
@@ -185,6 +246,17 @@ export class ServiceRegistry {
|
|
|
185
246
|
this.#readers.add(plugin);
|
|
186
247
|
return this.#read(key, plugin, false);
|
|
187
248
|
},
|
|
249
|
+
lazy: <T>(key: ServiceKey<T>) => {
|
|
250
|
+
this.#readers.add(plugin);
|
|
251
|
+
this.#lazy.push({ plugin, key });
|
|
252
|
+
return () => {
|
|
253
|
+
if (!this.#settled)
|
|
254
|
+
throw new NotLinkedError(
|
|
255
|
+
`service ${key.id} is read through lazy() once every plugin is set up. Call it from a service's start or from a handler, not during setup.`,
|
|
256
|
+
);
|
|
257
|
+
return this.#values.get(key.id) as T;
|
|
258
|
+
};
|
|
259
|
+
},
|
|
188
260
|
provide: (key, value) => {
|
|
189
261
|
if (this.#setting !== plugin)
|
|
190
262
|
throw new PluginError(
|
|
@@ -77,7 +77,7 @@ export class SessionFactory {
|
|
|
77
77
|
const linked = this.#options.sessions();
|
|
78
78
|
this.#linked = {
|
|
79
79
|
...linked,
|
|
80
|
-
extensionPaths: linked.piPackages.map(packageDir),
|
|
80
|
+
extensionPaths: linked.piPackages.map((name) => packageDir(name)),
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
return this.#linked;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The installed package's folder, as Pi's `additionalExtensionPaths` takes it. The package is
|
|
6
|
+
* looked up from `from`, which is `import.meta.url` of the module that asks, so a host finds its
|
|
7
|
+
* own dependencies even when `pi-roundtable` is linked or installed apart from them; without it
|
|
8
|
+
* the lookup starts at the core's own files.
|
|
9
|
+
*/
|
|
10
|
+
export function packageDir(name: string, from: string | URL = import.meta.url) {
|
|
11
|
+
return dirname(createRequire(from).resolve(`${name}/package.json`));
|
|
9
12
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import type { Server } from "bun";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
|
|
6
|
+
* turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
|
|
7
|
+
* types reject the option there, hence the cast. A stale socket file is removed first.
|
|
8
|
+
* `error` answers a failure raised outside the fetch handler; without it Bun's own page is sent.
|
|
9
|
+
*/
|
|
10
|
+
export function serveUnix(
|
|
11
|
+
socketPath: string,
|
|
12
|
+
fetch: (request: Request) => Response | Promise<Response>,
|
|
13
|
+
options: { error?: (error: Error) => Response | Promise<Response> } = {},
|
|
14
|
+
): Server<undefined> {
|
|
15
|
+
rmSync(socketPath, { force: true });
|
|
16
|
+
const serveOptions = {
|
|
17
|
+
unix: socketPath,
|
|
18
|
+
idleTimeout: 0,
|
|
19
|
+
fetch,
|
|
20
|
+
...(options.error ? { error: options.error } : {}),
|
|
21
|
+
};
|
|
22
|
+
// SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
|
|
23
|
+
return Bun.serve(
|
|
24
|
+
serveOptions as unknown as Parameters<typeof Bun.serve>[0],
|
|
25
|
+
) as Server<undefined>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Members a probe may ask of any object: a test's stand-in is not refused for them. */
|
|
2
|
+
const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A stand-in for a port the code under test takes, made of only the members the test gives.
|
|
6
|
+
* Reading any other member throws an error that names it, so a test learns at once which member
|
|
7
|
+
* the code reaches for, instead of meeting `undefined is not a function` or a hidden cast.
|
|
8
|
+
* `partial<AgentTeam>({ announce })` is an `AgentTeam` to the type checker.
|
|
9
|
+
*/
|
|
10
|
+
export function partial<T extends object>(given: Partial<T>): T {
|
|
11
|
+
// SAFETY: the stand-in answers only the members given; any other read throws before it is used.
|
|
12
|
+
return new Proxy(given, {
|
|
13
|
+
get(target, member, receiver) {
|
|
14
|
+
if (member in target || typeof member === "symbol" || PROBED.has(member))
|
|
15
|
+
return Reflect.get(target, member, receiver);
|
|
16
|
+
throw new Error(
|
|
17
|
+
`partial() was given no "${member}". Give it where the stand-in is made: partial({ ${member}: ... }).`,
|
|
18
|
+
);
|
|
19
|
+
},
|
|
20
|
+
}) as T;
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Logger } from "../log.ts";
|
|
2
|
+
|
|
3
|
+
/** One line a recording logger kept. */
|
|
4
|
+
export interface RecordedLog {
|
|
5
|
+
level: "debug" | "info" | "warn" | "error" | "fatal";
|
|
6
|
+
/** The fields of the call, with those of every `child` the logger came from. */
|
|
7
|
+
fields: Record<string, unknown>;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A logger that keeps what it is asked to write, and the lines it kept. */
|
|
12
|
+
export interface RecordingLogger {
|
|
13
|
+
readonly logger: Logger;
|
|
14
|
+
/** Every line written through the logger or a child of it, in order. */
|
|
15
|
+
readonly lines: RecordedLog[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A logger for a test that checks what the code logs: it writes nowhere, and `lines` holds each
|
|
20
|
+
* call with its level, fields (a child's included), and message.
|
|
21
|
+
*/
|
|
22
|
+
export function recordingLogger(): RecordingLogger {
|
|
23
|
+
const lines: RecordedLog[] = [];
|
|
24
|
+
const make = (bound: Record<string, unknown>): Logger => {
|
|
25
|
+
const write =
|
|
26
|
+
(level: RecordedLog["level"]) =>
|
|
27
|
+
(first: object | string, message?: string): void => {
|
|
28
|
+
lines.push(
|
|
29
|
+
typeof first === "string"
|
|
30
|
+
? { level, fields: { ...bound }, message: first }
|
|
31
|
+
: { level, fields: { ...bound, ...first }, message: message ?? "" },
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
debug: write("debug"),
|
|
36
|
+
info: write("info"),
|
|
37
|
+
warn: write("warn"),
|
|
38
|
+
error: write("error"),
|
|
39
|
+
fatal: write("fatal"),
|
|
40
|
+
child: (fields) => make({ ...bound, ...fields }),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
return { logger: make({}), lines };
|
|
44
|
+
}
|
|
@@ -38,6 +38,11 @@ export interface TestHostOptions {
|
|
|
38
38
|
plugins?: readonly RoundtablePlugin[];
|
|
39
39
|
/** The runtime every turn runs on; by default one that answers "" and builds no Pi session. */
|
|
40
40
|
runtime?: AgentRuntime;
|
|
41
|
+
/**
|
|
42
|
+
* The credentials `context.apiKey` returns, by provider name; a provider not listed reads as
|
|
43
|
+
* having none, whatever login the machine holds.
|
|
44
|
+
*/
|
|
45
|
+
apiKeys?: Readonly<Record<string, string>>;
|
|
41
46
|
/** What the stand-in Discord hands out. */
|
|
42
47
|
discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
|
|
43
48
|
}
|
|
@@ -210,7 +215,15 @@ export async function testHost(
|
|
|
210
215
|
],
|
|
211
216
|
};
|
|
212
217
|
const defined = await defineRoundtable(config, { logger: silentLogger() });
|
|
213
|
-
const
|
|
218
|
+
const apiKeys = options.apiKeys ?? {};
|
|
219
|
+
const roundtable = new Roundtable(
|
|
220
|
+
{
|
|
221
|
+
...defined.options,
|
|
222
|
+
apiKey: async (provider) =>
|
|
223
|
+
Object.hasOwn(apiKeys, provider) ? apiKeys[provider] : undefined,
|
|
224
|
+
},
|
|
225
|
+
defined.plugins,
|
|
226
|
+
);
|
|
214
227
|
await roundtable.run();
|
|
215
228
|
if (!captured) throw new Error("the probe was not set up");
|
|
216
229
|
const context = captured;
|
package/src/discord/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The Discord entry: everything that names a discord.js type, apart from the main and kit entries.
|
|
2
|
-
//
|
|
2
|
+
// Versioned like the main entry: a breaking change comes in a minor release before 1.0 and is listed in the changelog.
|
|
3
3
|
|
|
4
4
|
export type { DiscordServices } from "../core/builtin/discord.ts";
|
|
5
5
|
export { DISCORD } from "../core/builtin/discord.ts";
|
package/src/kit/channels.ts
CHANGED
package/src/kit/domain.ts
CHANGED
package/src/kit/holds.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
2
|
// The chain a host links from every plugin's hold rules, to test a rule set as the host runs it.
|
|
3
3
|
|
|
4
4
|
export { holdChain } from "../core/holds.ts";
|
package/src/kit/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Helpers for owner commands, claims, and naming existing core parts; versioned like the main entry.
|
|
2
2
|
|
|
3
3
|
export type { ChannelQueue } from "./channels.ts";
|
|
4
4
|
export {
|
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
thinkingLine,
|
|
63
63
|
zonedStamp,
|
|
64
64
|
} from "./presentation.ts";
|
|
65
|
+
export { packageDir, serveUnix } from "./process.ts";
|
|
65
66
|
export {
|
|
66
67
|
SHELL_TOOLS,
|
|
67
68
|
shellHoldRule,
|
package/src/kit/judging.ts
CHANGED
package/src/kit/memory.ts
CHANGED
package/src/kit/mirror.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
2
|
// Mirror a built-in tool in a worker that cannot reach the host: the specs and the dispatcher.
|
|
3
3
|
|
|
4
4
|
export type { ScheduleToolContext } from "../core/modules/schedules/schedule-tools.ts";
|
package/src/kit/presentation.ts
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
|
+
// Running a worker process of your own: where an installed package lives, and a unix-socket HTTP server.
|
|
3
|
+
|
|
4
|
+
export { packageDir } from "../core/shared/package-dir.ts";
|
|
5
|
+
export { serveUnix } from "../core/shared/unix-server.ts";
|
package/src/kit/shell.ts
CHANGED
package/src/kit/skills.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Plugin helpers, versioned like the main entry; see the plugin guide.
|
|
2
2
|
// Skills and repositories: the names a repository shelf accepts, and the tool that lists skills.
|
|
3
3
|
|
|
4
4
|
export { checkRepoName } from "../core/modules/skills/repo-name.ts";
|
package/src/kit/support.ts
CHANGED
package/src/kit/threads.ts
CHANGED
package/src/kit/tools.ts
CHANGED
package/src/kit/worker.ts
CHANGED
package/src/testing.ts
CHANGED
|
@@ -63,7 +63,6 @@ import { type ToolTierTable, toolTiers } from "./core/tool-tiers.ts";
|
|
|
63
63
|
// Fixtures for plugin tests.
|
|
64
64
|
|
|
65
65
|
export { silentLogger } from "./core/log.ts";
|
|
66
|
-
|
|
67
66
|
export type { TestStore } from "./core/testing/database.ts";
|
|
68
67
|
export {
|
|
69
68
|
describeDb,
|
|
@@ -74,8 +73,13 @@ export {
|
|
|
74
73
|
export { eagerText, useEagerCatalog } from "./core/testing/eager-catalog.ts";
|
|
75
74
|
export type { TestLocale } from "./core/testing/locale.ts";
|
|
76
75
|
export { useTestLocale } from "./core/testing/locale.ts";
|
|
77
|
-
|
|
78
76
|
export { OWNER_SPEAKER } from "./core/testing/owner.ts";
|
|
77
|
+
export { partial } from "./core/testing/partial.ts";
|
|
78
|
+
export type {
|
|
79
|
+
RecordedLog,
|
|
80
|
+
RecordingLogger,
|
|
81
|
+
} from "./core/testing/recording-logger.ts";
|
|
82
|
+
export { recordingLogger } from "./core/testing/recording-logger.ts";
|
|
79
83
|
export type { TestHost, TestHostOptions } from "./core/testing/test-host.ts";
|
|
80
84
|
export { testHost } from "./core/testing/test-host.ts";
|
|
81
85
|
export type { FakeThreadHost } from "./core/testing/thread-host.ts";
|
|
@@ -170,6 +174,11 @@ export interface TestPluginOptions {
|
|
|
170
174
|
conversations?: Partial<ConversationPort>;
|
|
171
175
|
/** Replaces `context.turns`, which by default runs turns over the runtime and the surfaces. */
|
|
172
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>>;
|
|
173
182
|
/** How long the router holds a bare forward for the message that follows it; the host option `conversations.forwardJoinMs`. */
|
|
174
183
|
forwardJoinMs?: number;
|
|
175
184
|
}
|
|
@@ -393,6 +402,7 @@ export async function testPlugin(
|
|
|
393
402
|
logger,
|
|
394
403
|
}),
|
|
395
404
|
);
|
|
405
|
+
services.checkRequires();
|
|
396
406
|
const context: Omit<PluginContext, "services"> = {
|
|
397
407
|
logger,
|
|
398
408
|
env,
|
|
@@ -409,6 +419,10 @@ export async function testPlugin(
|
|
|
409
419
|
},
|
|
410
420
|
providers,
|
|
411
421
|
dashboard: () => (linked ? registry.dashboard : unlinked("dashboard")),
|
|
422
|
+
apiKey: async (provider) =>
|
|
423
|
+
Object.hasOwn(options.apiKeys ?? {}, provider)
|
|
424
|
+
? options.apiKeys?.[provider]
|
|
425
|
+
: undefined,
|
|
412
426
|
};
|
|
413
427
|
const registry = await collectContributions(
|
|
414
428
|
[plugin],
|
package/templates/agents.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AgentSeed } from "pi-roundtable";
|
|
2
2
|
|
|
3
|
+
// Ids come from .env, which Bun loads on its own.
|
|
4
|
+
const env = (name: string): string => process.env[name] ?? "";
|
|
5
|
+
|
|
3
6
|
// The first team. An agent that is already stored is never overwritten, so edit agents in Discord afterwards.
|
|
4
7
|
export const agents: AgentSeed[] = [
|
|
5
8
|
{
|
|
@@ -8,5 +11,7 @@ export const agents: AgentSeed[] = [
|
|
|
8
11
|
prompt:
|
|
9
12
|
"You are Guide, a friendly assistant. Answer briefly and ask when a request is unclear.",
|
|
10
13
|
avatarPrompt: "A friendly lighthouse keeper with a warm lantern",
|
|
14
|
+
// The agent in the entry channel is the coordinator: it answers there and can create the others.
|
|
15
|
+
channelId: env("DISCORD_ENTRY_CHANNEL_ID"),
|
|
11
16
|
},
|
|
12
17
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { PluginError } from "pi-roundtable";
|
|
3
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import {
|
|
5
|
+
type CodexFetch,
|
|
6
|
+
codexImages,
|
|
7
|
+
createCodexImages,
|
|
8
|
+
ImageNotGeneratedError,
|
|
9
|
+
} from "./codex-images.ts";
|
|
10
|
+
|
|
11
|
+
/** A login token shaped like the real one: a JWT whose claims name the ChatGPT account. */
|
|
12
|
+
function token(account: string): string {
|
|
13
|
+
const claims = {
|
|
14
|
+
"https://api.openai.com/auth": { chatgpt_account_id: account },
|
|
15
|
+
};
|
|
16
|
+
return `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
|
20
|
+
|
|
21
|
+
/** A server-sent event stream of the given events. */
|
|
22
|
+
function stream(...events: unknown[]): Response {
|
|
23
|
+
const text = events
|
|
24
|
+
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
|
25
|
+
.join("");
|
|
26
|
+
return new Response(text, {
|
|
27
|
+
headers: { "content-type": "text/event-stream" },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const drawn = (result: string, status = "completed") => ({
|
|
32
|
+
type: "response.output_item.done",
|
|
33
|
+
item: { type: "image_generation_call", status, result },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const completed = { type: "response.completed", response: { output: [] } };
|
|
37
|
+
|
|
38
|
+
/** The plugin over a fake network that answers every request with `answer`, recording the requests. */
|
|
39
|
+
function over(answer: () => Response) {
|
|
40
|
+
const requests: { url: string; init: RequestInit }[] = [];
|
|
41
|
+
const fetch: CodexFetch = async (url, init) => {
|
|
42
|
+
requests.push({ url, init });
|
|
43
|
+
return answer();
|
|
44
|
+
};
|
|
45
|
+
return { plugin: createCodexImages({ fetch }), requests };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("codex-images fills the images slot with the image Codex returns", async () => {
|
|
49
|
+
const secret = token("acct-1");
|
|
50
|
+
const { plugin, requests } = over(() =>
|
|
51
|
+
stream(drawn(Buffer.from(PNG).toString("base64")), completed),
|
|
52
|
+
);
|
|
53
|
+
const harness = await testPlugin(plugin, {
|
|
54
|
+
apiKeys: { "openai-codex": secret },
|
|
55
|
+
});
|
|
56
|
+
const image = await plugin.providers?.images?.("a fox", [
|
|
57
|
+
{ data: "AAAA", mimeType: "image/png" },
|
|
58
|
+
]);
|
|
59
|
+
expect([...(image ?? [])]).toEqual([...PNG]);
|
|
60
|
+
const [request] = requests;
|
|
61
|
+
expect(request?.url).toBe("https://chatgpt.com/backend-api/codex/responses");
|
|
62
|
+
const headers = request?.init.headers as Record<string, string>;
|
|
63
|
+
expect(headers.authorization).toBe(`Bearer ${secret}`);
|
|
64
|
+
expect(headers["chatgpt-account-id"]).toBe("acct-1");
|
|
65
|
+
const body = JSON.parse(String(request?.init.body));
|
|
66
|
+
expect(body.tools).toEqual([
|
|
67
|
+
{ type: "image_generation", output_format: "png" },
|
|
68
|
+
]);
|
|
69
|
+
expect(body.input[0].content).toEqual([
|
|
70
|
+
{ type: "input_text", text: "a fox" },
|
|
71
|
+
{ type: "input_image", image_url: "data:image/png;base64,AAAA" },
|
|
72
|
+
]);
|
|
73
|
+
await harness.stop();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("codex-images says so when Codex runs the tool and returns no image", async () => {
|
|
77
|
+
const refused = over(() => stream(drawn("", "failed"), completed));
|
|
78
|
+
const harness = await testPlugin(refused.plugin, {
|
|
79
|
+
apiKeys: { "openai-codex": token("acct-1") },
|
|
80
|
+
});
|
|
81
|
+
await expect(refused.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
|
|
82
|
+
ImageNotGeneratedError,
|
|
83
|
+
);
|
|
84
|
+
await harness.stop();
|
|
85
|
+
const silent = over(() => stream(completed));
|
|
86
|
+
const quiet = await testPlugin(silent.plugin, {
|
|
87
|
+
apiKeys: { "openai-codex": token("acct-1") },
|
|
88
|
+
});
|
|
89
|
+
await expect(silent.plugin.providers?.images?.("a fox", [])).rejects.toThrow(
|
|
90
|
+
"Codex answered without an image",
|
|
91
|
+
);
|
|
92
|
+
await quiet.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("codex-images reports an HTTP error with its status, and never the token", async () => {
|
|
96
|
+
const secret = token("acct-1");
|
|
97
|
+
const { plugin } = over(() => new Response("rate limited", { status: 429 }));
|
|
98
|
+
const harness = await testPlugin(plugin, {
|
|
99
|
+
apiKeys: { "openai-codex": secret },
|
|
100
|
+
});
|
|
101
|
+
const failure = await plugin.providers?.images?.("a fox", []).then(
|
|
102
|
+
() => undefined,
|
|
103
|
+
(error: Error) => error,
|
|
104
|
+
);
|
|
105
|
+
expect(failure?.message).toBe("Codex answered 429: rate limited");
|
|
106
|
+
expect(failure?.message).not.toContain(secret);
|
|
107
|
+
await harness.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("codex-images refuses to start without an openai-codex login", async () => {
|
|
111
|
+
const failure = await testPlugin(codexImages).then(
|
|
112
|
+
() => undefined,
|
|
113
|
+
(error: unknown) => error,
|
|
114
|
+
);
|
|
115
|
+
expect(failure).toBeInstanceOf(PluginError);
|
|
116
|
+
expect((failure as PluginError).message).toContain("openai-codex");
|
|
117
|
+
expect((failure as PluginError).message).toContain("Log in");
|
|
118
|
+
});
|