pi-roundtable 0.8.0 → 0.9.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 +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +47 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { ChatSurface, InboundMessage } from "pi-roundtable";
|
|
3
|
+
import type { ChannelContext } from "pi-roundtable/discord";
|
|
4
|
+
import { fakeDiscord, testPlugin } from "pi-roundtable/testing";
|
|
5
|
+
import { tavern } from "./channel-context.ts";
|
|
6
|
+
import { createEchoRuntime } from "./echo-runtime.ts";
|
|
7
|
+
|
|
8
|
+
const AROUND: ChannelContext = {
|
|
9
|
+
messages: [
|
|
10
|
+
{
|
|
11
|
+
id: "40",
|
|
12
|
+
authorId: "7",
|
|
13
|
+
authorName: "Kai",
|
|
14
|
+
bot: false,
|
|
15
|
+
owner: false,
|
|
16
|
+
text: "I open the cellar door",
|
|
17
|
+
at: new Date(0),
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
text: '## Channel messages since your last answer\n<channel-context>\n<message from="Kai" id="7" role="member">I open the cellar door</message>\n</channel-context>',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A server message from the owner in the tavern's channel. */
|
|
24
|
+
function addressed(extra: Partial<InboundMessage> = {}): InboundMessage {
|
|
25
|
+
return {
|
|
26
|
+
channel: "discord:500",
|
|
27
|
+
messageId: "50",
|
|
28
|
+
authorId: "owner",
|
|
29
|
+
authorName: "Ada",
|
|
30
|
+
authorIsBot: false,
|
|
31
|
+
isDirect: false,
|
|
32
|
+
space: "9",
|
|
33
|
+
mentionsBot: true,
|
|
34
|
+
repliesToBot: false,
|
|
35
|
+
text: "What do we find?",
|
|
36
|
+
attachments: [],
|
|
37
|
+
...extra,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function serving() {
|
|
42
|
+
const asked: { message: InboundMessage; keep?: number }[] = [];
|
|
43
|
+
const replies: string[] = [];
|
|
44
|
+
// The Discord surface as far as the turn's reply goes: `context.turns` posts through it.
|
|
45
|
+
const surface: ChatSurface = {
|
|
46
|
+
surface: "discord",
|
|
47
|
+
start: async () => undefined,
|
|
48
|
+
sendReply: async (_channel, reply) => {
|
|
49
|
+
replies.push(reply.chunks.join(""));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const harness = await testPlugin(tavern(["500"]), {
|
|
53
|
+
surfaces: [surface],
|
|
54
|
+
providers: { runtime: createEchoRuntime },
|
|
55
|
+
services: [
|
|
56
|
+
fakeDiscord({
|
|
57
|
+
channelContext: async (message, options) => {
|
|
58
|
+
asked.push({
|
|
59
|
+
message,
|
|
60
|
+
...(options?.keep ? { keep: options.keep } : {}),
|
|
61
|
+
});
|
|
62
|
+
return AROUND;
|
|
63
|
+
},
|
|
64
|
+
}).service,
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
return { asked, replies, harness };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("an addressed message's turn carries the channel context after its text", async () => {
|
|
71
|
+
const { asked, replies, harness } = await serving();
|
|
72
|
+
await harness.conversations.handle(addressed());
|
|
73
|
+
expect(asked).toEqual([
|
|
74
|
+
{ message: expect.objectContaining({ messageId: "50" }), keep: 20 },
|
|
75
|
+
]);
|
|
76
|
+
expect(replies).toEqual([
|
|
77
|
+
`[You keep the tavern's table. Answer the person who addressed you.] What do we find?\n\n${AROUND.text}`,
|
|
78
|
+
]);
|
|
79
|
+
await harness.stop();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a message that does not address the assistant gets no turn and reads nothing", async () => {
|
|
83
|
+
const { asked, replies, harness } = await serving();
|
|
84
|
+
await harness.conversations.handle(addressed({ mentionsBot: false }));
|
|
85
|
+
expect(asked).toEqual([]);
|
|
86
|
+
expect(replies).toEqual([]);
|
|
87
|
+
await harness.stop();
|
|
88
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { definePlugin, parseChannelKey } from "pi-roundtable";
|
|
2
|
+
import { DISCORD, withChannelContext } from "pi-roundtable/discord";
|
|
3
|
+
|
|
4
|
+
/** The kind of the tavern's conversations. */
|
|
5
|
+
const TAVERN = "tavern";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A claim that opts in to channel context: in the Discord channels it owns it answers only the
|
|
9
|
+
* messages that mention the assistant or reply to it, and each turn also reads what the others
|
|
10
|
+
* said there since the assistant last posted. The context is public channel text appended to the
|
|
11
|
+
* turn's text; the people who wrote it do not become speakers.
|
|
12
|
+
*/
|
|
13
|
+
export function tavern(channelIds: readonly string[]) {
|
|
14
|
+
return definePlugin({
|
|
15
|
+
name: "tavern",
|
|
16
|
+
setup: ({ turns, services }) => {
|
|
17
|
+
const discord = services.get(DISCORD);
|
|
18
|
+
return {
|
|
19
|
+
personas: [
|
|
20
|
+
{
|
|
21
|
+
kind: TAVERN,
|
|
22
|
+
prompt: () =>
|
|
23
|
+
"You keep the tavern's table. Answer the person who addressed you.",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
channels: [
|
|
27
|
+
{
|
|
28
|
+
name: "tavern",
|
|
29
|
+
priority: 10,
|
|
30
|
+
owns: (channel) => {
|
|
31
|
+
const { surface, id } = parseChannelKey(channel);
|
|
32
|
+
return surface === "discord" && channelIds.includes(id);
|
|
33
|
+
},
|
|
34
|
+
admit: (message) => {
|
|
35
|
+
const { speaker } = message;
|
|
36
|
+
if (message.authorIsBot || !speaker) return undefined;
|
|
37
|
+
if (!message.mentionsBot && !message.repliesToBot)
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
kind: "turn",
|
|
41
|
+
run: async () => {
|
|
42
|
+
// Once per answered message, inside `run`: what it returns is not returned again.
|
|
43
|
+
const around = await discord.channelContext(message, {
|
|
44
|
+
keep: 20,
|
|
45
|
+
});
|
|
46
|
+
await turns.run({
|
|
47
|
+
channel: message.channel,
|
|
48
|
+
kind: TAVERN,
|
|
49
|
+
text: withChannelContext(message.text, around),
|
|
50
|
+
speaker,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
failure: "a tavern turn failed",
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
startFresh: async () => TAVERN,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -58,6 +58,7 @@ test("an agent's turn carries its scope, and its conversation is the scope's ses
|
|
|
58
58
|
channel: "fake:agent-room",
|
|
59
59
|
selection: { id: "agent", tools: [], groups: [] },
|
|
60
60
|
text: "status?",
|
|
61
|
+
speaker: OWNER_SPEAKER,
|
|
61
62
|
agent: {
|
|
62
63
|
name: "infra",
|
|
63
64
|
session: "fake:agent-room",
|
|
@@ -4,11 +4,16 @@ import {
|
|
|
4
4
|
definePlugin,
|
|
5
5
|
NotLinkedError,
|
|
6
6
|
PluginError,
|
|
7
|
+
promptScope,
|
|
7
8
|
Roundtable,
|
|
8
9
|
type RoundtablePlugin,
|
|
9
10
|
type SurfacePort,
|
|
10
11
|
} from "pi-roundtable";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
describeSurfaceContract,
|
|
14
|
+
type SurfaceObservation,
|
|
15
|
+
silentLogger,
|
|
16
|
+
} from "pi-roundtable/testing";
|
|
12
17
|
import { FakeSurface, fakeChat } from "./fake-surface.ts";
|
|
13
18
|
|
|
14
19
|
/** One host runs per process, so each test stops its own. */
|
|
@@ -97,18 +102,76 @@ test("the port picks the surface by the key's prefix and refuses a prefix nobody
|
|
|
97
102
|
expect(surface.stops).toEqual(["show fake:1", "hide fake:1"]);
|
|
98
103
|
});
|
|
99
104
|
|
|
100
|
-
test("
|
|
105
|
+
test("approvals in a channel of the surface use the surface's prompts, and other channels have none", async () => {
|
|
101
106
|
const surface = new FakeSurface();
|
|
102
107
|
const probe = portOf();
|
|
103
108
|
await host([fakeChat(surface), probe.plugin]).run();
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
"
|
|
109
|
+
const speaker = {
|
|
110
|
+
id: "1",
|
|
111
|
+
name: "Ada",
|
|
112
|
+
tier: "member",
|
|
113
|
+
principalId: "p1",
|
|
114
|
+
} as const;
|
|
115
|
+
const prompts = probe.port().prompts("fake:1", promptScope(speaker));
|
|
116
|
+
const answer = prompts?.confirm(
|
|
117
|
+
"Search?",
|
|
118
|
+
"searches the web",
|
|
119
|
+
undefined,
|
|
120
|
+
"member",
|
|
107
121
|
);
|
|
108
|
-
expect(surface.asked).toEqual([
|
|
122
|
+
expect(surface.asked).toEqual([
|
|
123
|
+
{ id: "q1", channel: "fake:1", title: "Search?", open: true },
|
|
124
|
+
]);
|
|
125
|
+
surface.answer("q1", { principalId: "p1" }, true);
|
|
126
|
+
expect(await answer).toBe("approved");
|
|
109
127
|
expect(probe.port().prompts("mcp:1")).toBeUndefined();
|
|
110
128
|
});
|
|
111
129
|
|
|
130
|
+
/** The fake chat as the contract sees it: what its person, author "1", has seen. */
|
|
131
|
+
function contractSubject(tier: "owner" | "member") {
|
|
132
|
+
const surface = new FakeSurface();
|
|
133
|
+
return {
|
|
134
|
+
surface,
|
|
135
|
+
channel: "fake:room" as const,
|
|
136
|
+
speaker: { id: "1", name: "Ada", tier, principalId: "p1" },
|
|
137
|
+
write: async (text: string) => surface.say("fake:room", text),
|
|
138
|
+
observations: (): SurfaceObservation[] => [
|
|
139
|
+
...surface.replies.map(({ reply }) => ({
|
|
140
|
+
kind: "reply" as const,
|
|
141
|
+
text: reply.chunks.join("\n"),
|
|
142
|
+
files: [],
|
|
143
|
+
})),
|
|
144
|
+
...surface.typing.map((entry) => ({
|
|
145
|
+
kind: "typing" as const,
|
|
146
|
+
on: entry.startsWith("start"),
|
|
147
|
+
})),
|
|
148
|
+
...surface.stops.map((entry) => ({
|
|
149
|
+
kind: "stop" as const,
|
|
150
|
+
on: entry.startsWith("show"),
|
|
151
|
+
})),
|
|
152
|
+
...surface.asked.flatMap(({ id, title, open }) => [
|
|
153
|
+
{ kind: "prompt" as const, id, title },
|
|
154
|
+
...(open ? [] : [{ kind: "prompt_closed" as const, id }]),
|
|
155
|
+
]),
|
|
156
|
+
],
|
|
157
|
+
answer: async (id: string, approved: boolean) =>
|
|
158
|
+
surface.answer(id, { principalId: "p1" }, approved),
|
|
159
|
+
stranger: {
|
|
160
|
+
answer: async (id: string, approved: boolean) =>
|
|
161
|
+
surface.answer(id, { principalId: "p2" }, approved),
|
|
162
|
+
},
|
|
163
|
+
owner: {
|
|
164
|
+
answer: async (id: string, approved: boolean) =>
|
|
165
|
+
surface.answer(id, { principalId: "p0", owner: true }, approved),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describeSurfaceContract("the fake chat", async () => contractSubject("owner"));
|
|
171
|
+
describeSurfaceContract("the fake chat for a member", async () =>
|
|
172
|
+
contractSubject("member"),
|
|
173
|
+
);
|
|
174
|
+
|
|
112
175
|
test("two surfaces with one prefix are refused, naming both plugins", async () => {
|
|
113
176
|
const second = definePlugin({
|
|
114
177
|
name: "second-chat",
|
package/examples/fake-surface.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Approval,
|
|
2
3
|
type ChannelKey,
|
|
3
4
|
type ChatSurface,
|
|
4
5
|
definePlugin,
|
|
5
6
|
type InboundMessage,
|
|
6
7
|
type OutboundReply,
|
|
7
|
-
type
|
|
8
|
+
type PromptScope,
|
|
9
|
+
type Prompts,
|
|
8
10
|
parseChannelKey,
|
|
11
|
+
TIERS,
|
|
9
12
|
} from "pi-roundtable";
|
|
10
13
|
|
|
14
|
+
/** A prompt the surface showed, open until someone it is for answers it. */
|
|
15
|
+
interface Asked {
|
|
16
|
+
id: string;
|
|
17
|
+
channel: ChannelKey;
|
|
18
|
+
title: string;
|
|
19
|
+
open: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
/**
|
|
12
23
|
* A chat surface connects the host to one chat network. This one is an in-memory chat: its
|
|
13
24
|
* channels are the keys that start with `fake:`, and it records what the host asks of it. A real
|
|
@@ -19,7 +30,12 @@ export class FakeSurface implements ChatSurface {
|
|
|
19
30
|
readonly replies: { channel: ChannelKey; reply: OutboundReply }[] = [];
|
|
20
31
|
readonly typing: string[] = [];
|
|
21
32
|
readonly stops: string[] = [];
|
|
22
|
-
readonly asked:
|
|
33
|
+
readonly asked: Asked[] = [];
|
|
34
|
+
/** Who may answer each open prompt, and how it settles. */
|
|
35
|
+
readonly #answering = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ principalId?: string; owners: boolean; settle(answer: Approval): void }
|
|
38
|
+
>();
|
|
23
39
|
#deliver: ((message: InboundMessage) => void) | undefined;
|
|
24
40
|
|
|
25
41
|
/** The host hands over its router; every message the network reports goes to it. */
|
|
@@ -61,16 +77,66 @@ export class FakeSurface implements ChatSurface {
|
|
|
61
77
|
return () => void this.stops.push(`hide ${channel}`);
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
80
|
+
/**
|
|
81
|
+
* How a turn asks to approve a held action or answers a question. The scope names who may
|
|
82
|
+
* answer: its speaker when their tier holds the call, and the owners unless the conversation
|
|
83
|
+
* is private, where a call above the speaker's tier goes to no one and expires at once.
|
|
84
|
+
*/
|
|
85
|
+
prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
|
|
66
86
|
return {
|
|
67
|
-
confirm: async (title) => {
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
confirm: async (title, _message, signal, minTier = "owner") => {
|
|
88
|
+
const theirs =
|
|
89
|
+
scope !== undefined &&
|
|
90
|
+
TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
|
|
91
|
+
const owners = scope?.escalate !== "none";
|
|
92
|
+
if (!theirs && !owners) return "expired";
|
|
93
|
+
return this.#ask(channel, title, signal, {
|
|
94
|
+
...(theirs ? { principalId: scope.principalId } : {}),
|
|
95
|
+
owners,
|
|
96
|
+
});
|
|
70
97
|
},
|
|
71
98
|
ask: async () => undefined,
|
|
72
99
|
};
|
|
73
100
|
}
|
|
101
|
+
|
|
102
|
+
/** Someone answers an open prompt; it settles only when it is theirs to answer. */
|
|
103
|
+
answer(
|
|
104
|
+
id: string,
|
|
105
|
+
who: { principalId: string; owner?: boolean },
|
|
106
|
+
approved: boolean,
|
|
107
|
+
): void {
|
|
108
|
+
const open = this.#answering.get(id);
|
|
109
|
+
if (!open) return;
|
|
110
|
+
const theirs = open.principalId === who.principalId;
|
|
111
|
+
if (theirs || (who.owner && open.owners))
|
|
112
|
+
open.settle(approved ? "approved" : "declined");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#ask(
|
|
116
|
+
channel: ChannelKey,
|
|
117
|
+
title: string,
|
|
118
|
+
signal: AbortSignal | undefined,
|
|
119
|
+
audience: { principalId?: string; owners: boolean },
|
|
120
|
+
): Promise<Approval> {
|
|
121
|
+
const asked = {
|
|
122
|
+
id: `q${this.asked.length + 1}`,
|
|
123
|
+
channel,
|
|
124
|
+
title,
|
|
125
|
+
open: true,
|
|
126
|
+
};
|
|
127
|
+
this.asked.push(asked);
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const settle = (answer: Approval) => {
|
|
130
|
+
this.#answering.delete(asked.id);
|
|
131
|
+
asked.open = false;
|
|
132
|
+
resolve(answer);
|
|
133
|
+
};
|
|
134
|
+
this.#answering.set(asked.id, { ...audience, settle });
|
|
135
|
+
signal?.addEventListener("abort", () => settle("cancelled"), {
|
|
136
|
+
once: true,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
74
140
|
}
|
|
75
141
|
|
|
76
142
|
/**
|
package/examples/headless.ts
CHANGED
|
@@ -22,7 +22,11 @@ export function studyHall(
|
|
|
22
22
|
): RoundtableConfig {
|
|
23
23
|
return {
|
|
24
24
|
name: "Study Hall",
|
|
25
|
-
|
|
25
|
+
access: {
|
|
26
|
+
owners: [{ principal: "owner", name: "Ada" }],
|
|
27
|
+
// Admit the fake surface's authors explicitly, not in the claim.
|
|
28
|
+
members: { everyone: ["fake"] },
|
|
29
|
+
},
|
|
26
30
|
database: { url: where.databaseUrl },
|
|
27
31
|
dataDir: where.dataDir,
|
|
28
32
|
model: "anthropic/claude-sonnet-5-5",
|
|
@@ -15,6 +15,7 @@ test("the precheck stays quiet on a normal reading and wakes the agent on a low
|
|
|
15
15
|
const context = {
|
|
16
16
|
schedule: { id: 1, title: "recovery" } as Schedule,
|
|
17
17
|
firedAt: new Date(),
|
|
18
|
+
tier: "owner" as const,
|
|
18
19
|
signal: new AbortController().signal,
|
|
19
20
|
};
|
|
20
21
|
expect(await precheck.run(context)).toEqual({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { IDENTITY, type Speaker } from "pi-roundtable";
|
|
3
|
+
import { servicePair, testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import { access, principals } from "./principals.ts";
|
|
5
|
+
|
|
6
|
+
test("two actor identities of one principal ask about the same person", async () => {
|
|
7
|
+
const lookedUp: string[] = [];
|
|
8
|
+
const harness = await testPlugin(principals, {
|
|
9
|
+
services: [
|
|
10
|
+
servicePair(IDENTITY, {
|
|
11
|
+
principal: async (id) => {
|
|
12
|
+
lookedUp.push(id);
|
|
13
|
+
return { id, displayName: "Mo", disabled: false };
|
|
14
|
+
},
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
for (const id of ["discord-actor", "web-actor"]) {
|
|
20
|
+
const speaker: Speaker = {
|
|
21
|
+
id,
|
|
22
|
+
name: "Mo",
|
|
23
|
+
principalId: "p_mo",
|
|
24
|
+
tier: "member",
|
|
25
|
+
};
|
|
26
|
+
expect(await harness.runTool("principal_who", {}, { speaker })).toBe(
|
|
27
|
+
"Mo (p_mo)",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
expect(lookedUp).toEqual(["p_mo", "p_mo"]);
|
|
31
|
+
} finally {
|
|
32
|
+
await harness.stop();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("an unavailable principal is refused even at owner tier", async () => {
|
|
37
|
+
const harness = await testPlugin(principals, {
|
|
38
|
+
services: [servicePair(IDENTITY, { principal: async () => undefined })],
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
expect(
|
|
42
|
+
await harness.runTool(
|
|
43
|
+
"principal_who",
|
|
44
|
+
{},
|
|
45
|
+
{
|
|
46
|
+
speaker: {
|
|
47
|
+
id: "actor",
|
|
48
|
+
name: "Ada",
|
|
49
|
+
principalId: "operator",
|
|
50
|
+
tier: "owner",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
).toContain("This principal is not available.");
|
|
55
|
+
expect(access.owners[0]?.principal).toBe("operator");
|
|
56
|
+
expect(access.members.roles).toEqual(["web:role:App.User"]);
|
|
57
|
+
} finally {
|
|
58
|
+
await harness.stop();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AccessConfig,
|
|
3
|
+
definePlugin,
|
|
4
|
+
defineTool,
|
|
5
|
+
IDENTITY,
|
|
6
|
+
ToolRefusal,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
/** One person may have several identities; surface roles never make an owner. */
|
|
11
|
+
export const access = {
|
|
12
|
+
owners: [
|
|
13
|
+
{
|
|
14
|
+
principal: "operator",
|
|
15
|
+
name: "Ada",
|
|
16
|
+
identities: ["discord:966666600000000001", "token:remote-mcp"],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
members: { roles: ["web:role:App.User"] },
|
|
20
|
+
provisioning: "admitted",
|
|
21
|
+
} satisfies AccessConfig;
|
|
22
|
+
|
|
23
|
+
/** Ask about the current principal, not the external actor or the primary owner. */
|
|
24
|
+
export const principals = definePlugin({
|
|
25
|
+
name: "principals",
|
|
26
|
+
requires: [IDENTITY],
|
|
27
|
+
setup: ({ services }) => {
|
|
28
|
+
const identity = services.get(IDENTITY);
|
|
29
|
+
return {
|
|
30
|
+
tools: [
|
|
31
|
+
defineTool({
|
|
32
|
+
name: "principal_who",
|
|
33
|
+
description: "Show who this turn is for.",
|
|
34
|
+
parameters: Type.Object({}),
|
|
35
|
+
minTier: "member",
|
|
36
|
+
run: async (_args, turn) => {
|
|
37
|
+
if (!turn.speaker)
|
|
38
|
+
throw new ToolRefusal("No speaker for this turn.");
|
|
39
|
+
const person = await identity.principal(turn.speaker.principalId);
|
|
40
|
+
if (!person || person.disabled)
|
|
41
|
+
throw new ToolRefusal("This principal is not available.");
|
|
42
|
+
return `${person.displayName} (${person.id})`;
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
package/examples/prompt.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ test("the section names the agent, and the speaker when there is one", async ()
|
|
|
17
17
|
expect(
|
|
18
18
|
section?.build({
|
|
19
19
|
agent,
|
|
20
|
-
speaker: { id: "1", name: "Ada", tier: "member" },
|
|
20
|
+
speaker: { id: "1", name: "Ada", tier: "member", principalId: "1" },
|
|
21
21
|
scope,
|
|
22
22
|
}),
|
|
23
23
|
).toContain("You are talking with Ada.");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { IDENTITY } from "pi-roundtable";
|
|
3
|
+
import { servicePair, testPlugin } from "pi-roundtable/testing";
|
|
3
4
|
import { createEchoRuntime } from "./echo-runtime.ts";
|
|
4
5
|
import { FakeSurface } from "./fake-surface.ts";
|
|
5
6
|
import { studyRoom } from "./study-room.ts";
|
|
@@ -10,6 +11,16 @@ async function studying() {
|
|
|
10
11
|
const harness = await testPlugin(studyRoom, {
|
|
11
12
|
surfaces: [surface],
|
|
12
13
|
providers: { runtime: createEchoRuntime },
|
|
14
|
+
services: [
|
|
15
|
+
servicePair(IDENTITY, {
|
|
16
|
+
resolve: async (facts) => ({
|
|
17
|
+
id: facts.subject,
|
|
18
|
+
name: facts.name,
|
|
19
|
+
principalId: "student",
|
|
20
|
+
tier: "member",
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
13
24
|
});
|
|
14
25
|
return { surface, harness };
|
|
15
26
|
}
|
|
@@ -39,6 +50,7 @@ test("a message in a study room runs as a turn of the study kind, with the tutor
|
|
|
39
50
|
expect(started?.turn).toMatchObject({
|
|
40
51
|
kind: "study",
|
|
41
52
|
channel: "fake:study-algebra",
|
|
53
|
+
speaker: { principalId: "student", tier: "member" },
|
|
42
54
|
});
|
|
43
55
|
expect(started?.turn?.agent).toBeUndefined();
|
|
44
56
|
await harness.stop();
|
|
@@ -60,6 +72,22 @@ test("starting a room over says it was a study conversation", async () => {
|
|
|
60
72
|
await harness.stop();
|
|
61
73
|
});
|
|
62
74
|
|
|
75
|
+
test("an author with no resolved tier never starts a study turn", async () => {
|
|
76
|
+
const surface = new FakeSurface();
|
|
77
|
+
const harness = await testPlugin(studyRoom, {
|
|
78
|
+
surfaces: [surface],
|
|
79
|
+
providers: { runtime: createEchoRuntime },
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
surface.say("fake:study-algebra", "hello");
|
|
83
|
+
await Bun.sleep(30);
|
|
84
|
+
expect(surface.replies).toEqual([]);
|
|
85
|
+
expect(harness.events).toEqual([]);
|
|
86
|
+
} finally {
|
|
87
|
+
await harness.stop();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
63
91
|
test("the persona is the plugin's and belongs to the study kind only", async () => {
|
|
64
92
|
const { harness } = await studying();
|
|
65
93
|
const [persona] = harness.contribution.personas ?? [];
|
package/examples/study-room.ts
CHANGED
|
@@ -25,25 +25,22 @@ export const studyRoom = definePlugin({
|
|
|
25
25
|
priority: 10,
|
|
26
26
|
// The id of a room starts with `study-`, on whichever surface carries it.
|
|
27
27
|
owns: (channel) => parseChannelKey(channel).id.startsWith("study-"),
|
|
28
|
-
admit: (message) =>
|
|
29
|
-
message.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
},
|
|
45
|
-
failure: "a study turn failed",
|
|
46
|
-
},
|
|
28
|
+
admit: (message) => {
|
|
29
|
+
const speaker = message.speaker;
|
|
30
|
+
if (message.authorIsBot || !speaker) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
kind: "turn",
|
|
33
|
+
run: async () => {
|
|
34
|
+
await turns.run({
|
|
35
|
+
channel: message.channel,
|
|
36
|
+
kind: STUDY,
|
|
37
|
+
text: message.text,
|
|
38
|
+
speaker,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
failure: "a study turn failed",
|
|
42
|
+
};
|
|
43
|
+
},
|
|
47
44
|
// What the conversation was, so a host picks the right persona when it starts over.
|
|
48
45
|
startFresh: async () => STUDY,
|
|
49
46
|
},
|
|
@@ -2,10 +2,12 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { testPlugin } from "pi-roundtable/testing";
|
|
3
3
|
import { supportDesk } from "./support-desk.ts";
|
|
4
4
|
|
|
5
|
+
// A turn runs as its author's principal: here the harness's owner, the one person it knows.
|
|
5
6
|
const turn = (target: string) => ({
|
|
6
7
|
channel: "support:1" as const,
|
|
7
8
|
target,
|
|
8
|
-
author: { id: "
|
|
9
|
+
author: { principalId: "owner", id: "owner", name: "Owner" },
|
|
10
|
+
tier: "member" as const,
|
|
9
11
|
turnId: "t1",
|
|
10
12
|
text: "check the queue",
|
|
11
13
|
});
|
package/examples/tools.test.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { notes } from "./tools.ts";
|
|
|
5
5
|
|
|
6
6
|
test("note_add saves a note for the speaker and refuses an empty one", async () => {
|
|
7
7
|
const harness = await testPlugin(notes);
|
|
8
|
-
const ada: Speaker = {
|
|
8
|
+
const ada: Speaker = {
|
|
9
|
+
id: "1",
|
|
10
|
+
name: "Ada",
|
|
11
|
+
tier: "member",
|
|
12
|
+
principalId: "1",
|
|
13
|
+
};
|
|
9
14
|
expect(harness.tools).toEqual(["note_add"]);
|
|
10
15
|
expect(harness.tiers.minTier("note_add")).toBe("member");
|
|
11
16
|
expect(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
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",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"src",
|
|
27
27
|
"!src/**/*.test.ts",
|
|
28
28
|
"!src/core/testing/file-size.ts",
|
|
29
|
+
"!src/core/testing/prompt-capture*.ts",
|
|
30
|
+
"!src/core/testing/fixtures",
|
|
31
|
+
"!src/prompt-text.snapshot.json",
|
|
29
32
|
"!src/cli/testing",
|
|
30
33
|
"templates",
|
|
31
34
|
"docs",
|
|
@@ -52,7 +55,7 @@
|
|
|
52
55
|
"typecheck": "tsc --noEmit",
|
|
53
56
|
"lint": "biome check .",
|
|
54
57
|
"test": "bun test ./src ./scripts ./examples",
|
|
55
|
-
"check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces test"
|
|
58
|
+
"check:packages": "bun run --workspaces typecheck && bun run --workspaces lint && bun run --workspaces --sequential test"
|
|
56
59
|
},
|
|
57
60
|
"dependencies": {
|
|
58
61
|
"@babel/parser": "7.29.9",
|