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,237 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SQL } from "bun";
3
+ import { MigrationError, PluginError } from "../errors.ts";
4
+ import { Roundtable } from "../host.ts";
5
+ import { silentLogger } from "../log.ts";
6
+ import type { RoundtablePlugin } from "../plugin.ts";
7
+ import { type Migration, migrate, openPool } from "./migrations.ts";
8
+
9
+ // The core cannot import a consumer's test helpers, so it reads the test database itself.
10
+ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
11
+ const describeDb = url ? describe : describe.skip;
12
+
13
+ /** What the promise rejected with; undefined when it resolved. */
14
+ async function rejection(
15
+ promise: Promise<unknown> | undefined,
16
+ ): Promise<unknown> {
17
+ try {
18
+ await promise;
19
+ } catch (error) {
20
+ return error;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function recording(name: string, log: string[], fails = false): Migration {
26
+ return {
27
+ name,
28
+ up: async () => {
29
+ log.push(name);
30
+ if (fails) throw new Error("boom");
31
+ },
32
+ };
33
+ }
34
+
35
+ function roundtable(
36
+ plugins: RoundtablePlugin[],
37
+ database?: { url: string },
38
+ ): { host: Roundtable; exits: number[] } {
39
+ const exits: number[] = [];
40
+ const host = new Roundtable(
41
+ {
42
+ logger: silentLogger(),
43
+ drain: { intervalMs: 1, limitMs: 50 },
44
+ exit: (code) => exits.push(code),
45
+ ...(database ? { database } : {}),
46
+ },
47
+ plugins,
48
+ );
49
+ return { host, exits };
50
+ }
51
+
52
+ describe("migrate", () => {
53
+ // A pool connects on its first query, so these never reach a server.
54
+ const unreachable = () => openPool("postgres://nobody@127.0.0.1:1/none");
55
+
56
+ test("runs the migrations in order", async () => {
57
+ const log: string[] = [];
58
+ const sql = unreachable();
59
+ await migrate(sql, [recording("a", log), recording("b", log)]);
60
+ await sql.close();
61
+ expect(log).toEqual(["a", "b"]);
62
+ });
63
+
64
+ test("refuses a name declared twice before running any", async () => {
65
+ const log: string[] = [];
66
+ const sql = unreachable();
67
+ const error = await rejection(
68
+ migrate(sql, [recording("a", log), recording("a", log)]),
69
+ );
70
+ await sql.close();
71
+ expect(error).toBeInstanceOf(PluginError);
72
+ expect(log).toEqual([]);
73
+ });
74
+
75
+ test("stops at a failing migration and names it", async () => {
76
+ const log: string[] = [];
77
+ const sql = unreachable();
78
+ const error = await rejection(
79
+ migrate(sql, [recording("a", log, true), recording("b", log)]),
80
+ );
81
+ await sql.close();
82
+ expect(error).toBeInstanceOf(MigrationError);
83
+ expect(String(error)).toContain("migration a failed");
84
+ expect(log).toEqual(["a"]);
85
+ });
86
+ });
87
+
88
+ describe("Roundtable migrations", () => {
89
+ test("refuses migrations without a configured database, before any setup", async () => {
90
+ const log: string[] = [];
91
+ const { host } = roundtable([
92
+ {
93
+ name: "stores",
94
+ migrations: [recording("a", log)],
95
+ setup: () => {
96
+ log.push("setup");
97
+ return {};
98
+ },
99
+ },
100
+ ]);
101
+ expect(await rejection(host.run())).toBeInstanceOf(PluginError);
102
+ expect(log).toEqual([]);
103
+ });
104
+
105
+ test("without a database, reading it during setup is a plugin error", async () => {
106
+ let error: unknown;
107
+ const { host } = roundtable([
108
+ {
109
+ name: "reader",
110
+ setup: ({ database }) => {
111
+ try {
112
+ database();
113
+ } catch (e) {
114
+ error = e;
115
+ }
116
+ return { events: {} };
117
+ },
118
+ },
119
+ ]);
120
+ await host.run();
121
+ expect(error).toBeInstanceOf(PluginError);
122
+ });
123
+ });
124
+
125
+ describeDb("PostgreSQL", () => {
126
+ const table = "roundtable_migration_test";
127
+ const tableMigration: Migration = {
128
+ name: "test-table",
129
+ up: async (sql) => {
130
+ await sql.unsafe(
131
+ `CREATE TABLE IF NOT EXISTS ${table} (id int PRIMARY KEY, note text NOT NULL)`,
132
+ );
133
+ await sql.unsafe(
134
+ `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS added text NOT NULL DEFAULT 'x'`,
135
+ );
136
+ },
137
+ };
138
+
139
+ async function reset(): Promise<void> {
140
+ const admin = openPool(url ?? "");
141
+ await admin.unsafe(`DROP TABLE IF EXISTS ${table}`);
142
+ await admin.close();
143
+ }
144
+
145
+ test("migrates every plugin in order before any setup, then closes the pool last", async () => {
146
+ await reset();
147
+ const log: string[] = [];
148
+ let pool: SQL | undefined;
149
+ const { host, exits } = roundtable(
150
+ [
151
+ {
152
+ name: "first",
153
+ migrations: [recording("first", log), tableMigration],
154
+ setup: ({ database }) => {
155
+ log.push("setup first");
156
+ pool = database();
157
+ return {
158
+ services: [{ name: "uses", stop: () => void log.push("stop") }],
159
+ };
160
+ },
161
+ },
162
+ {
163
+ name: "second",
164
+ migrations: [recording("second", log)],
165
+ setup: () => {
166
+ log.push("setup second");
167
+ return {};
168
+ },
169
+ },
170
+ ],
171
+ { url: url ?? "" },
172
+ );
173
+ await host.run();
174
+ expect(log).toEqual(["first", "second", "setup first", "setup second"]);
175
+ await pool?.unsafe(`INSERT INTO ${table} (id, note) VALUES (1, 'kept')`);
176
+ await host.shutdown("SIGTERM");
177
+ expect(log.at(-1)).toBe("stop");
178
+ expect(exits).toEqual([0]);
179
+ // The pool closed after the services stopped.
180
+ expect(await rejection(pool?.unsafe("SELECT 1"))).toBeInstanceOf(Error);
181
+ });
182
+
183
+ test("a second boot over the existing schema keeps its rows", async () => {
184
+ const read: { id: number; note: string; added: string }[][] = [];
185
+ for (let boot = 0; boot < 2; boot++) {
186
+ const { host } = roundtable(
187
+ [
188
+ {
189
+ name: "stores",
190
+ migrations: [tableMigration],
191
+ setup: async ({ database }) => {
192
+ read.push(
193
+ await database().unsafe(
194
+ `SELECT id, note, added FROM ${table} ORDER BY id`,
195
+ ),
196
+ );
197
+ return {};
198
+ },
199
+ },
200
+ ],
201
+ { url: url ?? "" },
202
+ );
203
+ await host.run();
204
+ await host.shutdown("SIGTERM");
205
+ }
206
+ expect(read).toEqual([
207
+ [{ id: 1, note: "kept", added: "x" }],
208
+ [{ id: 1, note: "kept", added: "x" }],
209
+ ]);
210
+ await reset();
211
+ });
212
+
213
+ test("a failing migration stops the boot before any setup", async () => {
214
+ const log: string[] = [];
215
+ const { host } = roundtable(
216
+ [
217
+ {
218
+ name: "broken",
219
+ migrations: [recording("broken", log, true)],
220
+ setup: () => {
221
+ log.push("setup");
222
+ return {};
223
+ },
224
+ },
225
+ ],
226
+ { url: url ?? "" },
227
+ );
228
+ const error = await rejection(host.run());
229
+ expect(error).toBeInstanceOf(PluginError);
230
+ expect(String(error)).toContain("plugin broken: migration broken failed: ");
231
+ expect(String(error)).toContain(
232
+ "Fix the migration or restore the database, then start again.",
233
+ );
234
+ expect((error as Error).cause).toBeInstanceOf(MigrationError);
235
+ expect(log).toEqual(["broken"]);
236
+ });
237
+ });
@@ -0,0 +1,37 @@
1
+ import { SQL } from "bun";
2
+ import { MigrationError, PluginError } from "../errors.ts";
3
+
4
+ /** Idempotent DDL a plugin's tables need; every boot runs it again over the existing schema. */
5
+ export interface Migration {
6
+ name: string;
7
+ up(sql: SQL): Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * The host's one connection pool. PostgreSQL's 100 connections are shared with the other services on the same server,
12
+ * so the pool stays small and closes idle connections; Bun's defaults (10, never closed)
13
+ * exhausted the server when every store kept its own.
14
+ */
15
+ export function openPool(databaseUrl: string): SQL {
16
+ return new SQL(databaseUrl, { max: 10, idleTimeout: 60 });
17
+ }
18
+
19
+ /** Runs the migrations in order; a repeated name is a plugin bug, a failing one stops the boot. */
20
+ export async function migrate(
21
+ sql: SQL,
22
+ migrations: readonly Migration[],
23
+ ): Promise<void> {
24
+ const names = new Set<string>();
25
+ for (const { name } of migrations) {
26
+ if (names.has(name))
27
+ throw new PluginError(`migration ${name} is declared twice`);
28
+ names.add(name);
29
+ }
30
+ for (const migration of migrations) {
31
+ try {
32
+ await migration.up(sql);
33
+ } catch (error) {
34
+ throw new MigrationError(migration.name, error);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { RoundtableConfig } from "./config/config.ts";
6
+ import { defineRoundtable } from "./define-roundtable.ts";
7
+ import { ConfigError } from "./domain/errors.ts";
8
+ import { messages } from "./i18n/index.ts";
9
+ import { createZhTW } from "./i18n/zh-tw.ts";
10
+ import type { RoundtablePlugin } from "./plugin.ts";
11
+ import { useTestLocale } from "./testing/locale.ts";
12
+
13
+ const dataDir = mkdtempSync(join(tmpdir(), "roundtable-define-"));
14
+
15
+ const config: RoundtableConfig = {
16
+ owner: { id: "100000000000000001", name: "Ada" },
17
+ discord: {
18
+ token: "token",
19
+ guild: "900000000000000001",
20
+ entryChannel: "900000000000000002",
21
+ },
22
+ database: { url: "postgres://localhost/roundtable" },
23
+ dataDir,
24
+ model: "anthropic/claude-sonnet-5-5",
25
+ http: { publicUrl: "https://bot.example.com", port: 3456 },
26
+ };
27
+
28
+ const mine: RoundtablePlugin = {
29
+ name: "mine",
30
+ setup: () => ({ dashboard: ["a line"] }),
31
+ };
32
+
33
+ afterEach(() => {
34
+ // Other suites read the text through the catalog; leave it as the tests expect.
35
+ useTestLocale();
36
+ });
37
+
38
+ describe("defineRoundtable", () => {
39
+ test("returns the built-in plugins in their fixed order, the operator's, then the scheduler", async () => {
40
+ const { plugins } = await defineRoundtable({ ...config, plugins: [mine] });
41
+ expect(plugins.map((plugin) => plugin.name)).toEqual([
42
+ "stores",
43
+ "discord",
44
+ "modules",
45
+ "agent-server",
46
+ "seeds",
47
+ "mine",
48
+ "schedules",
49
+ ]);
50
+ });
51
+
52
+ test("migrates the core's tables first, in a fixed order", async () => {
53
+ const { plugins } = await defineRoundtable(config);
54
+ expect(
55
+ plugins.flatMap((plugin) => (plugin.migrations ?? []).map((m) => m.name)),
56
+ ).toEqual([
57
+ "owner-memory",
58
+ "owner-memory-speaker",
59
+ "schedules",
60
+ "pending-confirmations",
61
+ "agents",
62
+ "agents-guild",
63
+ "skills",
64
+ "skills-guild",
65
+ "skills-written-kind",
66
+ ]);
67
+ });
68
+
69
+ test("the host options carry the database, the root command, the tool tiers, and one listener", async () => {
70
+ const { options } = await defineRoundtable({
71
+ ...config,
72
+ name: "Robin",
73
+ toolTiers: { shell: "admin" },
74
+ });
75
+ expect(options.database).toEqual({ url: config.database.url });
76
+ expect(options.commands?.root.name).toBe("robin");
77
+ expect(options.listeners).toEqual([{ id: "public", port: 3456 }]);
78
+ expect(options.toolTiers?.minTier("shell")).toBe("admin");
79
+ });
80
+
81
+ test("the locale and the assistant's name reach the text the bot shows", async () => {
82
+ await defineRoundtable({ ...config, name: "Robin", locale: "zh-TW" });
83
+ expect(messages().ownerRootDescription).toContain("Robin");
84
+ expect(messages().stopLabel).toBe(
85
+ createZhTW({ assistant: "Robin", root: "robin" }).stopLabel,
86
+ );
87
+ });
88
+
89
+ test("a mistake in the configuration stops before anything is built, naming the key", async () => {
90
+ await expect(
91
+ defineRoundtable({
92
+ ...config,
93
+ discord: { ...config.discord, guilld: "1" },
94
+ } as RoundtableConfig),
95
+ ).rejects.toThrow(
96
+ 'config discord.guilld: unknown key. Did you mean "guild"?',
97
+ );
98
+ await expect(
99
+ defineRoundtable({ ...config, model: "sonnet" }),
100
+ ).rejects.toBeInstanceOf(ConfigError);
101
+ });
102
+
103
+ test("reads the prompt files named in the configuration, and refuses a missing or empty one", async () => {
104
+ const dir = join(dataDir, "prompts");
105
+ mkdirSync(dir, { recursive: true });
106
+ writeFileSync(join(dir, "shared.md"), "You are Robin.\n");
107
+ writeFileSync(join(dir, "empty.md"), " \n");
108
+ await defineRoundtable({
109
+ ...config,
110
+ prompts: { shared: join(dir, "shared.md") },
111
+ });
112
+ await expect(
113
+ defineRoundtable({
114
+ ...config,
115
+ prompts: { shared: join(dir, "missing.md") },
116
+ }),
117
+ ).rejects.toThrow("config prompts.shared: cannot read");
118
+ await expect(
119
+ defineRoundtable({
120
+ ...config,
121
+ prompts: { shared: join(dir, "empty.md") },
122
+ }),
123
+ ).rejects.toThrow(
124
+ `config prompts.shared: ${join(dir, "empty.md")} is empty`,
125
+ );
126
+ });
127
+
128
+ test("the judge answers with a clear error until the model is available", async () => {
129
+ const { options } = await defineRoundtable(config);
130
+ await expect(options.judgeModel?.("system", "question")).rejects.toThrow(
131
+ "the judge's model anthropic/claude-sonnet-5-5 is not available",
132
+ );
133
+ });
134
+ });
@@ -0,0 +1,195 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { userInfo } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type AgentServerOptions,
7
+ agentServerPlugin,
8
+ } from "./builtin/agent-server.ts";
9
+ import { discordPlugin } from "./builtin/discord.ts";
10
+ import {
11
+ type ModulesOptions,
12
+ modulesPlugin,
13
+ schedulerPlugin,
14
+ } from "./builtin/modules.ts";
15
+ import { seedsPlugin } from "./builtin/seeds.ts";
16
+ import { storesPlugin } from "./builtin/stores.ts";
17
+ import { type RoundtableConfig, resolveConfig } from "./config/config.ts";
18
+ import { ownerRootCommand } from "./discord/owner-command.ts";
19
+ import { ConfigError } from "./domain/errors.ts";
20
+ import { JudgeError } from "./errors.ts";
21
+ import type { RoundtableOptions } from "./host.ts";
22
+ import { setLocale } from "./i18n/index.ts";
23
+ import { type JudgeModel, piJudgeModel } from "./judging/model-judge.ts";
24
+ import { createLogger, type Logger } from "./log.ts";
25
+ import { formatModelRef, type ModelRef } from "./models.ts";
26
+ import { ErrorReporter } from "./ops/error-reporter.ts";
27
+ import type { RoundtablePlugin } from "./plugin.ts";
28
+ import { speakerPolicy } from "./speakers.ts";
29
+ import { setTimeZone } from "./time.ts";
30
+ import { toolTiers } from "./tool-tiers.ts";
31
+
32
+ /** What `defineRoundtable` returns: give both to `new Roundtable(options, plugins)`. */
33
+ export interface DefinedRoundtable {
34
+ options: RoundtableOptions;
35
+ plugins: RoundtablePlugin[];
36
+ }
37
+
38
+ /** Replaceable parts of the assembly, for a host that shares them with its own plugins. */
39
+ export interface DefineOverrides {
40
+ /** The model login the process shares; by default one is opened in the agent directory. */
41
+ modelRuntime?: ModelRuntime;
42
+ /** Your own logger and error reporter, when the process already has them. */
43
+ logger?: Logger;
44
+ errorReporter?: ErrorReporter;
45
+ /** Host options merged over the built ones, such as more listeners or the record of aborted work. */
46
+ options?: Partial<RoundtableOptions>;
47
+ /** What only the plugins after the built-ins can know. */
48
+ modules?: Pick<ModulesOptions, "agentChannelOf">;
49
+ agentServer?: Pick<AgentServerOptions, "ownerSessions">;
50
+ }
51
+
52
+ const ASSETS = join(import.meta.dir, "assets");
53
+
54
+ function readPrompt(path: string, key: string): string {
55
+ let text: string;
56
+ try {
57
+ text = readFileSync(path, "utf8").trim();
58
+ } catch (error) {
59
+ throw new ConfigError(
60
+ `config ${key}: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}. Create the file or fix the path.`,
61
+ );
62
+ }
63
+ if (text === "")
64
+ throw new ConfigError(
65
+ `config ${key}: ${path} is empty. Write the prompt in it.`,
66
+ );
67
+ return text;
68
+ }
69
+
70
+ /** The judge's completion through the shared login, resolving the model when first asked. */
71
+ function judgeThrough(modelRuntime: ModelRuntime, ref: ModelRef): JudgeModel {
72
+ const registry = new ModelRegistry(modelRuntime);
73
+ return async (system, prompt) => {
74
+ const model = registry.find(ref.provider, ref.id);
75
+ if (!model)
76
+ throw new JudgeError(
77
+ `the judge's model ${formatModelRef(ref)} is not available; log in to its provider or change judge.model`,
78
+ );
79
+ return await piJudgeModel(modelRuntime, model)(system, prompt);
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The host's options and the plugin list of a configured bot: the built-in plugins in their
85
+ * fixed order, then the operator's, then the scheduler, so a due schedule fires only once
86
+ * everything it reaches runs. Configuration mistakes stop here, naming the key and the fix.
87
+ */
88
+ export async function defineRoundtable(
89
+ input: RoundtableConfig,
90
+ overrides: DefineOverrides = {},
91
+ ): Promise<DefinedRoundtable> {
92
+ const config = resolveConfig(input);
93
+ const { name, owner, discord } = config;
94
+ setLocale(config.locale, { assistant: name, root: discord.rootCommand });
95
+ setTimeZone(config.timeZone);
96
+ // Pi packages such as pi-web-access read their config from the Pi agent directory.
97
+ process.env.PI_CODING_AGENT_DIR = config.agentDir;
98
+ const modelRuntime =
99
+ overrides.modelRuntime ??
100
+ (await ModelRuntime.create({
101
+ authPath: join(config.agentDir, "auth.json"),
102
+ }));
103
+ const errorReporter =
104
+ overrides.errorReporter ??
105
+ (config.ops
106
+ ? new ErrorReporter({ opsAgent: config.ops.agent, app: name })
107
+ : undefined);
108
+ const logger =
109
+ overrides.logger ??
110
+ createLogger(
111
+ discord.rootCommand,
112
+ errorReporter ? (entry) => errorReporter.record(entry) : undefined,
113
+ );
114
+ const speakers = speakerPolicy({
115
+ owners: [owner.id],
116
+ ...(config.speakers.admins ? { admins: config.speakers.admins } : {}),
117
+ ...(config.speakers.members ? { members: config.speakers.members } : {}),
118
+ });
119
+ const shared = config.prompts.shared
120
+ ? readPrompt(config.prompts.shared, "prompts.shared")
121
+ : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
122
+ const guest = config.prompts.guest
123
+ ? readPrompt(config.prompts.guest, "prompts.guest")
124
+ : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
125
+ return {
126
+ options: {
127
+ logger,
128
+ database: { url: config.databaseUrl },
129
+ toolTiers: toolTiers(config.toolTiers),
130
+ commands: { root: ownerRootCommand(discord.rootCommand) },
131
+ listeners: [
132
+ config.http.socketPath
133
+ ? { id: "public", socketPath: config.http.socketPath }
134
+ : {
135
+ id: "public",
136
+ port: config.http.port,
137
+ ...(config.http.hostname
138
+ ? { hostname: config.http.hostname }
139
+ : {}),
140
+ },
141
+ ],
142
+ judgeModel: judgeThrough(modelRuntime, config.judge.model),
143
+ ...overrides.options,
144
+ },
145
+ plugins: [
146
+ storesPlugin({ ownerId: owner.id, guildId: discord.guild }),
147
+ discordPlugin({
148
+ token: discord.token,
149
+ ownerId: owner.id,
150
+ ownerName: owner.name,
151
+ speakers,
152
+ rootCommand: discord.rootCommand,
153
+ dataDir: config.dataDir,
154
+ avatarUrl: config.http.publicUrl,
155
+ avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
156
+ }),
157
+ modulesPlugin({
158
+ owner,
159
+ assistant: name,
160
+ modelRuntime,
161
+ agentDir: config.agentDir,
162
+ dataDir: config.dataDir,
163
+ delegation: config.delegation,
164
+ ...overrides.modules,
165
+ }),
166
+ agentServerPlugin({
167
+ guildId: discord.guild,
168
+ entryChannelId: discord.entryChannel,
169
+ owner,
170
+ assistant: name,
171
+ speakers,
172
+ modelRuntime,
173
+ agentDir: config.agentDir,
174
+ dataDir: config.dataDir,
175
+ model: config.model,
176
+ thinking: config.thinking,
177
+ judgeThreshold: config.judge.threshold,
178
+ workDir: config.workDir,
179
+ shellUser: userInfo().username,
180
+ prompts: { shared, guest },
181
+ skills: {
182
+ reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
183
+ writtenDir: join(config.dataDir, "skills"),
184
+ builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
185
+ },
186
+ avatarListener: "public",
187
+ ...(errorReporter ? { errorReporter } : {}),
188
+ ...overrides.agentServer,
189
+ }),
190
+ seedsPlugin(config.agents),
191
+ ...config.plugins,
192
+ schedulerPlugin(),
193
+ ],
194
+ };
195
+ }