pi-roundtable 0.7.19 → 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.
Files changed (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. 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
+ }
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import { $ } from "bun";
3
+ import { defineRoundtable, type RoundtableConfig } from "pi-roundtable";
4
+ import { silentLogger } from "pi-roundtable/testing";
5
+ import { withDiscordAdapter } from "./discord-adapter.ts";
6
+
7
+ const ENV: Record<string, string> = {
8
+ OWNER_ID: "100000000000000001",
9
+ OWNER_NAME: "Ada",
10
+ DISCORD_TOKEN: "token",
11
+ DISCORD_GUILD_ID: "900000000000000001",
12
+ DISCORD_ENTRY_CHANNEL_ID: "900000000000000002",
13
+ DATABASE_URL: "postgres://localhost/roundtable",
14
+ MODEL: "anthropic/claude-sonnet-5-5",
15
+ PUBLIC_URL: "https://bot.example.com",
16
+ };
17
+
18
+ test("a Discord adapter assembles the plugins the top-level discord does, in the same order", async () => {
19
+ const dataDir = `${Bun.env.TMPDIR ?? "/tmp"}/discord-adapter-${crypto.randomUUID()}`;
20
+ const adapted = withDiscordAdapter((name) => ENV[name] ?? "", dataDir);
21
+ const { adapters: _adapters, ...rest } = adapted;
22
+ const topLevel: RoundtableConfig = {
23
+ ...rest,
24
+ discord: {
25
+ token: "token",
26
+ guild: "900000000000000001",
27
+ entryChannel: "900000000000000002",
28
+ },
29
+ };
30
+ const names = async (config: RoundtableConfig) =>
31
+ (await defineRoundtable(config, { logger: silentLogger() })).plugins.map(
32
+ (plugin) => plugin.name,
33
+ );
34
+ try {
35
+ const plugins = await names(adapted);
36
+ expect(plugins).toContain("discord");
37
+ expect(plugins).toContain("agent-server");
38
+ expect(plugins).toEqual(await names(topLevel));
39
+ } finally {
40
+ await $`rm -rf ${dataDir}`;
41
+ }
42
+ });
@@ -0,0 +1,26 @@
1
+ import type { RoundtableConfig } from "pi-roundtable";
2
+ import { discord } from "pi-roundtable/discord";
3
+
4
+ /**
5
+ * Discord as an adapter: `discord()` takes the same settings as the top-level `discord`, and the
6
+ * host assembles the same plugins in the same order. Configure Discord in one place, not both.
7
+ */
8
+ export function withDiscordAdapter(
9
+ env: (name: string) => string,
10
+ dataDir: string,
11
+ ): RoundtableConfig {
12
+ return {
13
+ owner: { id: env("OWNER_ID"), name: env("OWNER_NAME") },
14
+ adapters: [
15
+ discord({
16
+ token: env("DISCORD_TOKEN"),
17
+ guild: env("DISCORD_GUILD_ID"),
18
+ entryChannel: env("DISCORD_ENTRY_CHANNEL_ID"),
19
+ }),
20
+ ],
21
+ database: { url: env("DATABASE_URL") },
22
+ dataDir,
23
+ model: env("MODEL"),
24
+ http: { publicUrl: env("PUBLIC_URL") },
25
+ };
26
+ }
@@ -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",
@@ -98,7 +98,7 @@ export const createEchoRuntime: RuntimeFactory = (deps) => {
98
98
 
99
99
  /**
100
100
  * A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
101
- * agent server builds the Pi runtime. One plugin may fill it.
101
+ * runtime plugin builds the Pi runtime. One plugin may fill it.
102
102
  */
103
103
  export const echoRuntime = definePlugin({
104
104
  name: "echo-runtime",
@@ -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 { silentLogger } from "pi-roundtable/testing";
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("the owner's approvals in a channel of the surface use the surface's prompts, and other channels have none", async () => {
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 prompts = probe.port().prompts("fake:1");
105
- expect(await prompts?.confirm("Run rm?", "deletes the notes")).toBe(
106
- "approved",
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(["fake:1: Run rm?"]);
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",
@@ -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 OwnerPrompts,
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: string[] = [];
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
- /** How the owner would approve a held action or answer a question in a turn. */
65
- prompts(channel: ChannelKey): OwnerPrompts {
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
- this.asked.push(`${channel}: ${title}`);
69
- return "approved";
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
  /**
@@ -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,35 @@
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
+ access: {
26
+ owners: [{ principal: "owner", name: "Ada" }],
27
+ // Admit the fake surface's authors explicitly, not in the claim.
28
+ members: { everyone: ["fake"] },
29
+ },
30
+ database: { url: where.databaseUrl },
31
+ dataDir: where.dataDir,
32
+ model: "anthropic/claude-sonnet-5-5",
33
+ plugins: [chatNetwork(surface), studyRoom],
34
+ };
35
+ }
@@ -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
+ });
@@ -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({