pi-roundtable 0.15.4 → 0.17.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +5 -0
  3. package/docs/plugins.md +68 -0
  4. package/package.json +1 -1
  5. package/src/cli/checks/configuration.ts +21 -1
  6. package/src/cli/project.ts +59 -8
  7. package/src/cli/runtime.ts +3 -0
  8. package/src/core/agents/agent-store.ts +4 -1
  9. package/src/core/builtin/discord.ts +5 -0
  10. package/src/core/config/adapters.ts +12 -0
  11. package/src/core/contract/runtime.ts +7 -0
  12. package/src/core/contract/surface.ts +7 -0
  13. package/src/core/db/check-database.ts +39 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/discord-surface.ts +5 -0
  16. package/src/core/discord/formatting.ts +20 -0
  17. package/src/core/host-registration.ts +292 -0
  18. package/src/core/host.ts +45 -227
  19. package/src/core/identity/config-owner.ts +34 -0
  20. package/src/core/identity/identity-plugin.ts +7 -2
  21. package/src/core/identity/identity-service.ts +8 -1
  22. package/src/core/identity/plugin-identities.ts +19 -4
  23. package/src/core/modules/skills/skill-store.ts +3 -1
  24. package/src/core/runtime/extensions/surface-formatting.ts +22 -0
  25. package/src/core/runtime/pi-agent-runtime.ts +6 -8
  26. package/src/core/runtime/running-turn.ts +19 -0
  27. package/src/core/runtime/runtime-plugin.ts +4 -0
  28. package/src/core/runtime/runtime-types.ts +12 -1
  29. package/src/core/runtime/session-factory.ts +7 -0
  30. package/src/core/testing/database.ts +20 -2
  31. package/src/core/testing/postgres.ts +160 -0
  32. package/src/core/testing/run-tool.ts +110 -0
  33. package/src/core/testing/test-host.ts +78 -30
  34. package/src/index.ts +1 -0
  35. package/src/testing.ts +25 -56
  36. package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
  37. package/templates/official/codex-images/plugin.ts +19 -2
@@ -1,3 +1,4 @@
1
+ import { isCheckDatabase } from "../db/check-database.ts";
1
2
  import type { RoundtablePlugin } from "../plugin.ts";
2
3
  import { IDENTITY, IDENTITY_LINKS } from "../services.ts";
3
4
  import type { AccessRules } from "./access-policy.ts";
