pi-roundtable 0.1.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 (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,13 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { echo } from "./channels.ts";
4
+
5
+ test("the claim owns channels of its surface and admits their messages as turns", async () => {
6
+ const harness = await testPlugin(echo);
7
+ const [claim] = harness.contribution.channels ?? [];
8
+ expect(claim?.owns("echo:1")).toBe(true);
9
+ expect(claim?.owns("discord:1")).toBe(false);
10
+ const admitted = claim?.admit({ text: "hi" } as never);
11
+ expect(admitted?.kind).toBe("turn");
12
+ await harness.stop();
13
+ });
@@ -0,0 +1,27 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /**
4
+ * A channel claim makes a plugin the owner of the conversations in some channels. The router
5
+ * asks claims by descending priority; the first that owns a channel decides everything there,
6
+ * and a message its `admit` returns nothing for is dropped.
7
+ */
8
+ export const echo = definePlugin({
9
+ name: "echo",
10
+ setup: () => ({
11
+ channels: [
12
+ {
13
+ name: "echo-channels",
14
+ priority: 10,
15
+ owns: (channel) => channel.startsWith("echo:"),
16
+ admit: (message) => ({
17
+ kind: "turn",
18
+ run: async () => {
19
+ console.log(`echo: ${message.text}`);
20
+ },
21
+ failure: "an echo turn failed",
22
+ }),
23
+ startFresh: async () => "The echo channel has nothing to start over.",
24
+ },
25
+ ],
26
+ }),
27
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { links } from "./dashboard.ts";
4
+
5
+ test("the plugin adds one line to the dashboard", async () => {
6
+ const harness = await testPlugin(links);
7
+ expect(harness.contribution.dashboard).toEqual([
8
+ "Docs: https://example.com/docs",
9
+ ]);
10
+ await harness.stop();
11
+ });
@@ -0,0 +1,9 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** Dashboard lines are shown under the title of the agent server's dashboard message, in plugin order. */
4
+ export const links = definePlugin({
5
+ name: "links",
6
+ setup: () => ({
7
+ dashboard: ["Docs: https://example.com/docs"],
8
+ }),
9
+ });
@@ -0,0 +1,26 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { turnLog } from "./events.ts";
4
+
5
+ test("the handlers record a turn, a team change, and the shutdown", async () => {
6
+ const lines: string[] = [];
7
+ const harness = await testPlugin(turnLog(lines));
8
+ const { events } = harness.contribution;
9
+ // The harness does not deliver the core's events: call the handlers with the payload the core sends.
10
+ const turn = {
11
+ agent: "guide",
12
+ channel: "discord:1",
13
+ speaker: undefined,
14
+ } as const;
15
+ await events?.turnStarted?.(turn);
16
+ await events?.turnEnded?.({ ...turn, result: "ok" });
17
+ await events?.changed?.();
18
+ // stop() delivers shutdown(left) with an empty list, then stops the services.
19
+ await harness.stop();
20
+ expect(lines).toEqual([
21
+ "guide started",
22
+ "guide ok",
23
+ "team changed",
24
+ "shutdown, 0 unfinished",
25
+ ]);
26
+ });
@@ -0,0 +1,25 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** Handlers hear what the core does. One that throws is logged and never stops the others. */
4
+ export function turnLog(lines: string[]) {
5
+ return definePlugin({
6
+ name: "turn-log",
7
+ setup: () => ({
8
+ events: {
9
+ turnStarted: (turn) => {
10
+ lines.push(`${turn.agent} started`);
11
+ },
12
+ turnEnded: (turn) => {
13
+ lines.push(`${turn.agent} ${turn.result}`);
14
+ },
15
+ changed: () => {
16
+ lines.push("team changed");
17
+ },
18
+ // The drain is over, and no service has stopped yet.
19
+ shutdown: (left) => {
20
+ lines.push(`shutdown, ${left.length} unfinished`);
21
+ },
22
+ },
23
+ }),
24
+ });
25
+ }
@@ -0,0 +1,52 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const root = join(import.meta.dir, "..");
6
+ const guide = readFileSync(join(root, "docs/plugins.md"), "utf8");
7
+ const files = readdirSync(import.meta.dir).filter((name) =>
8
+ name.endsWith(".ts"),
9
+ );
10
+ const examples = files.filter(
11
+ (name) => !name.endsWith(".test.ts") && name !== "guide.test.ts",
12
+ );
13
+
14
+ /** The guide embeds a file as a `ts` block between these two markers. */
15
+ const MARKER = /<!-- example: (\S+) -->/g;
16
+ const BLOCK =
17
+ /<!-- example: (\S+) -->\n```ts\n([\s\S]*?)\n```\n<!-- \/example -->/g;
18
+
19
+ test("every marker in the guide wraps one ts block", () => {
20
+ expect([...guide.matchAll(BLOCK)].length).toBe(
21
+ [...guide.matchAll(MARKER)].length,
22
+ );
23
+ expect([...guide.matchAll(MARKER)].length).toBeGreaterThan(0);
24
+ });
25
+
26
+ test("every block in the guide is exactly the file it names", () => {
27
+ for (const [, path, embedded] of guide.matchAll(BLOCK)) {
28
+ const file = readFileSync(join(root, path as string), "utf8");
29
+ expect({ path, code: embedded }).toEqual({ path, code: file.trimEnd() });
30
+ }
31
+ });
32
+
33
+ test("every example is embedded in the guide and has a test beside it", () => {
34
+ const embedded = [...guide.matchAll(MARKER)].map((match) => match[1]);
35
+ for (const name of examples) {
36
+ expect(embedded).toContain(`examples/${name}`);
37
+ expect(files).toContain(name.replace(/\.ts$/, ".test.ts"));
38
+ }
39
+ });
40
+
41
+ test("examples import only the public entries, typebox, bun, and their neighbors", () => {
42
+ for (const name of files.filter((file) => file !== "guide.test.ts")) {
43
+ const source = readFileSync(join(import.meta.dir, name), "utf8");
44
+ for (const [, specifier] of source.matchAll(/from "([^"]+)"/g))
45
+ expect({ name, specifier }).toEqual({
46
+ name,
47
+ specifier: expect.stringMatching(
48
+ /^(pi-roundtable|pi-roundtable\/testing|typebox|bun|bun:test|\.\/[a-z-]+\.ts)$/,
49
+ ),
50
+ });
51
+ }
52
+ });
@@ -0,0 +1,16 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { cleanup } from "./holds.ts";
4
+
5
+ test("a tool's hold and a hold rule each describe the call that must wait", async () => {
6
+ const harness = await testPlugin(cleanup);
7
+ const rules = harness.contribution.holdRules ?? [];
8
+ const describe = (tool: string, input: Record<string, unknown>) =>
9
+ rules.flatMap((rule) => rule.describe(tool, input, {}) ?? []);
10
+ expect(describe("file_delete", { path: "a.txt" })).toEqual(["Delete a.txt"]);
11
+ expect(describe("bash", { command: "restart production" })).toEqual([
12
+ "bash touches production",
13
+ ]);
14
+ expect(describe("bash", { command: "ls" })).toEqual([]);
15
+ await harness.stop();
16
+ });
@@ -0,0 +1,30 @@
1
+ import { definePlugin, defineTool } from "pi-roundtable";
2
+ import { Type } from "typebox";
3
+
4
+ /** Holds make a call wait for the owner's approval; the description is what the owner is asked to approve. */
5
+ export const cleanup = definePlugin({
6
+ name: "cleanup",
7
+ setup: () => ({
8
+ tools: [
9
+ defineTool({
10
+ name: "file_delete",
11
+ description: "Delete a file in the shared workspace.",
12
+ parameters: Type.Object({ path: Type.String() }),
13
+ minTier: "admin",
14
+ // Returning text holds this call; returning undefined lets it run.
15
+ hold: ({ path }) => `Delete ${path}`,
16
+ run: ({ path }) => `Deleted ${path}.`,
17
+ }),
18
+ ],
19
+ // A rule sees every tool call, whoever defined the tool.
20
+ holdRules: [
21
+ {
22
+ name: "cleanup-production",
23
+ describe: (tool, input) =>
24
+ JSON.stringify(input).includes("production")
25
+ ? `${tool} touches production`
26
+ : undefined,
27
+ },
28
+ ],
29
+ }),
30
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { health } from "./http.ts";
4
+
5
+ test("the route answers GET /healthz", async () => {
6
+ const harness = await testPlugin(health);
7
+ const route = harness.contribution.http?.[0];
8
+ expect(route?.listener).toBe("public");
9
+ const response = await route?.handle(new Request("http://localhost/healthz"));
10
+ expect(await response?.text()).toBe("ok");
11
+ await harness.stop();
12
+ });
@@ -0,0 +1,17 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** A route answers requests on a listener the host runs; "public" is the one the configuration's `http` block opens. */
4
+ export const health = definePlugin({
5
+ name: "health",
6
+ setup: () => ({
7
+ http: [
8
+ {
9
+ name: "health-check",
10
+ listener: "public",
11
+ path: { exact: "/healthz" },
12
+ methods: ["GET"],
13
+ handle: () => new Response("ok"),
14
+ },
15
+ ],
16
+ }),
17
+ });
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { ping } from "./interactions.ts";
4
+
5
+ /** The parts of a Discord interaction the module reads. */
6
+ function fake(subcommand: string, replies: string[]) {
7
+ return {
8
+ isChatInputCommand: () => true,
9
+ options: { getSubcommand: () => subcommand },
10
+ reply: async (text: string) => {
11
+ replies.push(text);
12
+ },
13
+ } as never;
14
+ }
15
+
16
+ test("the module answers /roundtable ping and ignores other subcommands", async () => {
17
+ const harness = await testPlugin(ping);
18
+ const [added] = harness.contribution.interactions ?? [];
19
+ expect(added?.rootOptions?.map((option) => option.name)).toEqual(["ping"]);
20
+ const replies: string[] = [];
21
+ expect(await added?.module.handle(fake("ping", replies))).toBe(true);
22
+ expect(await added?.module.handle(fake("other", replies))).toBe(false);
23
+ expect(replies).toEqual(["pong"]);
24
+ await harness.stop();
25
+ });
@@ -0,0 +1,28 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /**
4
+ * Interactions add slash commands. A subcommand goes under the one root command (`/roundtable`
5
+ * by default); the module answers the interactions Discord sends and returns true for the ones it handled.
6
+ */
7
+ export const ping = definePlugin({
8
+ name: "ping",
9
+ setup: () => ({
10
+ interactions: [
11
+ {
12
+ rootOptions: [
13
+ { type: 1, name: "ping", description: "Check that the bot answers" },
14
+ ],
15
+ module: {
16
+ commands: () => [],
17
+ handle: async (interaction) => {
18
+ if (!interaction.isChatInputCommand()) return false;
19
+ if (interaction.options.getSubcommand(false) !== "ping")
20
+ return false;
21
+ await interaction.reply("pong");
22
+ return true;
23
+ },
24
+ },
25
+ },
26
+ ],
27
+ }),
28
+ });
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { testPlugin } from "pi-roundtable/testing";
4
+ import { visitCounter } from "./migrations.ts";
5
+
6
+ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
7
+
8
+ // The harness gives the plugin your database but does not migrate it: run the migrations first.
9
+ test.skipIf(!url)(
10
+ "the tool counts visits in the plugin's own table",
11
+ async () => {
12
+ const sql = new SQL(url as string);
13
+ try {
14
+ for (const migration of visitCounter.migrations ?? []) {
15
+ await migration.up(sql);
16
+ await migration.up(sql); // Idempotent: the host runs it on every start.
17
+ }
18
+ const harness = await testPlugin(visitCounter, { database: sql });
19
+ expect(await harness.runTool("visit_count", { place: "lab" })).toBe(
20
+ "Visit 1 to lab.",
21
+ );
22
+ expect(await harness.runTool("visit_count", { place: "lab" })).toBe(
23
+ "Visit 2 to lab.",
24
+ );
25
+ await harness.stop();
26
+ } finally {
27
+ await sql`DROP TABLE IF EXISTS visit_counter`;
28
+ await sql.close();
29
+ }
30
+ },
31
+ );
@@ -0,0 +1,42 @@
1
+ import { definePlugin, defineTool } from "pi-roundtable";
2
+ import { Type } from "typebox";
3
+
4
+ /**
5
+ * Migrations create the plugin's tables before any setup runs, on every start, so they must be
6
+ * idempotent. Their names and the tables are shared with every other plugin: prefix them.
7
+ */
8
+ export const visitCounter = definePlugin({
9
+ name: "visit-counter",
10
+ migrations: [
11
+ {
12
+ name: "visit-counter-1-create",
13
+ up: async (sql) => {
14
+ await sql`CREATE TABLE IF NOT EXISTS visit_counter (
15
+ channel text PRIMARY KEY,
16
+ visits integer NOT NULL DEFAULT 0
17
+ )`;
18
+ },
19
+ },
20
+ ],
21
+ setup: (context) => {
22
+ // The host's one connection pool, already migrated.
23
+ const sql = context.database();
24
+ return {
25
+ tools: [
26
+ defineTool({
27
+ name: "visit_count",
28
+ description: "Count a visit to a place and say which visit it is.",
29
+ parameters: Type.Object({ place: Type.String() }),
30
+ minTier: "member",
31
+ run: async ({ place }) => {
32
+ const [row] = await sql`
33
+ INSERT INTO visit_counter (channel, visits) VALUES (${place}, 1)
34
+ ON CONFLICT (channel) DO UPDATE SET visits = visit_counter.visits + 1
35
+ RETURNING visits`;
36
+ return `Visit ${row?.visits} to ${place}.`;
37
+ },
38
+ }),
39
+ ],
40
+ };
41
+ },
42
+ });
@@ -0,0 +1,9 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { webSearch } from "./packages.ts";
4
+
5
+ test("the plugin asks every session to load pi-web-access", async () => {
6
+ const harness = await testPlugin(webSearch);
7
+ expect(harness.contribution.piPackages).toEqual(["pi-web-access"]);
8
+ await harness.stop();
9
+ });
@@ -0,0 +1,9 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** Pi packages are npm packages whose Pi extensions every session loads; install each one in your project first. */
4
+ export const webSearch = definePlugin({
5
+ name: "web-search",
6
+ setup: () => ({
7
+ piPackages: ["pi-web-access"],
8
+ }),
9
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from "bun:test";
2
+ import { needsKey } from "./preflight.ts";
3
+
4
+ test("the preflight passes with the key and throws without it", () => {
5
+ expect(() =>
6
+ needsKey("API_KEY", { API_KEY: "x" }).preflight?.(),
7
+ ).not.toThrow();
8
+ expect(() => needsKey("API_KEY", {}).preflight?.()).toThrow(
9
+ "API_KEY is empty. Set it in .env.",
10
+ );
11
+ });
@@ -0,0 +1,19 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /**
4
+ * A preflight runs once every plugin is set up and linked, before any command is registered or
5
+ * service starts. A throw stops the boot, so a bad setting is caught before Discord connects.
6
+ */
7
+ export function needsKey(
8
+ name: string,
9
+ env: Record<string, string | undefined>,
10
+ ) {
11
+ return definePlugin({
12
+ name: "needs-key",
13
+ preflight: () => {
14
+ if (!env[name]?.trim())
15
+ throw new Error(`${name} is empty. Set it in .env.`);
16
+ },
17
+ setup: () => ({ dashboard: [`Uses ${name}`] }),
18
+ });
19
+ }
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { houseRules } from "./prompt.ts";
4
+
5
+ test("the section names the agent, and the speaker when there is one", async () => {
6
+ const harness = await testPlugin(houseRules);
7
+ const section = harness.contribution.prompt?.[0];
8
+ const scope = {
9
+ name: "guide",
10
+ session: "discord:1",
11
+ home: "discord:1",
12
+ } as const;
13
+ const agent = { name: "guide", displayName: "Guide" };
14
+ expect(section?.build({ agent, speaker: undefined, scope })).toBe(
15
+ "House rules for Guide: answer in the language you were asked in.",
16
+ );
17
+ expect(
18
+ section?.build({
19
+ agent,
20
+ speaker: { id: "1", name: "Ada", tier: "member" },
21
+ scope,
22
+ }),
23
+ ).toContain("You are talking with Ada.");
24
+ await harness.stop();
25
+ });
@@ -0,0 +1,20 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** A prompt section is text added after the core's prompt in every agent turn; return undefined to add nothing. */
4
+ export const houseRules = definePlugin({
5
+ name: "house-rules",
6
+ setup: () => ({
7
+ prompt: [
8
+ {
9
+ name: "house-rules",
10
+ build: ({ agent, speaker }) =>
11
+ [
12
+ `House rules for ${agent.displayName}: answer in the language you were asked in.`,
13
+ speaker ? `You are talking with ${speaker.name}.` : undefined,
14
+ ]
15
+ .filter((line) => line !== undefined)
16
+ .join(" "),
17
+ },
18
+ ],
19
+ }),
20
+ });
@@ -0,0 +1,10 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { pixelAvatars } from "./providers.ts";
4
+
5
+ test("the plugin fills the images slot with PNG bytes", async () => {
6
+ const harness = await testPlugin(pixelAvatars);
7
+ const drawn = await pixelAvatars.providers?.images?.("a fox", []);
8
+ expect([...(drawn?.slice(1, 4) ?? [])]).toEqual([0x50, 0x4e, 0x47]);
9
+ await harness.stop();
10
+ });
@@ -0,0 +1,17 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ // A one-pixel PNG standing in for a real image service.
4
+ const PIXEL = Buffer.from(
5
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
6
+ "base64",
7
+ );
8
+
9
+ /** A provider fills a slot the core otherwise runs on its default; one plugin may fill each slot. */
10
+ export const pixelAvatars = definePlugin({
11
+ name: "pixel-avatars",
12
+ providers: {
13
+ // The images slot draws an agent's avatar from a prompt and reference pictures.
14
+ images: async (_prompt, _references) => new Uint8Array(PIXEL),
15
+ },
16
+ setup: () => ({}),
17
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { library } from "./seeds.ts";
4
+
5
+ test("the plugin seeds one agent with a name, a prompt, and an avatar prompt", async () => {
6
+ const harness = await testPlugin(library);
7
+ expect(harness.contribution.seeds?.map((seed) => seed.name)).toEqual([
8
+ "librarian",
9
+ ]);
10
+ expect(harness.contribution.seeds?.[0]?.avatarPrompt).toContain("librarian");
11
+ await harness.stop();
12
+ });
@@ -0,0 +1,18 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** Seeds are agents created on the first start; an agent already stored is never overwritten, so edit it in Discord afterwards. */
4
+ export const library = definePlugin({
5
+ name: "library",
6
+ setup: () => ({
7
+ seeds: [
8
+ {
9
+ name: "librarian",
10
+ displayName: "Librarian",
11
+ prompt:
12
+ "You keep the team's reading list. Answer briefly and cite what you were given.",
13
+ avatarPrompt:
14
+ "A calm librarian with round glasses and a stack of books",
15
+ },
16
+ ],
17
+ }),
18
+ });
@@ -0,0 +1,13 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { alwaysOn } from "./selection.ts";
4
+
5
+ test("every read of the selection sees the current tools", async () => {
6
+ let tools = ["note_add"];
7
+ const harness = await testPlugin(alwaysOn(() => tools));
8
+ const { agentSelection } = harness.contribution;
9
+ expect(agentSelection?.().tools).toEqual(["note_add"]);
10
+ tools = ["note_add", "file_delete"];
11
+ expect(agentSelection?.().tools).toEqual(["note_add", "file_delete"]);
12
+ await harness.stop();
13
+ });
@@ -0,0 +1,14 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /**
4
+ * The selection names tools every agent turn carries besides its own, read before each turn so
5
+ * a set that changes while the process runs stays current.
6
+ */
7
+ export function alwaysOn(tools: () => string[]) {
8
+ return definePlugin({
9
+ name: "always-on",
10
+ setup: () => ({
11
+ agentSelection: () => ({ tools: tools(), groups: [] }),
12
+ }),
13
+ });
14
+ }
@@ -0,0 +1,20 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { heartbeat } from "./services.ts";
4
+
5
+ test("the timer starts with the harness and stops with it", async () => {
6
+ const first = Promise.withResolvers<void>();
7
+ let beats = 0;
8
+ const harness = await testPlugin(
9
+ heartbeat(1, () => {
10
+ beats++;
11
+ first.resolve();
12
+ }),
13
+ );
14
+ await first.promise;
15
+ await harness.stop();
16
+ const stoppedAt = beats;
17
+ await Bun.sleep(20);
18
+ expect(beats).toBe(stoppedAt);
19
+ expect(harness.contribution.services?.[0]?.busy?.()).toEqual([]);
20
+ });
@@ -0,0 +1,30 @@
1
+ import { definePlugin } from "pi-roundtable";
2
+
3
+ /** A service is a long-lived part: it starts once everything is set up and stops in reverse order at shutdown. */
4
+ export function heartbeat(everyMs: number, beat: () => Promise<void> | void) {
5
+ let timer: ReturnType<typeof setInterval> | undefined;
6
+ let running = 0;
7
+ return definePlugin({
8
+ name: "heartbeat",
9
+ setup: () => ({
10
+ services: [
11
+ {
12
+ name: "heartbeat-timer",
13
+ start: () => {
14
+ timer = setInterval(async () => {
15
+ running++;
16
+ try {
17
+ await beat();
18
+ } finally {
19
+ running--;
20
+ }
21
+ }, everyMs);
22
+ },
23
+ stop: () => clearInterval(timer),
24
+ // Shutdown waits until this list is empty, so a beat is never cut off.
25
+ busy: () => (running > 0 ? ["a heartbeat is running"] : []),
26
+ },
27
+ ],
28
+ }),
29
+ });
30
+ }
@@ -0,0 +1,24 @@
1
+ import { expect, test } from "bun:test";
2
+ import { testPlugin } from "pi-roundtable/testing";
3
+ import { clock } from "./session-tools.ts";
4
+
5
+ test("the factory registers clock_now in a session", async () => {
6
+ const harness = await testPlugin(clock);
7
+ const [tool] = harness.contribution.sessionTools ?? [];
8
+ const registered: string[] = [];
9
+ const session = {
10
+ kind: "agent",
11
+ homeChannel: "test:1",
12
+ turnChannel: "test:1",
13
+ compaction: { wrap: (factory: unknown) => factory },
14
+ speaker: () => undefined,
15
+ runTask: async () => "",
16
+ } as never;
17
+ // A stand-in for Pi's extension API: it keeps the names registered.
18
+ const pi = {
19
+ registerTool: ({ name }: { name: string }) => registered.push(name),
20
+ };
21
+ tool?.snapshot().factory(session)?.(pi as never);
22
+ expect(registered).toEqual(["clock_now"]);
23
+ await harness.stop();
24
+ });