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,123 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import {
5
+ type ResolvedConfig,
6
+ type RoundtableConfig,
7
+ resolveConfig,
8
+ } from "../core/config/config.ts";
9
+ import type { DefinedRoundtable } from "../core/define-roundtable.ts";
10
+ import { fail, type Result } from "./report.ts";
11
+
12
+ export const CONFIG_FILE = "roundtable.config.ts";
13
+
14
+ /** What the command line reaches outside itself, so each check can be run against a fake. */
15
+ export interface Ports {
16
+ /** The value `roundtable.config.ts` exports by default. */
17
+ loadConfig(cwd: string): Promise<unknown>;
18
+ /** The bot's options and plugin list, assembled from a valid configuration. */
19
+ define(
20
+ config: RoundtableConfig,
21
+ agentDir: string,
22
+ ): Promise<DefinedRoundtable>;
23
+ /** Where the login for `provider` in `agentDir` comes from, or undefined when there is none. */
24
+ login(agentDir: string, provider: string): Promise<string | undefined>;
25
+ }
26
+
27
+ /** Loads the project's configuration file with Bun, which reads `.env` on its own. */
28
+ export async function loadConfigFile(cwd: string): Promise<unknown> {
29
+ const path = join(cwd, CONFIG_FILE);
30
+ if (!existsSync(path))
31
+ throw new Error(
32
+ `${CONFIG_FILE} is not in ${cwd}. Run this in the project directory, or create a project with \`roundtable init\`.`,
33
+ );
34
+ const module = (await import(pathToFileURL(path).href)) as {
35
+ default?: unknown;
36
+ };
37
+ if (module.default === undefined)
38
+ throw new Error(
39
+ `${CONFIG_FILE} has no default export. Write \`export default { ... } satisfies RoundtableConfig\`.`,
40
+ );
41
+ return module.default;
42
+ }
43
+
44
+ type Loaded<T> = { ok: true; value: T } | { ok: false; failure: Result };
45
+
46
+ export interface Assembled {
47
+ config: ResolvedConfig;
48
+ defined: DefinedRoundtable;
49
+ }
50
+
51
+ const message = (error: unknown): string =>
52
+ error instanceof Error ? error.message : String(error);
53
+
54
+ /**
55
+ * The project's configuration, loaded and resolved at most once and shared by every check, so a
56
+ * mistake in it is reported by the configuration check and the checks that need it say so
57
+ * rather than repeating it.
58
+ */
59
+ export class Project {
60
+ readonly #cwd: string;
61
+ readonly #ports: Ports;
62
+ #raw: Promise<Loaded<unknown>> | undefined;
63
+ #assembled: Promise<Loaded<Assembled>> | undefined;
64
+
65
+ constructor(cwd: string, ports: Ports) {
66
+ this.#cwd = cwd;
67
+ this.#ports = ports;
68
+ }
69
+
70
+ /** The exported value as written, before the schema looks at it. */
71
+ raw(): Promise<Loaded<unknown>> {
72
+ this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
73
+ (value): Loaded<unknown> => ({ ok: true, value }),
74
+ (error: unknown): Loaded<unknown> => ({
75
+ ok: false,
76
+ failure: fail(
77
+ `${CONFIG_FILE} could not be loaded: ${message(error)}`,
78
+ `Fix the error in ${CONFIG_FILE}.`,
79
+ ),
80
+ }),
81
+ );
82
+ return this.#raw;
83
+ }
84
+
85
+ /** A string at `path` in the configuration as written, even when other keys are wrong. */
86
+ async text(...path: string[]): Promise<string | undefined> {
87
+ const raw = await this.raw();
88
+ if (!raw.ok) return undefined;
89
+ let value: unknown = raw.value;
90
+ for (const key of path) {
91
+ if (typeof value !== "object" || value === null) return undefined;
92
+ value = (value as Record<string, unknown>)[key];
93
+ }
94
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
95
+ }
96
+
97
+ /** The configuration checked against its schema and assembled, or the failure that says why not. */
98
+ assembled(): Promise<Loaded<Assembled>> {
99
+ this.#assembled ??= this.#assemble();
100
+ return this.#assembled;
101
+ }
102
+
103
+ async #assemble(): Promise<Loaded<Assembled>> {
104
+ const raw = await this.raw();
105
+ if (!raw.ok) return raw;
106
+ try {
107
+ const config = resolveConfig(raw.value);
108
+ const defined = await this.#ports.define(
109
+ raw.value as RoundtableConfig,
110
+ config.agentDir,
111
+ );
112
+ return { ok: true, value: { config, defined } };
113
+ } catch (error) {
114
+ return {
115
+ ok: false,
116
+ failure: fail(
117
+ message(error),
118
+ `Fix the key in ${CONFIG_FILE}; a value it reads from .env is set there (see .env.example).`,
119
+ ),
120
+ };
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,68 @@
1
+ /** How one check ended: passed, failed with what to do, or not run because it needs something an earlier check reports. */
2
+ export type Result =
3
+ | { status: "ok"; detail?: string }
4
+ | { status: "fail"; problem: string; fix: string }
5
+ | { status: "skipped"; reason: string };
6
+
7
+ /** One thing `doctor` looks at. `offline` checks need no network, so `start` runs them too. */
8
+ export interface Check {
9
+ name: string;
10
+ offline: boolean;
11
+ run(): Promise<Result>;
12
+ }
13
+
14
+ export interface Outcome {
15
+ name: string;
16
+ result: Result;
17
+ }
18
+
19
+ export const ok = (detail?: string): Result =>
20
+ detail === undefined ? { status: "ok" } : { status: "ok", detail };
21
+ export const fail = (problem: string, fix: string): Result => ({
22
+ status: "fail",
23
+ problem,
24
+ fix,
25
+ });
26
+ export const skipped = (reason: string): Result => ({
27
+ status: "skipped",
28
+ reason,
29
+ });
30
+
31
+ const errorText = (error: unknown): string =>
32
+ error instanceof Error ? error.message : String(error);
33
+
34
+ /** Runs the checks in order. A check that throws fails with its message, so a bug in one never hides the rest. */
35
+ export async function runChecks(checks: readonly Check[]): Promise<Outcome[]> {
36
+ const outcomes: Outcome[] = [];
37
+ for (const check of checks) {
38
+ try {
39
+ outcomes.push({ name: check.name, result: await check.run() });
40
+ } catch (error) {
41
+ outcomes.push({
42
+ name: check.name,
43
+ result: fail(
44
+ `the check itself failed: ${errorText(error)}`,
45
+ "Report this as a bug in roundtable doctor.",
46
+ ),
47
+ });
48
+ }
49
+ }
50
+ return outcomes;
51
+ }
52
+
53
+ export const failed = (outcomes: readonly Outcome[]): Outcome[] =>
54
+ outcomes.filter(({ result }) => result.status === "fail");
55
+
56
+ /** One line per check, and for a failure its problem and fix underneath. */
57
+ export function formatOutcomes(outcomes: readonly Outcome[]): string[] {
58
+ return outcomes.flatMap(({ name, result }) => {
59
+ if (result.status === "ok")
60
+ return [`✓ ${name}${result.detail ? `: ${result.detail}` : ""}`];
61
+ if (result.status === "skipped")
62
+ return [`- ${name}: skipped, ${result.reason}`];
63
+ return [
64
+ `✗ ${name}: ${result.problem}`,
65
+ ...result.fix.split("\n").map((line) => ` ${line}`),
66
+ ];
67
+ });
68
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // The bin npm links. It runs under Node only to find Bun: with Bun it hands over, without it
3
+ // it says where to get it, since a `bun` shebang would fail with an error nobody can read.
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const main = fileURLToPath(new URL("./main.ts", import.meta.url));
8
+
9
+ if (typeof Bun !== "undefined") {
10
+ await import("./main.ts");
11
+ } else {
12
+ const run = spawnSync("bun", [main, ...process.argv.slice(2)], {
13
+ stdio: "inherit",
14
+ });
15
+ if (run.error?.code === "ENOENT") {
16
+ console.error(
17
+ "Bun is not installed. pi-roundtable runs on Bun: install it from https://bun.sh/docs/installation and run this command again.",
18
+ );
19
+ process.exit(1);
20
+ }
21
+ process.exit(run.status ?? 1);
22
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Credential, CredentialStore } from "@earendil-works/pi-ai";
4
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
5
+ import type { DefinedRoundtable } from "../core/define-roundtable.ts";
6
+ import { defineRoundtable } from "../core/define-roundtable.ts";
7
+
8
+ /** A credential store that holds nothing and refuses writes, so looking never creates `auth.json`. */
9
+ const emptyCredentials: CredentialStore = {
10
+ read: async () => undefined,
11
+ list: async () => [],
12
+ modify: async (): Promise<Credential | undefined> => {
13
+ throw new Error("the doctor does not change logins");
14
+ },
15
+ delete: async () => {
16
+ throw new Error("the doctor does not change logins");
17
+ },
18
+ };
19
+
20
+ /**
21
+ * The model login as the bot would open it, without creating the agent directory or
22
+ * `auth.json` when there is none yet.
23
+ */
24
+ export async function openModelRuntime(
25
+ agentDir: string,
26
+ ): Promise<ModelRuntime> {
27
+ process.env.PI_CODING_AGENT_DIR = agentDir;
28
+ const authPath = join(agentDir, "auth.json");
29
+ return await ModelRuntime.create(
30
+ existsSync(authPath)
31
+ ? { authPath, refreshOnCreate: false }
32
+ : { credentials: emptyCredentials, refreshOnCreate: false },
33
+ );
34
+ }
35
+
36
+ /** Where the configured provider's login comes from (a key variable or the stored login), undefined when there is none. */
37
+ export async function providerLogin(
38
+ agentDir: string,
39
+ provider: string,
40
+ ): Promise<string | undefined> {
41
+ const check = await (await openModelRuntime(agentDir)).checkAuth(provider);
42
+ return check ? (check.source ?? check.type) : undefined;
43
+ }
44
+
45
+ /** The configuration assembled as the bot will run it, over a login that is only read. */
46
+ export async function assemble(
47
+ config: Parameters<typeof defineRoundtable>[0],
48
+ agentDir: string,
49
+ ): Promise<DefinedRoundtable> {
50
+ return await defineRoundtable(config, {
51
+ modelRuntime: await openModelRuntime(agentDir),
52
+ });
53
+ }
@@ -0,0 +1,9 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { oversizedFiles } from "../core/testing/file-size.ts";
4
+
5
+ test("no file of the command line is over the size limit", () => {
6
+ expect(
7
+ oversizedFiles(resolve(import.meta.dir, "../.."), ["src/cli"]),
8
+ ).toEqual([]);
9
+ });
@@ -0,0 +1,33 @@
1
+ import type { DefinedRoundtable } from "../core/define-roundtable.ts";
2
+ import { buildChecks, type DoctorInputs } from "./doctor.ts";
3
+ import { Project } from "./project.ts";
4
+ import { failed, type Outcome, runChecks } from "./report.ts";
5
+
6
+ export interface StartInputs extends Omit<DoctorInputs, "reachable"> {
7
+ /** Runs the bot; by default the host with its signal handlers. Tests pass a fake. */
8
+ launch(defined: DefinedRoundtable): Promise<void>;
9
+ }
10
+
11
+ export interface StartReport {
12
+ /** Whether the bot was launched. */
13
+ started: boolean;
14
+ outcomes: Outcome[];
15
+ }
16
+
17
+ /**
18
+ * Runs the checks that need no network, then starts the bot. A failing check stops it before
19
+ * anything reaches Discord, with the message `doctor` prints for it; what needs the network
20
+ * (PostgreSQL, Discord) fails the boot itself, before Discord connects, when it is wrong.
21
+ */
22
+ export async function start(inputs: StartInputs): Promise<StartReport> {
23
+ const project = new Project(inputs.cwd, inputs.ports);
24
+ const offline = buildChecks({ ...inputs, reachable: false }, project).filter(
25
+ (check) => check.offline,
26
+ );
27
+ const outcomes = await runChecks(offline);
28
+ if (failed(outcomes).length > 0) return { started: false, outcomes };
29
+ const assembled = await project.assembled();
30
+ if (!assembled.ok) return { started: false, outcomes };
31
+ await inputs.launch(assembled.value.defined);
32
+ return { started: true, outcomes };
33
+ }
@@ -0,0 +1,129 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { addPlugin } from "./add-plugin.ts";
5
+ import { init } from "./init.ts";
6
+ import { tempDir } from "./testing/fixtures.ts";
7
+
8
+ const ROOT = resolve(import.meta.dir, "../..");
9
+ const TSC = join(ROOT, "node_modules/.bin/tsc");
10
+ const BIOME = join(ROOT, "node_modules/.bin/biome");
11
+
12
+ function run(cwd: string, cmd: string[]): { ok: boolean; output: string } {
13
+ const result = Bun.spawnSync(cmd, {
14
+ cwd,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ env: { ...process.env, CI: "" },
18
+ });
19
+ return {
20
+ ok: result.exitCode === 0,
21
+ output: `${result.stdout.toString()}${result.stderr.toString()}`,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * The generated project with `pi-roundtable`, `typebox`, and the types linked from this
27
+ * checkout, so it typechecks and tests against the source under test without the network.
28
+ */
29
+ function link(project: string): void {
30
+ const modules = join(project, "node_modules");
31
+ mkdirSync(join(modules, "pi-roundtable"), { recursive: true });
32
+ mkdirSync(join(modules, "@types"), { recursive: true });
33
+ writeFileSync(
34
+ join(modules, "pi-roundtable/package.json"),
35
+ JSON.stringify({
36
+ name: "pi-roundtable",
37
+ type: "module",
38
+ exports: { ".": "./src/index.ts", "./testing": "./src/testing.ts" },
39
+ }),
40
+ );
41
+ symlinkSync(join(ROOT, "src"), join(modules, "pi-roundtable/src"));
42
+ symlinkSync(join(ROOT, "node_modules/typebox"), join(modules, "typebox"));
43
+ symlinkSync(
44
+ join(ROOT, "node_modules/@types/bun"),
45
+ join(modules, "@types/bun"),
46
+ );
47
+ }
48
+
49
+ const dir = tempDir("roundtable-template-");
50
+ const bun = { version: Bun.version, required: ">=1.3.0" };
51
+ const check = (label: string) => {
52
+ const typecheck = run(dir.path, [TSC, "--noEmit"]);
53
+ const test = run(dir.path, ["bun", "test"]);
54
+ const lint = run(dir.path, [BIOME, "check", "."]);
55
+ return { label, typecheck, test, lint };
56
+ };
57
+
58
+ let fresh: ReturnType<typeof check>;
59
+ let grown: ReturnType<typeof check>;
60
+
61
+ beforeAll(() => {
62
+ const report = init({ cwd: dir.path, bun, version: "0.1.0" });
63
+ if (!report.ok) throw new Error(report.problems.join("\n"));
64
+ link(dir.path);
65
+ fresh = check("fresh");
66
+ const added = addPlugin({ cwd: dir.path, name: "second-plugin" });
67
+ if (!added.ok) throw new Error(added.problems.join("\n"));
68
+ grown = check("after add plugin");
69
+ }, 120_000);
70
+ afterAll(() => dir.done());
71
+
72
+ describe("every template rendered into one project", () => {
73
+ test("typechecks with no edits", () => {
74
+ expect(fresh.typecheck.output).toBe("");
75
+ expect(fresh.typecheck.ok).toBe(true);
76
+ });
77
+ test("passes its own test with no edits", () => {
78
+ expect(fresh.test.output).toContain("1 pass");
79
+ expect(fresh.test.ok).toBe(true);
80
+ });
81
+ test("passes the linter it ships with", () => {
82
+ expect(fresh.lint.output).toContain("No fixes applied");
83
+ expect(fresh.lint.ok).toBe(true);
84
+ });
85
+ test("still typechecks, tests, and lints after `add plugin`", () => {
86
+ expect(grown.typecheck.output).toBe("");
87
+ expect(grown.test.output).toContain("2 pass");
88
+ expect(grown.lint.output).toContain("No fixes applied");
89
+ expect(grown.typecheck.ok && grown.test.ok && grown.lint.ok).toBe(true);
90
+ });
91
+ });
92
+
93
+ test("the generated project's pinned tools match this package's own", () => {
94
+ const project = JSON.parse(
95
+ readFileSync(join(dir.path, "package.json"), "utf8"),
96
+ ) as {
97
+ dependencies: Record<string, string>;
98
+ devDependencies: Record<string, string>;
99
+ };
100
+ const own = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as {
101
+ dependencies: Record<string, string>;
102
+ devDependencies: Record<string, string>;
103
+ };
104
+ expect(project.dependencies.typebox).toBe(own.dependencies.typebox);
105
+ for (const name of ["typescript", "@biomejs/biome", "@types/bun"])
106
+ expect(project.devDependencies[name]).toBe(own.devDependencies[name]);
107
+ });
108
+
109
+ test("no template file is hidden from git by an ignore rule, which would leave a clone unable to init", () => {
110
+ const inRepository = Bun.spawnSync(["git", "rev-parse", "--git-dir"], {
111
+ cwd: ROOT,
112
+ stdout: "pipe",
113
+ stderr: "pipe",
114
+ });
115
+ // The exported public tree is not a checkout; its own ignore file is packaging/.gitignore.
116
+ if (inRepository.exitCode !== 0) return;
117
+ const ignored = Bun.spawnSync(
118
+ [
119
+ "git",
120
+ "ls-files",
121
+ "--others",
122
+ "--ignored",
123
+ "--exclude-standard",
124
+ "templates",
125
+ ],
126
+ { cwd: ROOT, stdout: "pipe", stderr: "pipe" },
127
+ );
128
+ expect(ignored.stdout.toString().trim()).toBe("");
129
+ });
@@ -0,0 +1,119 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname, join, relative } from "node:path";
9
+
10
+ /** The templates the package ships, as real files beside the source. */
11
+ export const TEMPLATES_DIR = join(import.meta.dir, "../../templates");
12
+
13
+ /** The directory of the plugin template, rendered once for `hello` and again for every `add plugin`. */
14
+ const PLUGIN_DIR = "plugin";
15
+
16
+ /** What a template file may say, replaced when it is rendered. */
17
+ export interface Substitutions {
18
+ /** The project's package name. */
19
+ project: string;
20
+ /** The exact version of pi-roundtable the project depends on. */
21
+ version: string;
22
+ }
23
+
24
+ /** The words a plugin template is written in: the kebab name, its identifier, and its tool prefix. */
25
+ export interface PluginNames {
26
+ name: string;
27
+ ident: string;
28
+ tool: string;
29
+ }
30
+
31
+ /** A file to write: where, and what. */
32
+ export interface Rendered {
33
+ path: string;
34
+ content: string;
35
+ }
36
+
37
+ /** Files that tools would act on in place are stored with a `.tmpl` suffix; npm also drops `.gitignore`. */
38
+ function targetOf(path: string): string {
39
+ const name = path.replace(/\.tmpl$/, "");
40
+ return name.endsWith("_gitignore")
41
+ ? name.replace("_gitignore", ".gitignore")
42
+ : name;
43
+ }
44
+
45
+ function filesUnder(dir: string): string[] {
46
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
47
+ const path = join(dir, entry.name);
48
+ return entry.isDirectory() ? filesUnder(path) : [path];
49
+ });
50
+ }
51
+
52
+ /** `my-notes` as the words a template needs. */
53
+ export function pluginNames(name: string): PluginNames {
54
+ return {
55
+ name,
56
+ ident: name.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()),
57
+ tool: name.replaceAll("-", "_"),
58
+ };
59
+ }
60
+
61
+ /** A plugin name is lowercase words joined by dashes, as `definePlugin` requires. */
62
+ export const isPluginName = (name: string): boolean =>
63
+ /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);
64
+
65
+ function fill(text: string, values: Record<string, string>): string {
66
+ return text.replace(/__([A-Z]+)__/g, (whole, key: string) => {
67
+ const value = values[key];
68
+ if (value === undefined)
69
+ throw new Error(`template placeholder ${whole} has no value`);
70
+ return value;
71
+ });
72
+ }
73
+
74
+ /** The plugin and its test, written for `names`. */
75
+ export function renderPlugin(
76
+ names: PluginNames,
77
+ dir = TEMPLATES_DIR,
78
+ ): Rendered[] {
79
+ const values = { NAME: names.name, IDENT: names.ident, TOOL: names.tool };
80
+ return [
81
+ ["plugin.ts", `plugins/${names.name}.ts`],
82
+ ["plugin.test.ts.tmpl", `plugins/${names.name}.test.ts`],
83
+ ].map(([source, path]) => ({
84
+ path: path as string,
85
+ content: fill(
86
+ readFileSync(join(dir, PLUGIN_DIR, source as string), "utf8"),
87
+ values,
88
+ ),
89
+ }));
90
+ }
91
+
92
+ /** Every file of a new project: the skeleton and the `hello` plugin, paths relative to the project. */
93
+ export function renderProject(
94
+ substitutions: Substitutions,
95
+ dir = TEMPLATES_DIR,
96
+ ): Rendered[] {
97
+ if (!existsSync(dir)) throw new Error(`templates are missing: ${dir}`);
98
+ const values = {
99
+ PROJECT: substitutions.project,
100
+ VERSION: substitutions.version,
101
+ };
102
+ const skeleton = filesUnder(dir)
103
+ .map((file) => relative(dir, file))
104
+ .filter((path) => !path.startsWith(`${PLUGIN_DIR}/`))
105
+ .map((path) => ({
106
+ path: targetOf(path),
107
+ content: fill(readFileSync(join(dir, path), "utf8"), values),
108
+ }));
109
+ return [...skeleton, ...renderPlugin(pluginNames("hello"), dir)];
110
+ }
111
+
112
+ /** Writes rendered files under `root`, creating directories; the caller has checked that none exists. */
113
+ export function writeRendered(root: string, files: readonly Rendered[]): void {
114
+ for (const file of files) {
115
+ const target = join(root, file.path);
116
+ mkdirSync(dirname(target), { recursive: true });
117
+ writeFileSync(target, file.content);
118
+ }
119
+ }
@@ -0,0 +1,106 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { RoundtableConfig } from "../../core/config/config.ts";
5
+ import type { DefinedRoundtable } from "../../core/define-roundtable.ts";
6
+ import type { Http, HttpResponse } from "../http.ts";
7
+ import type { Ports } from "../project.ts";
8
+
9
+ /** A configuration the schema accepts. */
10
+ export const validConfig: RoundtableConfig = {
11
+ owner: { id: "100000000000000001", name: "Ada" },
12
+ discord: {
13
+ token: "bot-token",
14
+ guild: "900000000000000001",
15
+ entryChannel: "900000000000000002",
16
+ },
17
+ database: { url: "postgres://user:secret@db.example.test:5432/bot" },
18
+ dataDir: "/data",
19
+ model: "anthropic/claude-sonnet-5-5",
20
+ http: { publicUrl: "https://bot.example.test" },
21
+ };
22
+
23
+ /** A directory that is removed when the test ends; call `done` in `afterEach`. */
24
+ export function tempDir(prefix = "roundtable-cli-"): {
25
+ path: string;
26
+ write(file: string, content: string): void;
27
+ done(): void;
28
+ } {
29
+ const path = mkdtempSync(join(tmpdir(), prefix));
30
+ return {
31
+ path,
32
+ write(file, content) {
33
+ mkdirSync(dirname(join(path, file)), { recursive: true });
34
+ writeFileSync(join(path, file), content);
35
+ },
36
+ done: () => rmSync(path, { recursive: true, force: true }),
37
+ };
38
+ }
39
+
40
+ /** Ports that hand out `config`, assemble nothing real, and report a login from `source`. */
41
+ export function fakePorts(
42
+ config: unknown = validConfig,
43
+ overrides: Partial<Ports> = {},
44
+ ): Ports {
45
+ const defined: DefinedRoundtable = {
46
+ options: { logger: {} as never },
47
+ plugins: [{ name: "database", setup: () => ({}) }],
48
+ };
49
+ return {
50
+ loadConfig: async () => config,
51
+ define: async () => defined,
52
+ login: async () => "ANTHROPIC_API_KEY",
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ /** An HTTP client that answers from `routes`, keyed by the path after the host, and fails on any other. */
58
+ export function fakeHttp(
59
+ routes: Record<string, HttpResponse | Error>,
60
+ ): Http & { requests: string[] } {
61
+ const requests: string[] = [];
62
+ return {
63
+ requests,
64
+ async get(url) {
65
+ const key = url.replace(/^https?:\/\/[^/]+(\/api\/v10)?/, "") || "/";
66
+ requests.push(key);
67
+ const route = routes[key];
68
+ if (route === undefined) throw new Error(`no route for ${key}`);
69
+ if (route instanceof Error) throw route;
70
+ return route;
71
+ },
72
+ };
73
+ }
74
+
75
+ export const BOT_ID = "800000000000000001";
76
+
77
+ /** Discord as it answers when everything is right: the token is good, the bot is in the guild, with every permission and the intent. */
78
+ export function healthyDiscord(
79
+ permissions: bigint,
80
+ guild = validConfig.discord.guild,
81
+ channel = validConfig.discord.entryChannel,
82
+ ): Record<string, HttpResponse> {
83
+ return {
84
+ "/users/@me": { status: 200, body: { id: BOT_ID, username: "Bot" } },
85
+ "/applications/@me": { status: 200, body: { flags: 1 << 18 } },
86
+ [`/guilds/${guild}`]: {
87
+ status: 200,
88
+ body: {
89
+ name: "Test Guild",
90
+ owner_id: "100000000000000001",
91
+ roles: [
92
+ { id: guild, permissions: "0" },
93
+ { id: "700000000000000001", permissions: String(permissions) },
94
+ ],
95
+ },
96
+ },
97
+ [`/guilds/${guild}/members/${BOT_ID}`]: {
98
+ status: 200,
99
+ body: { roles: ["700000000000000001"] },
100
+ },
101
+ [`/channels/${channel}`]: {
102
+ status: 200,
103
+ body: { id: channel, guild_id: guild, permission_overwrites: [] },
104
+ },
105
+ };
106
+ }