@@ -45,12 +46,16 @@ export function identityPlugin(options: IdentityOptions): RoundtablePlugin {
45
46
  provides: rules.selfLink ? [IDENTITY, IDENTITY_LINKS] : [IDENTITY],
46
47
  setup: async ({ database, services, logger }) => {
47
48
  if (summary) logger.info(backfillLine(summary));
49
+ const sql = database();
48
50
  const identity = new PgIdentityService(
49
- await PgPrincipalStore.attach(database()),
51
+ await PgPrincipalStore.attach(sql),
50
52
  rules,
51
53
  { logger, identities },
52
54
  );
53
- await identity.syncConfig();
55
+ // `Roundtable.check()` has no database to write the configured principals to: it asks
56
+ // only what the configuration and the declared identities settle without one.
57
+ if (isCheckDatabase(sql)) identity.checkConfig();
58
+ else await identity.syncConfig();
54
59
  services.provide(IDENTITY, identityView(identity));
55
60
  if (rules.selfLink)
56
61
  services.provide(
@@ -15,10 +15,11 @@ import {
15
15
  surfaceOf,
16
16
  } from "./actor-facts.ts";
17
17
  import { CachingPrincipalStore } from "./caching-principal-store.ts";
18
- import { configuredOwnerId } from "./config-owner.ts";
18
+ import { configuredOwnerId, refuseOwnerDeclarations } from "./config-owner.ts";
19
19
  import type { Contact, ContactAssessor } from "./contact.ts";
20
20
  import {
21
21
  type DeclaredIdentity,
22
+ refuseUnboundIdentities,
22
23
  syncDeclaredIdentities,
23
24
  } from "./plugin-identities.ts";
24
25
  import { highest, lowest, publicOf, speakerOf } from "./principal-reads.ts";
@@ -141,6 +142,12 @@ export class PgIdentityService implements IdentityService, ContactAssessor {
141
142
  this.#logger = options.logger;
142
143
  }
143
144
 
145
+ /** The refusals `syncConfig` makes from the configuration and the declarations alone, for `Roundtable.check()`. */
146
+ checkConfig(): void {
147
+ refuseUnboundIdentities(this.#declared, this.#rules.owners.length > 0);
148
+ refuseOwnerDeclarations(this.#rules.owners, this.#declared);
149
+ }
150
+
144
151
  /**
145
152
  * Makes the stored owners match the configuration: each configured owner's principal made or
146
153
  * renamed, their identities linked, and the owner role granted, all as the configuration's;
@@ -57,6 +57,21 @@ export function declaredIdentities(
57
57
  return declared;
58
58
  }
59
59
 
60
+ /**
61
+ * The refusal `syncDeclaredIdentities` makes that needs no stored principal: an identity that names
62
+ * no principal when the configuration lists no primary owner to bind it to.
63
+ */
64
+ export function refuseUnboundIdentities(
65
+ identities: readonly DeclaredIdentity[],
66
+ hasPrimaryOwner: boolean,
67
+ ): void {
68
+ for (const { plugin, identity, principal } of identities)
69
+ if (principal === undefined && !hasPrimaryOwner)
70
+ throw new ConfigError(
71
+ `plugin ${plugin}: ${identity} names no principal, and access.owners lists no primary owner to bind it to. Give the plugin the principal it stands for.`,
72
+ );
73
+ }
74
+
60
75
  /**
61
76
  * Links each identity a plugin declares to its principal, `primaryOwner` by default, as the
62
77
  * plugin's: one the plugins bound to another principal at an earlier boot moves, one the
@@ -76,10 +91,10 @@ export async function syncDeclaredIdentities(
76
91
  const ref = parseIdentity(identity);
77
92
  if (!ref) throw new ConfigError(`plugin ${plugin}: ${identity}`);
78
93
  const id = principal ?? primaryOwner;
79
- if (id === undefined)
80
- throw new ConfigError(
81
- `plugin ${plugin}: ${identity} names no principal, and access.owners lists no primary owner to bind it to. Give the plugin the principal it stands for.`,
82
- );
94
+ if (id === undefined) {
95
+ refuseUnboundIdentities([{ plugin, identity, principal }], false);
96
+ continue;
97
+ }
83
98
  if (id === SYSTEM_PRINCIPAL || !(await store.get(id)))
84
99
  throw new ConfigError(
85
100
  `plugin ${plugin}: ${identity} is bound to principal ${id}, and there is no principal ${id}. roundtable principal list shows the principals, and roundtable principal create makes one.`,
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import { isCheckDatabase } from "../../db/check-database.ts";
2
3
  import { scopeToGuild } from "../../db/guild-scope.ts";
3
4
  import type { Migration } from "../../db/migrations.ts";
4
5
  import { AgentError } from "../../domain/errors.ts";
@@ -128,7 +129,8 @@ export class SkillStore {
128
129
  /** The store over the host's migrated pool; the skills agents carry are those of one guild. */
129
130
  static async attach(sql: SQL, guildId: string): Promise<SkillStore> {
130
131
  const store = new SkillStore(sql, guildId);
131
- await store.#reload();
132
+ // `Roundtable.check()` has no database to read the rows from: the store starts empty.
133
+ if (!isCheckDatabase(sql)) await store.#reload();
132
134
  return store;
133
135
  }
134
136
 
@@ -0,0 +1,22 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Appends the formatting guidance of the surface the running turn replies on to the system
5
+ * prompt's appended part before each run, after the persona and what earlier handlers appended.
6
+ * Nothing is added when `guidance` returns undefined or only blank text: a turn whose channel has
7
+ * no surface, a surface without guidance, or no turn running.
8
+ */
9
+ export function surfaceFormattingExtension(
10
+ guidance: () => string | undefined,
11
+ ): ExtensionFactory {
12
+ return (pi) => {
13
+ pi.on("before_agent_start", (event) => {
14
+ const text = guidance();
15
+ if (text === undefined || text.trim() === "") return;
16
+ const appended = event.systemPromptOptions.appendSystemPrompt;
17
+ event.systemPromptOptions.appendSystemPrompt = appended
18
+ ? `${appended}\n\n${text}`
19
+ : text;
20
+ });
21
+ };
22
+ }
@@ -35,7 +35,7 @@ import { interimPoster } from "./interim-text.ts";
35
35
  import { preflightTools } from "./preflight-tools.ts";
36
36
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
37
37
  import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
38
- import type { RunningTurn } from "./running-turn.ts";
38
+ import { type RunningTurn, runningTurn } from "./running-turn.ts";
39
39
  import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
40
40
  import { archiveSessions } from "./session-archive.ts";
41
41
  import {
@@ -86,6 +86,10 @@ export class PiAgentRuntime implements AgentRuntime {
86
86
  speaker: (channel) => this.#turns.get(channel)?.speaker,
87
87
  origin: (channel) => this.#turns.get(channel)?.origin,
88
88
  client: (channel) => this.#turns.get(channel)?.client,
89
+ formatting: (channel) => {
90
+ const reply = this.#turns.get(channel)?.channel;
91
+ return reply === undefined ? undefined : options.formatting?.(reply);
92
+ },
89
93
  withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
90
94
  runTask: (scope, task) => this.#runTask(scope, task),
91
95
  });
@@ -195,13 +199,7 @@ export class PiAgentRuntime implements AgentRuntime {
195
199
  loadsMemory(registered),
196
200
  );
197
201
  this.#memoryTurns.set(key, markPrivateMemory);
198
- this.#turns.set(key, {
199
- tier,
200
- speaker: request.speaker,
201
- ...(request.origin === undefined ? {} : { origin: request.origin }),
202
- ...(request.client === undefined ? {} : { client: request.client }),
203
- withheld,
204
- });
202
+ this.#turns.set(key, runningTurn(request, tier, withheld));
205
203
  const missing = wanted.filter((name) => !registered.has(name));
206
204
  if (missing.length > 0) {
207
205
  logger.warn(
@@ -1,8 +1,11 @@
1
1
  import type { ClientPort } from "../domain/client.ts";
2
+ import type { TurnRequest } from "../domain/ports.ts";
2
3
  import type { Speaker, Tier } from "../speakers.ts";
3
4
 
4
5
  /** What the runtime keeps about a conversation's running turn, for its tools to read. */
5
6
  export interface RunningTurn {
7
+ /** The channel the turn replies in, as `TurnRequest.channel` says; its surface formats the reply. */
8
+ channel: TurnRequest["channel"];
6
9
  tier: Tier;
7
10
  speaker: Speaker;
8
11
  /** Where the turn's request came from, as `TurnRequest.origin` says. */
@@ -12,3 +15,19 @@ export interface RunningTurn {
12
15
  /** Whether the turn loads no memory. */
13
16
  withheld: boolean;
14
17
  }
18
+
19
+ /** The record of a turn that starts: what its tools read while it runs. */
20
+ export function runningTurn(
21
+ request: TurnRequest,
22
+ tier: Tier,
23
+ withheld: boolean,
24
+ ): RunningTurn {
25
+ return {
26
+ channel: request.channel,
27
+ tier,
28
+ speaker: request.speaker,
29
+ ...(request.origin === undefined ? {} : { origin: request.origin }),
30
+ ...(request.client === undefined ? {} : { client: request.client }),
31
+ withheld,
32
+ };
33
+ }
@@ -129,6 +129,8 @@ export function runtimePlugin(
129
129
  const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
130
130
  context.surfaces.prompts(channel, scope);
131
131
  const agents = () => options.agents?.current();
132
+ const formatting = (channel: ChannelKey) =>
133
+ context.surfaces.of(channel)?.formatting;
132
134
  const running: AgentRuntime = providers.filled.has("runtime")
133
135
  ? providers.runtime(
134
136
  runtimeDeps({
@@ -138,6 +140,7 @@ export function runtimePlugin(
138
140
  sessions: context.sessions,
139
141
  toolTiers: context.toolTiers,
140
142
  prompts,
143
+ formatting,
141
144
  agents,
142
145
  confirmations: heldActions,
143
146
  judge: providers.judge,
@@ -182,6 +185,7 @@ export function runtimePlugin(
182
185
  return agents();
183
186
  },
184
187
  prompts,
188
+ formatting,
185
189
  logger,
186
190
  ...(options.interimText
187
191
  ? { interimText: options.interimText }
@@ -79,6 +79,13 @@ export interface PiAgentRuntimeOptions {
79
79
  * held action waits for a message that approves it.
80
80
  */
81
81
  prompts?: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
82
+ /**
83
+ * The formatting guidance of the chat surface that serves a channel, such as
84
+ * `ChatSurface.formatting`; undefined for a channel with no surface or no guidance. Read before
85
+ * each run for the channel the turn replies in, and appended to the system prompt after the
86
+ * persona.
87
+ */
88
+ formatting?: (channel: ChannelKey) => string | undefined;
82
89
  /** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
83
90
  toolTiers?: ToolTiers;
84
91
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
@@ -124,13 +131,16 @@ export interface CoreExtensions {
124
131
  askUser: ExtensionFactory;
125
132
  selfCompactGuard: ExtensionFactory;
126
133
  privateMemory: ExtensionFactory;
134
+ /** The formatting guidance of the surface the turn replies on, appended after the persona. */
135
+ surfaceFormatting: ExtensionFactory;
127
136
  activeTools: ExtensionFactory;
128
137
  }
129
138
 
130
139
  /**
131
140
  * A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
132
141
  * and compact guard, the compactor, the MCP phase, the memory projection, so each request is
133
- * projected after every other extension changed it, and active-tools last, so its
142
+ * projected after every other extension changed it, the surface's formatting guidance, so it follows
143
+ * the persona and everything appended before it, and active-tools last, so its
134
144
  * before_agent_start handler runs after every other extension's.
135
145
  */
136
146
  export function sessionExtensions(
@@ -160,6 +170,7 @@ export function sessionExtensions(
160
170
  ...contributed(plan.compaction ? [plan.compaction] : []),
161
171
  ...contributed(plan.mcp),
162
172
  { name: "private-memory", factory: core.privateMemory },
173
+ { name: "surface-formatting", factory: core.surfaceFormatting },
163
174
  { name: "active-tools", factory: core.activeTools },
164
175
  ];
165
176
  }
@@ -58,6 +58,7 @@ import {
58
58
  COMPACT_TOOL,
59
59
  selfCompactGuardExtension,
60
60
  } from "./extensions/self-compact-guard.ts";
61
+ import { surfaceFormattingExtension } from "./extensions/surface-formatting.ts";
61
62
  import type { PromptSlot } from "./prompt-slot.ts";
62
63
  import type {
63
64
  ChannelSession,
@@ -85,6 +86,8 @@ export interface SessionFactoryDeps {
85
86
  origin(channel: ChannelKey): string | undefined;
86
87
  /** What the client that started the conversation's running turn can do for its tools. */
87
88
  client(channel: ChannelKey): ClientPort | undefined;
89
+ /** The formatting guidance of the surface the conversation's running turn replies on. */
90
+ formatting(channel: ChannelKey): string | undefined;
88
91
  /** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
89
92
  withholdsMemory(channel: ChannelKey): boolean;
90
93
  /**
@@ -416,6 +419,10 @@ export class SessionFactory {
416
419
  : memoryReader(conversation, deps.speaker(turnKey)),
417
420
  state.draws,
418
421
  ),
422
+ surfaceFormatting: surfaceFormattingExtension(() =>
423
+ // A worker writes a report for the turn, not a reply on its surface.
424
+ worker ? undefined : deps.formatting(turnKey),
425
+ ),
419
426
  activeTools: activeToolsExtension(() => state.tools),
420
427
  }),
421
428
  // An agent's prompt is set before each run by the agent-prompt extension.
@@ -9,9 +9,27 @@ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
9
9
  /** The guild the stores of the tests serve. */
10
10
  export const TEST_GUILD = "900000000000000001";
11
11
 
12
- export const testDatabaseUrl = url ?? "";
12
+ /** The test database's URL, or "" when the run has none; `usePostgresForTests` sets it for the rest of the run. */
13
+ export let testDatabaseUrl = url ?? "";
13
14
 
14
- export const describeDb = url ? describe : describe.skip;
15
+ /** `describe` when the run has a test database, `describe.skip` otherwise; `usePostgresForTests` switches it on. */
16
+ export let describeDb = url ? describe : describe.skip;
17
+
18
+ /** Makes `url` the run's test database, for the tests and suites that read the two exports above afterwards. */
19
+ export function adoptTestDatabase(url: string): void {
20
+ process.env.ROUNDTABLE_TEST_DATABASE_URL = url;
21
+ testDatabaseUrl = url;
22
+ describeDb = describe;
23
+ }
24
+
25
+ /** The test database's URL; throws, naming how to provide one, when the run has none. */
26
+ export function requireDatabase(): string {
27
+ if (!testDatabaseUrl)
28
+ throw new Error(
29
+ "this test needs PostgreSQL: set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database, or start one for the run with usePostgresForTests() in a test preload (it needs initdb and postgres on PATH or in PG_BIN)",
30
+ );
31
+ return testDatabaseUrl;
32
+ }
15
33
 
16
34
  /** A test pool with the migrations run, as the host runs them before any store attaches; the caller closes it. */
17
35
  export async function migratedPool(...migrations: Migration[]): Promise<SQL> {
@@ -0,0 +1,160 @@
1
+ import { afterAll } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { SQL } from "bun";
7
+ import { adoptTestDatabase } from "./database.ts";
8
+
9
+ export interface UsePostgresOptions {
10
+ /** Directories searched for initdb and postgres before PATH; default PG_BIN and the usual Homebrew and Debian paths. */
11
+ binDirs?: readonly string[];
12
+ /** Throw when initdb or postgres is not found; default false (the tests that need it skip or call requireDatabase). */
13
+ require?: boolean;
14
+ }
15
+
16
+ /** Where a PostgreSQL install usually keeps initdb and postgres, newest first. */
17
+ const USUAL_DIRS = [
18
+ ...[17, 16, 15, 14].map(
19
+ (major) => `/opt/homebrew/opt/postgresql@${major}/bin`,
20
+ ),
21
+ ...[17, 16, 15, 14].map((major) => `/usr/lib/postgresql/${major}/bin`),
22
+ "/usr/local/bin",
23
+ "/usr/bin",
24
+ ];
25
+
26
+ const READY_TIMEOUT_MS = 20_000;
27
+ const STOP_TIMEOUT_MS = 10_000;
28
+
29
+ function binary(name: string, dirs: readonly string[]): string | undefined {
30
+ for (const dir of dirs) {
31
+ const path = join(dir, name);
32
+ if (existsSync(path)) return path;
33
+ }
34
+ return Bun.which(name) ?? undefined;
35
+ }
36
+
37
+ /** A TCP port nothing listens on, found by binding port 0 and letting it go. */
38
+ async function freePort(): Promise<number> {
39
+ const probe = Bun.serve({
40
+ port: 0,
41
+ hostname: "127.0.0.1",
42
+ fetch: () => new Response(),
43
+ });
44
+ const port = probe.port ?? 0;
45
+ await probe.stop(true);
46
+ return port;
47
+ }
48
+
49
+ /** Waits until the server at `url` answers a query, or throws when it exited or the wait ran out. */
50
+ async function untilReady(url: string, server: Bun.Subprocess): Promise<void> {
51
+ const deadline = Date.now() + READY_TIMEOUT_MS;
52
+ for (;;) {
53
+ if (server.exitCode !== null)
54
+ throw new Error(
55
+ `the test PostgreSQL exited with code ${server.exitCode} before it was ready`,
56
+ );
57
+ const sql = new SQL(url, { max: 1 });
58
+ try {
59
+ await sql`SELECT 1`;
60
+ await sql.close();
61
+ return;
62
+ } catch {
63
+ await sql.close().catch(() => undefined);
64
+ if (Date.now() > deadline)
65
+ throw new Error(
66
+ `the test PostgreSQL did not become ready in ${READY_TIMEOUT_MS / 1000} seconds`,
67
+ );
68
+ await Bun.sleep(100);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * For a test preload: unless ROUNDTABLE_TEST_DATABASE_URL is set, starts a throwaway PostgreSQL on
75
+ * 127.0.0.1 (trust auth, fsync off, a temporary data directory), sets the variable and
76
+ * `testDatabaseUrl`, and stops and removes it after the run (bun's global afterAll, exit and signals).
77
+ */
78
+ export async function usePostgresForTests(
79
+ options: UsePostgresOptions = {},
80
+ ): Promise<void> {
81
+ const given = process.env.ROUNDTABLE_TEST_DATABASE_URL;
82
+ if (given) {
83
+ adoptTestDatabase(given);
84
+ return;
85
+ }
86
+ const dirs = options.binDirs ?? [process.env.PG_BIN ?? "", ...USUAL_DIRS];
87
+ const initdb = binary("initdb", dirs.filter(Boolean));
88
+ const postgres = binary("postgres", dirs.filter(Boolean));
89
+ if (!initdb || !postgres) {
90
+ if (options.require)
91
+ throw new Error(
92
+ "this run needs PostgreSQL: install it so initdb and postgres are on PATH, set PG_BIN to its bin directory, or set ROUNDTABLE_TEST_DATABASE_URL to a throwaway database",
93
+ );
94
+ return;
95
+ }
96
+ const root = mkdtempSync(join(tmpdir(), "roundtable-test-pg-"));
97
+ const data = join(root, "data");
98
+ const init = spawnSync(
99
+ initdb,
100
+ ["-D", data, "-U", "postgres", "--auth=trust", "-E", "UTF8", "--no-sync"],
101
+ { encoding: "utf8" },
102
+ );
103
+ if (init.status !== 0) {
104
+ rmSync(root, { recursive: true, force: true });
105
+ throw new Error(`initdb failed: ${init.stderr}`);
106
+ }
107
+ const port = await freePort();
108
+ const server = Bun.spawn(
109
+ [
110
+ postgres,
111
+ "-D",
112
+ data,
113
+ "-p",
114
+ String(port),
115
+ "-k",
116
+ root,
117
+ "-c",
118
+ "listen_addresses=127.0.0.1",
119
+ "-c",
120
+ "fsync=off",
121
+ ],
122
+ { stdout: "ignore", stderr: "ignore" },
123
+ );
124
+ let stopped = false;
125
+ const stop = async () => {
126
+ if (stopped) return;
127
+ stopped = true;
128
+ // SIGINT is PostgreSQL's fast shutdown: sessions end and the server exits at once.
129
+ server.kill("SIGINT");
130
+ await Promise.race([server.exited, Bun.sleep(STOP_TIMEOUT_MS)]);
131
+ rmSync(root, { recursive: true, force: true });
132
+ };
133
+ // A global hook of the run: bun runs preload hooks once, after every test file.
134
+ afterAll(stop);
135
+ process.on("exit", () => {
136
+ if (stopped) return;
137
+ server.kill("SIGINT");
138
+ // A process that exits without the run's hooks leaves no data directory behind. The server
139
+ // writes while it shuts down, so removing it takes a few tries.
140
+ for (let attempt = 0; attempt < 100 && existsSync(root); attempt++) {
141
+ try {
142
+ rmSync(root, { recursive: true, force: true });
143
+ } catch {
144
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
145
+ }
146
+ }
147
+ });
148
+ for (const signal of ["SIGINT", "SIGTERM"] as const)
149
+ process.on(signal, () => {
150
+ void stop().then(() => process.exit(1));
151
+ });
152
+ const url = `postgres://postgres@127.0.0.1:${port}/postgres`;
153
+ try {
154
+ await untilReady(url, server);
155
+ } catch (error) {
156
+ await stop();
157
+ throw error;
158
+ }
159
+ adoptTestDatabase(url);
160
+ }
@@ -0,0 +1,110 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionFactory,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ClientPart } from "../domain/client.ts";
6
+ import type { ReplyFile } from "../domain/conversation.ts";
7
+ import { PluginError } from "../errors.ts";
8
+ import {
9
+ attachReplyFile,
10
+ hasReplyFileScope,
11
+ withReplyFiles,
12
+ } from "../reply-files.ts";
13
+
14
+ /** What a session registers of a tool: the call the model's tool call reaches. */
15
+ export interface RegisteredTool {
16
+ name: string;
17
+ execute(
18
+ id: string,
19
+ params: Record<string, unknown>,
20
+ signal?: AbortSignal,
21
+ ): Promise<{
22
+ content: readonly {
23
+ type: string;
24
+ text?: string;
25
+ data?: string;
26
+ mimeType?: string;
27
+ }[];
28
+ isError?: boolean;
29
+ }>;
30
+ }
31
+
32
+ /** What the model would get back from one tool call, and what the call attached beside the text. */
33
+ export interface ToolCallResult {
34
+ text: string;
35
+ files: ReplyFile[];
36
+ images: ClientPart[];
37
+ }
38
+
39
+ /** Anything an extension may call or read on the Pi API besides registering a tool: accepted, does nothing. */
40
+ type Ignored = (...args: readonly never[]) => Ignored;
41
+
42
+ function ignored(): Ignored {
43
+ return new Proxy((() => ignored()) as Ignored, {
44
+ get: (target, key) => (key === "then" ? undefined : target),
45
+ });
46
+ }
47
+
48
+ /** The tools a factory registers, in order; every other registration (handlers, commands, events) is accepted and ignored. */
49
+ export async function registeredTools(
50
+ factory: ExtensionFactory | null | undefined,
51
+ ): Promise<RegisteredTool[]> {
52
+ const tools: RegisteredTool[] = [];
53
+ if (!factory) return tools;
54
+ // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tools and ignores the rest.
55
+ const api = new Proxy(
56
+ {},
57
+ {
58
+ get: (_target, key) =>
59
+ key === "registerTool"
60
+ ? (definition: RegisteredTool) => tools.push(definition)
61
+ : ignored(),
62
+ },
63
+ ) as unknown as ExtensionAPI;
64
+ await factory(api);
65
+ return tools;
66
+ }
67
+
68
+ /**
69
+ * Runs the call as a turn on a surface that takes files or not runs it: the text, the files it
70
+ * attached and the images it returned. A file is also queued for the enclosing real turn when a
71
+ * fake runtime runs the call inside one. A tool that throws throws here; a refusal (an `isError` result) comes back as its text.
72
+ */
73
+ export async function callRegisteredTool(
74
+ tool: RegisteredTool,
75
+ args: Record<string, unknown>,
76
+ supportsFiles: boolean,
77
+ ): Promise<ToolCallResult> {
78
+ const images: ClientPart[] = [];
79
+ const result = await withReplyFiles(supportsFiles, async () => {
80
+ const output = await tool.execute("test-call", args);
81
+ for (const item of output.content)
82
+ if (item.type === "image" && item.data && item.mimeType)
83
+ images.push({
84
+ mimeType: item.mimeType,
85
+ data: new Uint8Array(Buffer.from(item.data, "base64")),
86
+ });
87
+ return {
88
+ ok: true,
89
+ text: output.content
90
+ .filter((item) => item.type === "text")
91
+ .map((item) => item.text ?? "")
92
+ .join("\n"),
93
+ };
94
+ });
95
+ if (!result.ok) throw result.error;
96
+ const files = result.files ?? [];
97
+ // When a tool is run by a fake runtime, forward into the enclosing real turn.
98
+ if (hasReplyFileScope()) for (const file of files) attachReplyFile(file);
99
+ return { text: result.text, files, images };
100
+ }
101
+
102
+ /** The registered tool called `name`, or the PluginError a test reads. */
103
+ export function toolNamed(
104
+ tools: readonly RegisteredTool[],
105
+ name: string,
106
+ ): RegisteredTool {
107
+ const found = tools.find((tool) => tool.name === name);
108
+ if (!found) throw new PluginError(`tool ${name} is not registered`);
109
+ return found;
110
+ }