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,117 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { Migration } from "../../core/db/migrations.ts";
3
+ import { MigrationError } from "../../core/errors.ts";
4
+ import { describeDb, testDatabaseUrl } from "../../core/testing/database.ts";
5
+ import { Project } from "../project.ts";
6
+ import { fakePorts, validConfig } from "../testing/fixtures.ts";
7
+ import { checkDatabase, type DatabasePort, postgres } from "./database.ts";
8
+
9
+ const migration: Migration = { name: "doctor-probe", up: async () => {} };
10
+ const withMigration = (name = "probe") =>
11
+ new Project(
12
+ "/x",
13
+ fakePorts(validConfig, {
14
+ define: async () => ({
15
+ options: { logger: {} as never },
16
+ plugins: [{ name, setup: () => ({}), migrations: [migration] }],
17
+ }),
18
+ }),
19
+ );
20
+
21
+ test("passes when the database answers and the migrations run, without printing the password", async () => {
22
+ const asked: unknown[] = [];
23
+ const port: DatabasePort = {
24
+ check: async (url, migrations) => void asked.push([url, migrations]),
25
+ };
26
+ const result = await checkDatabase(withMigration(), port);
27
+ expect(result.status).toBe("ok");
28
+ if (result.status === "ok") {
29
+ expect(result.detail).toContain("db.example.test:5432/bot");
30
+ expect(result.detail).not.toContain("secret");
31
+ }
32
+ expect(asked).toEqual([[validConfig.database.url, [migration]]]);
33
+ });
34
+
35
+ test("fails when the database is unreachable, naming it and saying how to start one", async () => {
36
+ const port: DatabasePort = {
37
+ check: async () => {
38
+ throw new Error("connection refused");
39
+ },
40
+ };
41
+ const result = await checkDatabase(withMigration(), port);
42
+ expect(result.status).toBe("fail");
43
+ if (result.status === "fail") {
44
+ expect(result.problem).toContain("db.example.test:5432/bot");
45
+ expect(result.problem).toContain("connection refused");
46
+ expect(result.problem).not.toContain("secret");
47
+ expect(result.fix).toContain("docker compose up -d");
48
+ }
49
+ });
50
+
51
+ test("fails when a migration fails, naming it", async () => {
52
+ const port: DatabasePort = {
53
+ check: async () => {
54
+ throw new MigrationError("owner-memory", new Error("permission denied"));
55
+ },
56
+ };
57
+ const result = await checkDatabase(withMigration(), port);
58
+ expect(result.status).toBe("fail");
59
+ if (result.status === "fail") {
60
+ expect(result.problem).toContain("migration owner-memory failed");
61
+ expect(result.problem).toContain("permission denied");
62
+ }
63
+ });
64
+
65
+ test("still tests reachability from the raw url while the configuration is invalid, and skips without a url", async () => {
66
+ const asked: unknown[] = [];
67
+ const port: DatabasePort = {
68
+ check: async (url, migrations) => void asked.push([url, migrations]),
69
+ };
70
+ const invalid = new Project(
71
+ "/x",
72
+ fakePorts({ database: { url: "postgres://h/d" } }),
73
+ );
74
+ const result = await checkDatabase(invalid, port);
75
+ expect(result.status).toBe("ok");
76
+ expect(asked).toEqual([["postgres://h/d", undefined]]);
77
+ const none = await checkDatabase(new Project("/x", fakePorts({})), port);
78
+ expect(none.status).toBe("skipped");
79
+ });
80
+
81
+ describeDb("against PostgreSQL", () => {
82
+ test("runs the migrations inside a rolled-back transaction, including ones that open their own", async () => {
83
+ const table = `doctor_probe_${Date.now()}`;
84
+ const run: Migration = {
85
+ name: "creates",
86
+ up: async (sql) => {
87
+ await sql`create table ${sql(table)} (id int)`;
88
+ await sql.begin(async (inner) => {
89
+ await inner`insert into ${inner(table)} values (1)`;
90
+ });
91
+ },
92
+ };
93
+ await postgres.check(testDatabaseUrl, [run]);
94
+ const pool = new Bun.SQL(testDatabaseUrl, { max: 1 });
95
+ try {
96
+ const [found] = await pool`select to_regclass(${table}) as name`;
97
+ expect(found?.name).toBeNull();
98
+ } finally {
99
+ await pool.close();
100
+ }
101
+ });
102
+
103
+ test("reports a failing migration as a MigrationError and an unreachable server as an error", async () => {
104
+ const broken: Migration = {
105
+ name: "broken",
106
+ up: async (sql) => {
107
+ await sql`select * from no_such_table_anywhere`;
108
+ },
109
+ };
110
+ await expect(
111
+ postgres.check(testDatabaseUrl, [broken]),
112
+ ).rejects.toBeInstanceOf(MigrationError);
113
+ await expect(
114
+ postgres.check("postgres://nobody@127.0.0.1:1/none", undefined),
115
+ ).rejects.toThrow();
116
+ });
117
+ });
@@ -0,0 +1,100 @@
1
+ import type { SQL, TransactionSQL } from "bun";
2
+ import type { Migration } from "../../core/db/migrations.ts";
3
+ import { migrate, openPool } from "../../core/db/migrations.ts";
4
+ import { MigrationError } from "../../core/errors.ts";
5
+ import type { Project } from "../project.ts";
6
+ import { fail, ok, type Result, skipped } from "../report.ts";
7
+
8
+ /** Asks PostgreSQL whether it answers and, given migrations, whether they run. */
9
+ export interface DatabasePort {
10
+ check(
11
+ url: string,
12
+ migrations: readonly Migration[] | undefined,
13
+ ): Promise<void>;
14
+ }
15
+
16
+ class Rollback extends Error {}
17
+
18
+ /**
19
+ * The transaction as a migration sees the pool: a migration that opens its own transaction
20
+ * with `begin` gets a savepoint of the checking one, which PostgreSQL nests.
21
+ */
22
+ function nested(transaction: TransactionSQL): SQL {
23
+ return new Proxy(transaction, {
24
+ get(target, property) {
25
+ if (property === "begin")
26
+ return (...args: Parameters<TransactionSQL["savepoint"]>) =>
27
+ target.savepoint(...args);
28
+ const value: unknown = Reflect.get(target, property);
29
+ return typeof value === "function" ? value.bind(target) : value;
30
+ },
31
+ });
32
+ }
33
+
34
+ /**
35
+ * The real port. The migrations run inside a transaction that is always rolled back: DDL in
36
+ * PostgreSQL is transactional, so what the check proves it runs is exactly what the host runs,
37
+ * and the database is left as it was.
38
+ */
39
+ export const postgres: DatabasePort = {
40
+ async check(url, migrations) {
41
+ const pool = openPool(url);
42
+ try {
43
+ await pool`select 1`;
44
+ if (!migrations) return;
45
+ try {
46
+ await pool.begin(async (transaction) => {
47
+ await migrate(nested(transaction), migrations);
48
+ throw new Rollback();
49
+ });
50
+ } catch (error) {
51
+ if (!(error instanceof Rollback)) throw error;
52
+ }
53
+ } finally {
54
+ await pool.close();
55
+ }
56
+ },
57
+ };
58
+
59
+ /** The address without its password, safe to print. */
60
+ function where(url: string): string {
61
+ try {
62
+ const parsed = new URL(url);
63
+ return `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}${parsed.pathname}`;
64
+ } catch {
65
+ return "the configured database";
66
+ }
67
+ }
68
+
69
+ /** PostgreSQL reachable, and every plugin's migrations able to run on it. */
70
+ export async function checkDatabase(
71
+ project: Project,
72
+ port: DatabasePort,
73
+ ): Promise<Result> {
74
+ const url = await project.text("database", "url");
75
+ if (!url) return skipped("database.url has no value");
76
+ const assembled = await project.assembled();
77
+ const migrations = assembled.ok
78
+ ? assembled.value.defined.plugins.flatMap(
79
+ (plugin) => plugin.migrations ?? [],
80
+ )
81
+ : undefined;
82
+ try {
83
+ await port.check(url, migrations);
84
+ } catch (error) {
85
+ if (error instanceof MigrationError)
86
+ return fail(
87
+ `migration ${error.migration} failed: ${String(error.cause)}`,
88
+ "Fix the migration, or restore the database to the state it expects.",
89
+ );
90
+ return fail(
91
+ `cannot use ${where(url)}: ${error instanceof Error ? error.message : String(error)}`,
92
+ "Start PostgreSQL (`docker compose up -d` in this project) or correct DATABASE_URL in .env.",
93
+ );
94
+ }
95
+ return ok(
96
+ migrations
97
+ ? `${where(url)} answers and the migrations run`
98
+ : `${where(url)} answers; migrations are checked once the configuration is valid`,
99
+ );
100
+ }
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PermissionFlagsBits } from "discord.js";
3
+ import { INVITE_PERMISSIONS } from "../discord-api.ts";
4
+ import { Project } from "../project.ts";
5
+ import type { Result } from "../report.ts";
6
+ import {
7
+ BOT_ID,
8
+ fakeHttp,
9
+ fakePorts,
10
+ healthyDiscord,
11
+ validConfig,
12
+ } from "../testing/fixtures.ts";
13
+ import {
14
+ checkChannel,
15
+ checkGuild,
16
+ checkIntents,
17
+ checkToken,
18
+ Discord,
19
+ } from "./discord.ts";
20
+
21
+ const guild = validConfig.discord.guild;
22
+ const channel = validConfig.discord.entryChannel;
23
+ const project = (config: unknown = validConfig) =>
24
+ new Project("/x", fakePorts(config));
25
+
26
+ function failure(result: Result): string {
27
+ if (result.status !== "fail")
28
+ throw new Error(`expected a failure, got ${JSON.stringify(result)}`);
29
+ return `${result.problem}\n${result.fix}`;
30
+ }
31
+
32
+ const all = INVITE_PERMISSIONS;
33
+ const bit = (name: keyof typeof PermissionFlagsBits): bigint =>
34
+ PermissionFlagsBits[name];
35
+ const without = (name: keyof typeof PermissionFlagsBits): bigint =>
36
+ all & ~bit(name);
37
+
38
+ describe("Discord token", () => {
39
+ test("passes and names the bot", async () => {
40
+ const http = fakeHttp(healthyDiscord(all));
41
+ const result = await checkToken(new Discord(project(), http));
42
+ expect(result).toEqual({ status: "ok", detail: "the bot is Bot" });
43
+ });
44
+ test("fails on a rejected token with where to reset it", async () => {
45
+ const http = fakeHttp({ "/users/@me": { status: 401, body: {} } });
46
+ const text = failure(await checkToken(new Discord(project(), http)));
47
+ expect(text).toContain("rejected the bot token");
48
+ expect(text).toContain("DISCORD_TOKEN");
49
+ expect(text).toContain("discord.com/developers");
50
+ });
51
+ test("fails when Discord cannot be reached, without a stack trace", async () => {
52
+ const http = fakeHttp({ "/users/@me": new Error("getaddrinfo ENOTFOUND") });
53
+ const text = failure(await checkToken(new Discord(project(), http)));
54
+ expect(text).toContain("cannot reach discord.com: getaddrinfo ENOTFOUND");
55
+ expect(text).toContain("network");
56
+ });
57
+ test("skips without a token, and sends the token as the bot's authorization", async () => {
58
+ const none = project({
59
+ ...validConfig,
60
+ discord: { ...validConfig.discord, token: "" },
61
+ });
62
+ expect((await checkToken(new Discord(none, fakeHttp({})))).status).toBe(
63
+ "skipped",
64
+ );
65
+ let seen: Record<string, string> | undefined;
66
+ await checkToken(
67
+ new Discord(project(), {
68
+ get: async (_url, headers) => {
69
+ seen = headers;
70
+ return { status: 200, body: { id: BOT_ID } };
71
+ },
72
+ }),
73
+ );
74
+ expect(seen).toEqual({ Authorization: "Bot bot-token" });
75
+ });
76
+ test("asks Discord once for who the bot is however many checks need it", async () => {
77
+ const http = fakeHttp(healthyDiscord(all));
78
+ const discord = new Discord(project(), http);
79
+ await checkToken(discord);
80
+ await checkGuild(project(), discord);
81
+ await checkChannel(project(), discord);
82
+ expect(http.requests.filter((path) => path === "/users/@me")).toHaveLength(
83
+ 1,
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("Discord guild", () => {
89
+ test("passes when the bot is in the guild", async () => {
90
+ const http = fakeHttp(healthyDiscord(all));
91
+ const result = await checkGuild(project(), new Discord(project(), http));
92
+ expect(result).toEqual({
93
+ status: "ok",
94
+ detail: "the bot is in Test Guild",
95
+ });
96
+ });
97
+ test("fails when it is not, with an invitation that asks for what the bot needs", async () => {
98
+ const http = fakeHttp({
99
+ ...healthyDiscord(all),
100
+ [`/guilds/${guild}`]: { status: 404, body: { code: 10004 } },
101
+ });
102
+ const text = failure(
103
+ await checkGuild(project(), new Discord(project(), http)),
104
+ );
105
+ expect(text).toContain(`not in the guild ${guild}`);
106
+ expect(text).toContain(`client_id=${BOT_ID}`);
107
+ expect(text).toContain(`permissions=${INVITE_PERMISSIONS}`);
108
+ });
109
+ test("is skipped when the token failed", async () => {
110
+ const http = fakeHttp({ "/users/@me": { status: 401, body: {} } });
111
+ expect(
112
+ (await checkGuild(project(), new Discord(project(), http))).status,
113
+ ).toBe("skipped");
114
+ });
115
+ });
116
+
117
+ describe("Discord intents", () => {
118
+ test("passes with the Message Content intent, granted or limited", async () => {
119
+ for (const flags of [1 << 18, 1 << 19]) {
120
+ const http = fakeHttp({
121
+ ...healthyDiscord(all),
122
+ "/applications/@me": { status: 200, body: { flags } },
123
+ });
124
+ expect((await checkIntents(new Discord(project(), http))).status).toBe(
125
+ "ok",
126
+ );
127
+ }
128
+ });
129
+ test("fails without it, saying where to turn it on", async () => {
130
+ const http = fakeHttp({
131
+ ...healthyDiscord(all),
132
+ "/applications/@me": { status: 200, body: { flags: 0 } },
133
+ });
134
+ const text = failure(await checkIntents(new Discord(project(), http)));
135
+ expect(text).toContain("Message Content intent is off");
136
+ expect(text).toContain("Bot in https://discord.com/developers");
137
+ });
138
+ });
139
+
140
+ describe("Discord channel", () => {
141
+ const check = (routes: Record<string, never> | object) =>
142
+ checkChannel(project(), new Discord(project(), fakeHttp(routes as never)));
143
+ test("passes when the bot has every permission", async () => {
144
+ expect((await check(healthyDiscord(all))).status).toBe("ok");
145
+ });
146
+ test("fails naming the permission the bot lacks, Pin Messages included, and why it is needed", async () => {
147
+ const text = failure(await check(healthyDiscord(without("PinMessages"))));
148
+ expect(text).toContain("lacks PinMessages");
149
+ expect(text).toContain("pin the dashboard");
150
+ });
151
+ test("a channel overwrite that denies a permission counts, and an allow restores it", async () => {
152
+ const pin = bit("PinMessages");
153
+ const denied = healthyDiscord(all);
154
+ denied[`/channels/${channel}`] = {
155
+ status: 200,
156
+ body: {
157
+ guild_id: guild,
158
+ permission_overwrites: [
159
+ { id: "700000000000000001", type: 0, allow: "0", deny: String(pin) },
160
+ ],
161
+ },
162
+ };
163
+ expect(failure(await check(denied))).toContain("PinMessages");
164
+ const restored = healthyDiscord(all);
165
+ restored[`/channels/${channel}`] = {
166
+ status: 200,
167
+ body: {
168
+ guild_id: guild,
169
+ permission_overwrites: [
170
+ { id: guild, type: 0, allow: "0", deny: String(pin) },
171
+ { id: BOT_ID, type: 1, allow: String(pin), deny: "0" },
172
+ ],
173
+ },
174
+ };
175
+ expect((await check(restored)).status).toBe("ok");
176
+ });
177
+ test("the owner of the guild and an administrator have every permission", async () => {
178
+ const administrator = 1n << 3n;
179
+ expect((await check(healthyDiscord(administrator))).status).toBe("ok");
180
+ });
181
+ test("fails when the channel does not exist or is in another guild", async () => {
182
+ const missing = healthyDiscord(all);
183
+ missing[`/channels/${channel}`] = { status: 404, body: {} };
184
+ expect(failure(await check(missing))).toContain("DISCORD_ENTRY_CHANNEL_ID");
185
+ const elsewhere = healthyDiscord(all);
186
+ elsewhere[`/channels/${channel}`] = {
187
+ status: 200,
188
+ body: { guild_id: "999", permission_overwrites: [] },
189
+ };
190
+ expect(failure(await check(elsewhere))).toContain("another guild");
191
+ });
192
+ test("is skipped without the channel or guild value", async () => {
193
+ const empty = project({
194
+ ...validConfig,
195
+ discord: { ...validConfig.discord, entryChannel: "" },
196
+ });
197
+ const result = await checkChannel(empty, new Discord(empty, fakeHttp({})));
198
+ expect(result.status).toBe("skipped");
199
+ });
200
+ });
@@ -0,0 +1,218 @@
1
+ import {
2
+ channelPermissions,
3
+ DiscordApi,
4
+ inviteUrl,
5
+ REQUIRED_PERMISSIONS,
6
+ } from "../discord-api.ts";
7
+ import type { Http, HttpResponse } from "../http.ts";
8
+ import type { Project } from "../project.ts";
9
+ import { fail, ok, type Result, skipped } from "../report.ts";
10
+
11
+ const PORTAL = "https://discord.com/developers/applications";
12
+ const MESSAGE_CONTENT_FLAGS = (1 << 18) | (1 << 19);
13
+
14
+ const object = (value: unknown): Record<string, unknown> =>
15
+ typeof value === "object" && value !== null
16
+ ? (value as Record<string, unknown>)
17
+ : {};
18
+
19
+ type Bot = { id: string; name: string };
20
+ type Step<T> = { ok: true; value: T } | { ok: false; result: Result };
21
+
22
+ /**
23
+ * The bot's own view of Discord, asked at most once per fact, so four checks cost four
24
+ * requests, not ten. A failed step says why once and the checks after it are skipped.
25
+ */
26
+ export class Discord {
27
+ readonly #project: Project;
28
+ readonly #http: Http;
29
+ #api: Promise<Step<DiscordApi>> | undefined;
30
+ #bot: Promise<Step<Bot>> | undefined;
31
+
32
+ constructor(project: Project, http: Http) {
33
+ this.#project = project;
34
+ this.#http = http;
35
+ }
36
+
37
+ async #client(): Promise<Step<DiscordApi>> {
38
+ const token = await this.#project.text("discord", "token");
39
+ return token
40
+ ? { ok: true, value: new DiscordApi(this.#http, token) }
41
+ : { ok: false, result: skipped("discord.token has no value") };
42
+ }
43
+
44
+ /** One request; a network failure becomes a failed result naming the address. */
45
+ async ask(api: DiscordApi, path: string): Promise<Step<HttpResponse>> {
46
+ try {
47
+ return { ok: true, value: await api.get(path) };
48
+ } catch (error) {
49
+ return {
50
+ ok: false,
51
+ result: fail(
52
+ `cannot reach discord.com: ${error instanceof Error ? error.message : String(error)}`,
53
+ "Check the network connection and try again.",
54
+ ),
55
+ };
56
+ }
57
+ }
58
+
59
+ api(): Promise<Step<DiscordApi>> {
60
+ this.#api ??= this.#client();
61
+ return this.#api;
62
+ }
63
+
64
+ /** Who the token belongs to. */
65
+ bot(): Promise<Step<Bot>> {
66
+ this.#bot ??= this.#identify();
67
+ return this.#bot;
68
+ }
69
+
70
+ async #identify(): Promise<Step<Bot>> {
71
+ const api = await this.api();
72
+ if (!api.ok) return api;
73
+ const answer = await this.ask(api.value, "/users/@me");
74
+ if (!answer.ok) return answer;
75
+ const { status, body } = answer.value;
76
+ if (status === 401)
77
+ return {
78
+ ok: false,
79
+ result: fail(
80
+ "Discord rejected the bot token.",
81
+ `Reset it under Bot in ${PORTAL} and put the new one in .env as DISCORD_TOKEN.`,
82
+ ),
83
+ };
84
+ const id = object(body).id;
85
+ if (status !== 200 || typeof id !== "string")
86
+ return {
87
+ ok: false,
88
+ result: fail(
89
+ `Discord answered ${status} when asked who the token belongs to.`,
90
+ "Try again in a moment; if it persists, check https://discordstatus.com.",
91
+ ),
92
+ };
93
+ return {
94
+ ok: true,
95
+ value: { id, name: String(object(body).username ?? id) },
96
+ };
97
+ }
98
+ }
99
+
100
+ /** The token is one Discord accepts. */
101
+ export async function checkToken(discord: Discord): Promise<Result> {
102
+ const bot = await discord.bot();
103
+ return bot.ok ? ok(`the bot is ${bot.value.name}`) : bot.result;
104
+ }
105
+
106
+ /** The bot is a member of the configured guild; the fix is an invitation that asks for what it needs. */
107
+ export async function checkGuild(
108
+ project: Project,
109
+ discord: Discord,
110
+ ): Promise<Result> {
111
+ const guild = await project.text("discord", "guild");
112
+ if (!guild) return skipped("discord.guild has no value");
113
+ const bot = await discord.bot();
114
+ if (!bot.ok) return skipped("the token check failed");
115
+ const api = await discord.api();
116
+ if (!api.ok) return api.result;
117
+ const answer = await discord.ask(api.value, `/guilds/${guild}`);
118
+ if (!answer.ok) return answer.result;
119
+ const { status, body } = answer.value;
120
+ if (status === 200) return ok(`the bot is in ${String(object(body).name)}`);
121
+ if (status === 403 || status === 404)
122
+ return fail(
123
+ `the bot is not in the guild ${guild}, or the guild id is wrong.`,
124
+ `Check DISCORD_GUILD_ID, then invite the bot: ${inviteUrl(bot.value.id)}`,
125
+ );
126
+ return fail(
127
+ `Discord answered ${status} when asked for the guild ${guild}.`,
128
+ "Try again in a moment.",
129
+ );
130
+ }
131
+
132
+ /** The Message Content intent is switched on, without which the agents cannot read the channels. */
133
+ export async function checkIntents(discord: Discord): Promise<Result> {
134
+ const bot = await discord.bot();
135
+ if (!bot.ok) return skipped("the token check failed");
136
+ const api = await discord.api();
137
+ if (!api.ok) return api.result;
138
+ const answer = await discord.ask(api.value, "/applications/@me");
139
+ if (!answer.ok) return answer.result;
140
+ const flags = object(answer.value.body).flags;
141
+ if (answer.value.status !== 200 || typeof flags !== "number")
142
+ return fail(
143
+ `Discord answered ${answer.value.status} when asked for the application.`,
144
+ "Try again in a moment.",
145
+ );
146
+ if ((flags & MESSAGE_CONTENT_FLAGS) === 0)
147
+ return fail(
148
+ "the Message Content intent is off.",
149
+ `Turn on Message Content Intent under Bot in ${PORTAL}, save, and run the check again.`,
150
+ );
151
+ return ok("the Message Content intent is on");
152
+ }
153
+
154
+ /** The entry channel exists in the guild and the bot may do in it what the agents do. */
155
+ export async function checkChannel(
156
+ project: Project,
157
+ discord: Discord,
158
+ ): Promise<Result> {
159
+ const channel = await project.text("discord", "entryChannel");
160
+ const guild = await project.text("discord", "guild");
161
+ if (!channel || !guild)
162
+ return skipped("discord.entryChannel or discord.guild has no value");
163
+ const bot = await discord.bot();
164
+ if (!bot.ok) return skipped("the token check failed");
165
+ const api = await discord.api();
166
+ if (!api.ok) return api.result;
167
+ const found = await discord.ask(api.value, `/channels/${channel}`);
168
+ if (!found.ok) return found.result;
169
+ const shown = object(found.value.body);
170
+ if (found.value.status === 403 || found.value.status === 404)
171
+ return fail(
172
+ `the bot cannot see a channel ${channel}.`,
173
+ "Check DISCORD_ENTRY_CHANNEL_ID, and that the bot's role may view the channel.",
174
+ );
175
+ if (found.value.status !== 200)
176
+ return fail(
177
+ `Discord answered ${found.value.status} when asked for the channel ${channel}.`,
178
+ "Try again in a moment.",
179
+ );
180
+ if (shown.guild_id !== guild)
181
+ return fail(
182
+ `the channel ${channel} belongs to another guild.`,
183
+ "Use a channel of the configured guild for DISCORD_ENTRY_CHANNEL_ID.",
184
+ );
185
+ const [guildAnswer, member] = await Promise.all([
186
+ discord.ask(api.value, `/guilds/${guild}`),
187
+ discord.ask(api.value, `/guilds/${guild}/members/${bot.value.id}`),
188
+ ]);
189
+ if (!guildAnswer.ok) return guildAnswer.result;
190
+ if (!member.ok) return member.result;
191
+ if (guildAnswer.value.status !== 200 || member.value.status !== 200)
192
+ return fail(
193
+ `Discord did not show the bot's roles (${guildAnswer.value.status}, ${member.value.status}).`,
194
+ "Check that the bot is in the guild, then try again.",
195
+ );
196
+ const guildFacts = object(guildAnswer.value.body);
197
+ const granted = channelPermissions({
198
+ botId: bot.value.id,
199
+ guildId: guild,
200
+ ownerId: String(guildFacts.owner_id),
201
+ roles: (guildFacts.roles as { id: string; permissions: string }[]) ?? [],
202
+ memberRoles: (object(member.value.body).roles as string[]) ?? [],
203
+ overwrites:
204
+ (shown.permission_overwrites as {
205
+ id: string;
206
+ type: number;
207
+ allow: string;
208
+ deny: string;
209
+ }[]) ?? [],
210
+ });
211
+ const lacking = REQUIRED_PERMISSIONS.filter(([name]) => !granted.has(name));
212
+ if (lacking.length > 0)
213
+ return fail(
214
+ `in the entry channel the bot lacks ${lacking.map(([name]) => name).join(", ")}.`,
215
+ `Grant them to the bot's role or in the channel's permissions (${lacking.map(([name, use]) => `${name} to ${use}`).join("; ")}).`,
216
+ );
217
+ return ok("the bot has every permission it needs there");
218
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fail, ok, type Result, skipped } from "../report.ts";
4
+
5
+ /** The variables `.env.example` asks for: its uncommented `NAME=` lines. */
6
+ export function requiredVariables(example: string): string[] {
7
+ return [...example.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map(
8
+ (match) => match[1] as string,
9
+ );
10
+ }
11
+
12
+ /** Every variable `.env.example` lists has a value, so a missing credential is named before anything tries to use it. */
13
+ export function checkEnvironment(
14
+ cwd: string,
15
+ env: Record<string, string | undefined>,
16
+ ): Result {
17
+ const path = join(cwd, ".env.example");
18
+ if (!existsSync(path)) return skipped("this project has no .env.example");
19
+ const missing = requiredVariables(readFileSync(path, "utf8")).filter(
20
+ (name) => (env[name] ?? "").trim() === "",
21
+ );
22
+ if (missing.length === 0) return ok(".env has a value for every variable");
23
+ return fail(
24
+ `no value for ${missing.join(", ")}.`,
25
+ `${existsSync(join(cwd, ".env")) ? "Fill them in .env" : "Copy .env.example to .env and fill them in"}; .env.example says where each one comes from.`,
26
+ );
27
+ }
@@ -0,0 +1,28 @@
1
+ import { parseModelRef } from "../../core/models.ts";
2
+ import type { Ports, Project } from "../project.ts";
3
+ import { fail, ok, type Result, skipped } from "../report.ts";
4
+
5
+ /** A login exists for the provider of the configured model: an API key variable or a stored login. */
6
+ export async function checkModelLogin(
7
+ project: Project,
8
+ ports: Pick<Ports, "login">,
9
+ ): Promise<Result> {
10
+ const model = await project.text("model");
11
+ if (!model) return skipped("model has no value");
12
+ const ref = parseModelRef(model);
13
+ if (!ref)
14
+ return fail(
15
+ `the model ${JSON.stringify(model)} is not written <provider>/<id>.`,
16
+ "Write it like anthropic/claude-sonnet-5-5 in MODEL in .env.",
17
+ );
18
+ const dir = await project.text("agentDir");
19
+ const dataDir = await project.text("dataDir");
20
+ const agentDir = dir ?? (dataDir ? `${dataDir}/pi` : undefined);
21
+ if (!agentDir) return skipped("dataDir has no value");
22
+ const source = await ports.login(agentDir, ref.provider);
23
+ if (source) return ok(`${ref.provider} login from ${source}`);
24
+ return fail(
25
+ `no login for ${ref.provider}, the provider of ${model}.`,
26
+ `Set the provider's API key in .env (ANTHROPIC_API_KEY for anthropic, OPENAI_API_KEY for openai), or sign in with Pi so ${agentDir}/auth.json holds it.`,
27
+ );
28
+ }