pi-roundtable 0.7.19 → 0.8.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 +38 -0
- package/README.md +44 -11
- package/README.zh-TW.md +44 -9
- package/docs/migrating-0.8.md +132 -0
- package/docs/plugins.md +267 -23
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +31 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/package.json +3 -2
- package/src/cli/cli.ts +15 -4
- package/src/cli/doctor.ts +21 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/project.ts +28 -2
- package/src/cli/templates.ts +46 -14
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +6 -1
- package/src/core/builtin/agent-server.ts +30 -95
- package/src/core/builtin/modules.ts +133 -34
- package/src/core/config/adapters.ts +57 -0
- package/src/core/config/config.ts +174 -77
- package/src/core/contract/channels.ts +4 -0
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +8 -4
- package/src/core/contract/surface.ts +13 -0
- package/src/core/conversations/conversation-registry.ts +42 -0
- package/src/core/conversations/conversation-store.ts +113 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +124 -54
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/owner-cards.ts +19 -8
- package/src/core/domain/conversation.ts +5 -0
- package/src/core/domain/owner-prompts.ts +1 -1
- package/src/core/domain/ports.ts +6 -0
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +6 -3
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +5 -7
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +8 -0
- package/src/core/routing/conversation-turns.ts +55 -1
- package/src/core/routing/surface-port.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +42 -4
- package/src/core/runtime/pi-agent-runtime.ts +13 -6
- package/src/core/runtime/prompt-slot.ts +2 -2
- package/src/core/runtime/runtime-plugin.ts +160 -0
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +15 -1
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/testing/modules.ts +47 -23
- package/src/core/testing/surface-contract.ts +383 -0
- package/src/core/testing/test-host.ts +27 -9
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -0
- package/src/testing.ts +41 -5
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +15 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +55 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +2 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
AGENTS,
|
|
4
|
+
defineRoundtable,
|
|
5
|
+
type PluginContext,
|
|
6
|
+
Roundtable,
|
|
7
|
+
RUNTIME,
|
|
8
|
+
} from "pi-roundtable";
|
|
9
|
+
import { DISCORD } from "pi-roundtable/discord";
|
|
10
|
+
import {
|
|
11
|
+
describeDb,
|
|
12
|
+
silentLogger,
|
|
13
|
+
testDatabaseUrl,
|
|
14
|
+
} from "pi-roundtable/testing";
|
|
15
|
+
import { echoRuntime } from "./echo-runtime.ts";
|
|
16
|
+
import { FakeSurface } from "./fake-surface.ts";
|
|
17
|
+
import { studyHall } from "./headless.ts";
|
|
18
|
+
|
|
19
|
+
let roundtable: Roundtable | undefined;
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await roundtable?.shutdown("test");
|
|
22
|
+
roundtable = undefined;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function until(done: () => boolean): Promise<void> {
|
|
26
|
+
for (let waited = 0; !done() && waited < 2000; waited += 5)
|
|
27
|
+
await Bun.sleep(5);
|
|
28
|
+
expect(done()).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describeDb("a host without Discord", () => {
|
|
32
|
+
test("boots without Discord or the agent server and answers a study room through its runtime", async () => {
|
|
33
|
+
const surface = new FakeSurface();
|
|
34
|
+
const config = studyHall(surface, {
|
|
35
|
+
databaseUrl: testDatabaseUrl,
|
|
36
|
+
dataDir: `${Bun.env.TMPDIR ?? "/tmp"}/study-hall-${crypto.randomUUID()}`,
|
|
37
|
+
});
|
|
38
|
+
let context: PluginContext | undefined;
|
|
39
|
+
const { options, plugins } = await defineRoundtable(
|
|
40
|
+
{
|
|
41
|
+
...config,
|
|
42
|
+
plugins: [
|
|
43
|
+
...(config.plugins ?? []),
|
|
44
|
+
// The echo runtime stands in for Pi, so the test calls no model.
|
|
45
|
+
echoRuntime,
|
|
46
|
+
{
|
|
47
|
+
name: "probe",
|
|
48
|
+
setup: (given) => {
|
|
49
|
+
context = given;
|
|
50
|
+
return { services: [{ name: "probe" }] };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{ logger: silentLogger() },
|
|
56
|
+
);
|
|
57
|
+
const names = plugins.map((plugin) => plugin.name);
|
|
58
|
+
expect(names).not.toContain("discord");
|
|
59
|
+
expect(names).not.toContain("agent-server");
|
|
60
|
+
expect(options.listeners).toEqual([]);
|
|
61
|
+
roundtable = new Roundtable(options, plugins);
|
|
62
|
+
await roundtable.run();
|
|
63
|
+
expect(context?.services.find(DISCORD)).toBeUndefined();
|
|
64
|
+
expect(context?.services.find(AGENTS)).toBeUndefined();
|
|
65
|
+
expect(context?.services.get(RUNTIME)).toBeDefined();
|
|
66
|
+
surface.say("fake:study-algebra", "What is a group?");
|
|
67
|
+
await until(() => surface.replies.length > 0);
|
|
68
|
+
expect(surface.replies[0]?.reply.chunks.join()).toContain(
|
|
69
|
+
"What is a group?",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { definePlugin, type RoundtableConfig } from "pi-roundtable";
|
|
2
|
+
import type { FakeSurface } from "./fake-surface.ts";
|
|
3
|
+
import { studyRoom } from "./study-room.ts";
|
|
4
|
+
|
|
5
|
+
/** A plugin that brings a chat network and nothing else; the study room's claim answers its rooms. */
|
|
6
|
+
export function chatNetwork(surface: FakeSurface) {
|
|
7
|
+
return definePlugin({
|
|
8
|
+
name: "chat-network",
|
|
9
|
+
setup: () => ({ surfaces: [surface] }),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A host without Discord: no `discord` key, so no Discord plugin, no agent server, no agents and
|
|
15
|
+
* no skills; and no `http`, so no listener opens until a plugin needs one. Conversations come
|
|
16
|
+
* through the plugins' own chat surfaces, and `context.turns` runs them on the host's runtime,
|
|
17
|
+
* Pi's unless a plugin fills the `runtime` slot.
|
|
18
|
+
*/
|
|
19
|
+
export function studyHall(
|
|
20
|
+
surface: FakeSurface,
|
|
21
|
+
where: { databaseUrl: string; dataDir: string },
|
|
22
|
+
): RoundtableConfig {
|
|
23
|
+
return {
|
|
24
|
+
name: "Study Hall",
|
|
25
|
+
owner: { id: "owner", name: "Ada" },
|
|
26
|
+
database: { url: where.databaseUrl },
|
|
27
|
+
dataDir: where.dataDir,
|
|
28
|
+
model: "anthropic/claude-sonnet-5-5",
|
|
29
|
+
plugins: [chatNetwork(surface), studyRoom],
|
|
30
|
+
};
|
|
31
|
+
}
|
package/examples/http.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
+
import type { RouteSocket } from "pi-roundtable";
|
|
2
3
|
import { testPlugin } from "pi-roundtable/testing";
|
|
3
|
-
import { health } from "./http.ts";
|
|
4
|
+
import { echo, health } from "./http.ts";
|
|
4
5
|
|
|
5
6
|
test("the route answers GET /healthz", async () => {
|
|
6
7
|
const harness = await testPlugin(health);
|
|
@@ -10,3 +11,32 @@ test("the route answers GET /healthz", async () => {
|
|
|
10
11
|
expect(await response?.text()).toBe("ok");
|
|
11
12
|
await harness.stop();
|
|
12
13
|
});
|
|
14
|
+
|
|
15
|
+
test("the echo route admits a ticket's holder once and echoes what it is sent", async () => {
|
|
16
|
+
const harness = await testPlugin(echo(new Set(["t-1"])));
|
|
17
|
+
const websocket = harness.contribution.http?.[0]?.websocket;
|
|
18
|
+
const refused = await websocket?.accept(
|
|
19
|
+
new Request("http://localhost/echo?ticket=wrong"),
|
|
20
|
+
);
|
|
21
|
+
expect(refused instanceof Response && refused.status).toBe(401);
|
|
22
|
+
const accepted = await websocket?.accept(
|
|
23
|
+
new Request("http://localhost/echo?ticket=t-1"),
|
|
24
|
+
);
|
|
25
|
+
expect(accepted).toMatchObject({ data: { since: expect.any(Number) } });
|
|
26
|
+
const replayed = await websocket?.accept(
|
|
27
|
+
new Request("http://localhost/echo?ticket=t-1"),
|
|
28
|
+
);
|
|
29
|
+
expect(replayed instanceof Response && replayed.status).toBe(401);
|
|
30
|
+
const sent: unknown[] = [];
|
|
31
|
+
const socket: RouteSocket = {
|
|
32
|
+
data: undefined,
|
|
33
|
+
send: (message) => {
|
|
34
|
+
sent.push(message);
|
|
35
|
+
return "sent";
|
|
36
|
+
},
|
|
37
|
+
close: () => undefined,
|
|
38
|
+
};
|
|
39
|
+
await websocket?.message(socket, "hi");
|
|
40
|
+
expect(sent).toEqual(["hi"]);
|
|
41
|
+
await harness.stop();
|
|
42
|
+
});
|
package/examples/http.ts
CHANGED
|
@@ -15,3 +15,42 @@ export const health = definePlugin({
|
|
|
15
15
|
],
|
|
16
16
|
}),
|
|
17
17
|
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A route with `websocket` also takes upgrades. The Origin check and `accept` run before any
|
|
21
|
+
* socket opens, so a browser on another site, or a client without a ticket, never connects.
|
|
22
|
+
* A ticket opens one socket: `accept` spends it, so one that leaks from a log or the browser's
|
|
23
|
+
* history opens nothing. Whoever issues the tickets should also let them expire within seconds.
|
|
24
|
+
*/
|
|
25
|
+
export const echo = (tickets: Set<string>) =>
|
|
26
|
+
definePlugin({
|
|
27
|
+
name: "echo",
|
|
28
|
+
setup: () => ({
|
|
29
|
+
http: [
|
|
30
|
+
{
|
|
31
|
+
name: "echo",
|
|
32
|
+
listener: "public",
|
|
33
|
+
path: { exact: "/echo" },
|
|
34
|
+
methods: ["GET"],
|
|
35
|
+
handle: () => new Response("Upgrade Required", { status: 426 }),
|
|
36
|
+
websocket: {
|
|
37
|
+
origins: ["https://chat.example.com"],
|
|
38
|
+
accept: (request) => {
|
|
39
|
+
const ticket = URL.parse(request.url)?.searchParams.get("ticket");
|
|
40
|
+
return ticket && tickets.delete(ticket)
|
|
41
|
+
? { data: { since: Date.now() } }
|
|
42
|
+
: new Response("Unauthorized", { status: 401 });
|
|
43
|
+
},
|
|
44
|
+
maxMessageBytes: 4096,
|
|
45
|
+
rate: { messages: 20, perMs: 10_000 },
|
|
46
|
+
maxBufferedBytes: 64 * 1024,
|
|
47
|
+
maxConnections: 50,
|
|
48
|
+
message: (socket, message) => {
|
|
49
|
+
// "dropped" means the client stopped reading and the host cut it; an echo has nothing to resend.
|
|
50
|
+
socket.send(message);
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A plugin-driven Pi agent server for Discord",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"workspaces": [
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"typescript": "7.0.2",
|
|
75
75
|
"@biomejs/biome": "2.5.15",
|
|
76
76
|
"@types/bun": "1.4.2",
|
|
77
|
+
"pi-self-compact": "0.2.0",
|
|
77
78
|
"pi-web-access": "0.35.0"
|
|
78
79
|
}
|
|
79
80
|
}
|
package/src/cli/cli.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { loadConfigFile, type Ports } from "./project.ts";
|
|
|
14
14
|
import { formatOutcomes } from "./report.ts";
|
|
15
15
|
import { assemble, providerLogin } from "./runtime.ts";
|
|
16
16
|
import { start } from "./start.ts";
|
|
17
|
-
import { OFFICIAL_PLUGINS } from "./templates.ts";
|
|
17
|
+
import { ADAPTERS, isAdapter, OFFICIAL_PLUGINS } from "./templates.ts";
|
|
18
18
|
|
|
19
19
|
/** What the command line reads from its surroundings; tests replace every part. */
|
|
20
20
|
export interface CliEnvironment {
|
|
@@ -32,7 +32,9 @@ export interface CliEnvironment {
|
|
|
32
32
|
|
|
33
33
|
const USAGE = `roundtable: a Discord agent server on Pi
|
|
34
34
|
|
|
35
|
-
roundtable init [dir]
|
|
35
|
+
roundtable init [dir] [--adapter discord|web]
|
|
36
|
+
create a project (default: the current directory) that talks
|
|
37
|
+
through Discord (the default) or through the web chat
|
|
36
38
|
roundtable doctor [--reachable] check the setup and say how to fix what is wrong
|
|
37
39
|
roundtable start run the checks that need no network, then the bot
|
|
38
40
|
roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
|
|
@@ -108,13 +110,22 @@ export async function runCli(
|
|
|
108
110
|
return 0;
|
|
109
111
|
}
|
|
110
112
|
if (command === "init") {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
+
const at = rest.indexOf("--adapter");
|
|
114
|
+
const adapter = at === -1 ? "discord" : rest[at + 1];
|
|
115
|
+
if (adapter === undefined || !isAdapter(adapter))
|
|
116
|
+
return usage(
|
|
117
|
+
io,
|
|
118
|
+
`--adapter takes one of ${ADAPTERS.join(", ")}, not ${JSON.stringify(adapter ?? "")}`,
|
|
119
|
+
);
|
|
120
|
+
const [dir, ...extra] = at === -1 ? rest : rest.toSpliced(at, 2);
|
|
121
|
+
if (extra.length > 0 || dir?.startsWith("-"))
|
|
122
|
+
return usage(io, "init takes at most one directory");
|
|
113
123
|
const report = init({
|
|
114
124
|
cwd: io.cwd,
|
|
115
125
|
...(dir === undefined ? {} : { dir }),
|
|
116
126
|
bun: io.bun(),
|
|
117
127
|
version: io.version(),
|
|
128
|
+
adapter,
|
|
118
129
|
});
|
|
119
130
|
if (!report.ok) {
|
|
120
131
|
for (const line of report.problems) io.err(line);
|
package/src/cli/doctor.ts
CHANGED
|
@@ -14,7 +14,14 @@ import { checkModelLogin } from "./checks/model.ts";
|
|
|
14
14
|
import { checkPublicUrl } from "./checks/public-url.ts";
|
|
15
15
|
import type { Http } from "./http.ts";
|
|
16
16
|
import { type Ports, Project } from "./project.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type Check,
|
|
19
|
+
failed,
|
|
20
|
+
type Outcome,
|
|
21
|
+
type Result,
|
|
22
|
+
runChecks,
|
|
23
|
+
skipped,
|
|
24
|
+
} from "./report.ts";
|
|
18
25
|
|
|
19
26
|
export interface DoctorInputs {
|
|
20
27
|
cwd: string;
|
|
@@ -39,6 +46,11 @@ export function buildChecks(
|
|
|
39
46
|
): Check[] {
|
|
40
47
|
const { cwd, env, bun, ports, database, http, reachable } = inputs;
|
|
41
48
|
const discord = new Discord(project, http);
|
|
49
|
+
// A project without Discord asks Discord nothing.
|
|
50
|
+
const ifDiscord = (run: () => Promise<Result>) => async (): Promise<Result> =>
|
|
51
|
+
(await project.leavesOut("discord"))
|
|
52
|
+
? skipped("no Discord is configured")
|
|
53
|
+
: run();
|
|
42
54
|
return [
|
|
43
55
|
{ name: "Bun", offline: true, run: async () => checkBun(bun) },
|
|
44
56
|
{
|
|
@@ -62,21 +74,25 @@ export function buildChecks(
|
|
|
62
74
|
offline: false,
|
|
63
75
|
run: () => checkDatabase(project, database),
|
|
64
76
|
},
|
|
65
|
-
{
|
|
77
|
+
{
|
|
78
|
+
name: "Discord token",
|
|
79
|
+
offline: false,
|
|
80
|
+
run: ifDiscord(() => checkToken(discord)),
|
|
81
|
+
},
|
|
66
82
|
{
|
|
67
83
|
name: "Discord guild",
|
|
68
84
|
offline: false,
|
|
69
|
-
run: () => checkGuild(project, discord),
|
|
85
|
+
run: ifDiscord(() => checkGuild(project, discord)),
|
|
70
86
|
},
|
|
71
87
|
{
|
|
72
88
|
name: "Discord intents",
|
|
73
89
|
offline: false,
|
|
74
|
-
run: () => checkIntents(discord),
|
|
90
|
+
run: ifDiscord(() => checkIntents(discord)),
|
|
75
91
|
},
|
|
76
92
|
{
|
|
77
93
|
name: "Discord channel",
|
|
78
94
|
offline: false,
|
|
79
|
-
run: () => checkChannel(project, discord),
|
|
95
|
+
run: ifDiscord(() => checkChannel(project, discord)),
|
|
80
96
|
},
|
|
81
97
|
{
|
|
82
98
|
name: "model login",
|
package/src/cli/init.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { basename, join, relative, resolve } from "node:path";
|
|
3
3
|
import { type BunFacts, checkBun } from "./checks/bun.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type Adapter,
|
|
6
|
+
type Rendered,
|
|
7
|
+
renderProject,
|
|
8
|
+
writeRendered,
|
|
9
|
+
} from "./templates.ts";
|
|
5
10
|
|
|
6
11
|
export interface InitInputs {
|
|
7
12
|
cwd: string;
|
|
@@ -12,6 +17,8 @@ export interface InitInputs {
|
|
|
12
17
|
version: string;
|
|
13
18
|
/** Where the templates are; default the package's own. */
|
|
14
19
|
templates?: string;
|
|
20
|
+
/** The chat network the project talks through; default Discord. */
|
|
21
|
+
adapter?: Adapter;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export interface InitReport {
|
|
@@ -61,6 +68,7 @@ export function init(inputs: InitInputs): InitReport {
|
|
|
61
68
|
const files: Rendered[] = renderProject(
|
|
62
69
|
{ project: projectName(root), version: inputs.version },
|
|
63
70
|
inputs.templates,
|
|
71
|
+
inputs.adapter,
|
|
64
72
|
);
|
|
65
73
|
const existing = files
|
|
66
74
|
.map((file) => file.path)
|
package/src/cli/project.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { liftAdapters } from "../core/config/adapters.ts";
|
|
4
5
|
import {
|
|
5
6
|
type ResolvedConfig,
|
|
6
7
|
type RoundtableConfig,
|
|
@@ -51,6 +52,16 @@ export interface Assembled {
|
|
|
51
52
|
const message = (error: unknown): string =>
|
|
52
53
|
error instanceof Error ? error.message : String(error);
|
|
53
54
|
|
|
55
|
+
/** The configuration with its adapters lifted, or as written when they cannot be. */
|
|
56
|
+
// pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
|
|
57
|
+
function lifted(value: unknown): unknown {
|
|
58
|
+
try {
|
|
59
|
+
return liftAdapters(value);
|
|
60
|
+
} catch {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
/**
|
|
55
66
|
* The project's configuration, loaded and resolved at most once and shared by every check, so a
|
|
56
67
|
* mistake in it is reported by the configuration check and the checks that need it say so
|
|
@@ -67,10 +78,14 @@ export class Project {
|
|
|
67
78
|
this.#ports = ports;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The exported value as written, before the schema looks at it, with its adapters' settings in
|
|
83
|
+
* the places the host reads them, such as a Discord adapter's as `discord`. Adapters it cannot
|
|
84
|
+
* read stay as written; the configuration check reports them.
|
|
85
|
+
*/
|
|
71
86
|
raw(): Promise<Loaded<unknown>> {
|
|
72
87
|
this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
|
|
73
|
-
(value): Loaded<unknown> => ({ ok: true, value }),
|
|
88
|
+
(value): Loaded<unknown> => ({ ok: true, value: lifted(value) }),
|
|
74
89
|
(error: unknown): Loaded<unknown> => ({
|
|
75
90
|
ok: false,
|
|
76
91
|
failure: fail(
|
|
@@ -96,6 +111,17 @@ export class Project {
|
|
|
96
111
|
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/** Whether the configuration, loaded, leaves the top-level key out; false when it could not be loaded. */
|
|
115
|
+
async leavesOut(key: string): Promise<boolean> {
|
|
116
|
+
const raw = await this.raw();
|
|
117
|
+
return (
|
|
118
|
+
raw.ok &&
|
|
119
|
+
typeof raw.value === "object" &&
|
|
120
|
+
raw.value !== null &&
|
|
121
|
+
(raw.value as Record<string, unknown>)[key] === undefined
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
99
125
|
/** The configuration checked against its schema and assembled, or the failure that says why not. */
|
|
100
126
|
assembled(): Promise<Loaded<Assembled>> {
|
|
101
127
|
this.#assembled ??= this.#assemble();
|
package/src/cli/templates.ts
CHANGED
|
@@ -19,6 +19,23 @@ const OFFICIAL_DIR = "official";
|
|
|
19
19
|
/** The directory of the plugin `add package` writes around a Pi package. */
|
|
20
20
|
const PACKAGE_DIR = "package";
|
|
21
21
|
|
|
22
|
+
/** The directory of each chat network's files that replace or add to the skeleton's, one directory per adapter. */
|
|
23
|
+
const ADAPTERS_DIR = "adapters";
|
|
24
|
+
|
|
25
|
+
/** The chat networks `init` can set a project up for; Discord's files are the skeleton itself. */
|
|
26
|
+
export const ADAPTERS = ["discord", "web"] as const;
|
|
27
|
+
export type Adapter = (typeof ADAPTERS)[number];
|
|
28
|
+
|
|
29
|
+
/** Whether `name` is an adapter `init` knows. */
|
|
30
|
+
export const isAdapter = (name: string): name is Adapter =>
|
|
31
|
+
(ADAPTERS as readonly string[]).includes(name);
|
|
32
|
+
|
|
33
|
+
/** Skeleton files a project for the adapter leaves out: a web project has no agents and no shared persona. */
|
|
34
|
+
const LEFT_OUT: Readonly<Record<Adapter, readonly string[]>> = {
|
|
35
|
+
discord: [],
|
|
36
|
+
web: ["agents.ts", "persona/shared.md"],
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
/** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
|
|
23
40
|
export const OFFICIAL_PLUGINS = [
|
|
24
41
|
"codex-images",
|
|
@@ -143,31 +160,46 @@ export function renderPackagePlugin(
|
|
|
143
160
|
}));
|
|
144
161
|
}
|
|
145
162
|
|
|
146
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Every file of a new project for `adapter` (default Discord): the skeleton, without the files the
|
|
165
|
+
* adapter leaves out and with its own files in place of the skeleton's, and the `hello` plugin;
|
|
166
|
+
* paths relative to the project.
|
|
167
|
+
*/
|
|
147
168
|
export function renderProject(
|
|
148
169
|
substitutions: Substitutions,
|
|
149
170
|
dir = TEMPLATES_DIR,
|
|
171
|
+
adapter: Adapter = "discord",
|
|
150
172
|
): Rendered[] {
|
|
151
173
|
if (!existsSync(dir)) throw new Error(`templates are missing: ${dir}`);
|
|
152
174
|
const values = {
|
|
153
175
|
PROJECT: substitutions.project,
|
|
154
176
|
VERSION: substitutions.version,
|
|
155
177
|
};
|
|
178
|
+
const render = (base: string, path: string): Rendered => ({
|
|
179
|
+
path: targetOf(path),
|
|
180
|
+
content: fill(readFileSync(join(base, path), "utf8"), values),
|
|
181
|
+
});
|
|
182
|
+
const overlayDir = join(dir, ADAPTERS_DIR, adapter);
|
|
183
|
+
const overlay = existsSync(overlayDir)
|
|
184
|
+
? filesUnder(overlayDir).map((file) =>
|
|
185
|
+
render(overlayDir, relative(overlayDir, file)),
|
|
186
|
+
)
|
|
187
|
+
: [];
|
|
188
|
+
const replaced = new Set([
|
|
189
|
+
...LEFT_OUT[adapter],
|
|
190
|
+
...overlay.map((file) => file.path),
|
|
191
|
+
]);
|
|
156
192
|
const skeleton = filesUnder(dir)
|
|
157
193
|
.map((file) => relative(dir, file))
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
],
|
|
169
|
-
);
|
|
170
|
-
return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
|
|
194
|
+
.filter(
|
|
195
|
+
(path) =>
|
|
196
|
+
![PLUGIN_DIR, OFFICIAL_DIR, PACKAGE_DIR, ADAPTERS_DIR].some((sub) =>
|
|
197
|
+
path.startsWith(`${sub}/`),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
.map((path) => render(dir, path))
|
|
201
|
+
.filter((file) => !replaced.has(file.path));
|
|
202
|
+
return [...skeleton, ...overlay, ...renderPlugin(pluginNames("hello"), dir)];
|
|
171
203
|
}
|
|
172
204
|
|
|
173
205
|
/** Writes rendered files under `root`, creating directories; the caller has checked that none exists. */
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../testing/database.ts";
|
|
26
26
|
import { useTestLocale } from "../testing/locale.ts";
|
|
27
27
|
import { OWNER_SPEAKER, TEST_OWNER } from "../testing/owner.ts";
|
|
28
|
+
import { toolTiers } from "../tool-tiers.ts";
|
|
28
29
|
import type {
|
|
29
30
|
AgentCategory,
|
|
30
31
|
AgentChannels,
|
|
@@ -176,6 +177,10 @@ export interface TeamFixture {
|
|
|
176
177
|
skills: SkillRegistry;
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
/** The tool tiers the team reads; `deploy` is an admin's tool, so an admin may approve it. */
|
|
181
|
+
const FIXTURE_TIERS = toolTiers();
|
|
182
|
+
FIXTURE_TIERS.declare("test", { deploy: "admin" });
|
|
183
|
+
|
|
179
184
|
/** The fixture of the running test; set by `useTeamFixture` before each test. */
|
|
180
185
|
export const fx = {} as TeamFixture;
|
|
181
186
|
|
|
@@ -297,6 +302,7 @@ export function useTeamFixture(): void {
|
|
|
297
302
|
eventLog.length = 0;
|
|
298
303
|
team = new AgentTeam({
|
|
299
304
|
owner: TEST_OWNER,
|
|
305
|
+
toolTiers: FIXTURE_TIERS,
|
|
300
306
|
shellUser: "mcops",
|
|
301
307
|
guildId: "1",
|
|
302
308
|
entryChannelId: ENTRY,
|
|
@@ -143,8 +143,13 @@ export class TeamTurns {
|
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Whether the speaker may approve the held actions: the owner, or the speaker whose turn held
|
|
148
|
+
* them, when their tier now holds every tool. Nobody else in the channel may.
|
|
149
|
+
*/
|
|
147
150
|
#mayApprove(speaker: Speaker, pending: PendingConfirmation): boolean {
|
|
151
|
+
if (speaker.tier !== "owner" && speaker.id !== pending.speakerId)
|
|
152
|
+
return false;
|
|
148
153
|
const tiers = this.#options.toolTiers ?? toolTiers();
|
|
149
154
|
return pending.calls.every(
|
|
150
155
|
(call) =>
|