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,143 @@
1
+ import { ConfigError } from "./domain/errors.ts";
2
+ import type { OwnerIdentity } from "./identity.ts";
3
+
4
+ /** How much a speaker may do: `owner` above `admin` above `member`. */
5
+ export const TIERS = ["member", "admin", "owner"] as const;
6
+ export type Tier = (typeof TIERS)[number];
7
+
8
+ /** Whether `tier` is `least` or above. */
9
+ export function tierAtLeast(tier: Tier, least: Tier): boolean {
10
+ return TIERS.indexOf(tier) >= TIERS.indexOf(least);
11
+ }
12
+
13
+ /** A person whose message starts a turn, and the tier the policy gave them. */
14
+ export interface Speaker {
15
+ id: string;
16
+ name: string;
17
+ tier: Tier;
18
+ }
19
+
20
+ /**
21
+ * Who a prompt addresses for this speaker: the owner for an owner-tier speaker, so the owner's
22
+ * prompts stay as they are, and otherwise the speaker by name, since a name needs no guess at
23
+ * pronouns.
24
+ */
25
+ export function addressee(
26
+ speaker: Speaker | undefined,
27
+ owner: OwnerIdentity,
28
+ ): OwnerIdentity {
29
+ if (!speaker || speaker.tier === "owner") return owner;
30
+ return {
31
+ name: speaker.name,
32
+ pronouns: {
33
+ subject: speaker.name,
34
+ object: speaker.name,
35
+ possessive: `${speaker.name}'s`,
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * How a tool description names the person it serves in a session that several speakers share:
42
+ * the description is fixed when the session opens, so it names no one, and the prompt says who
43
+ * the speaker is.
44
+ */
45
+ export const THE_SPEAKER: OwnerIdentity = {
46
+ name: "the speaker",
47
+ pronouns: {
48
+ subject: "the speaker",
49
+ object: "the speaker",
50
+ possessive: "the speaker's",
51
+ },
52
+ };
53
+
54
+ /** The turn's text under the speaker's name, so a conversation several speakers share says who wrote what. */
55
+ export function attributed(speaker: Speaker, text: string): string {
56
+ if (speaker.tier === "owner") return text;
57
+ return `(Message from ${speaker.name}, at the ${speaker.tier} tier.)\n\n${text}`;
58
+ }
59
+
60
+ /** What a surface reports about the author of a message; the policy decides the rest. */
61
+ export interface SpeakerFacts {
62
+ id: string;
63
+ name: string;
64
+ /** The roles the author holds in the server; empty where the surface has none. */
65
+ roleIds?: readonly string[];
66
+ }
67
+
68
+ /**
69
+ * Decides who may talk to the agents. Undefined means the author is nobody: no reply and no
70
+ * turn. A plugin may replace the configured policy.
71
+ */
72
+ export interface SpeakerPolicy {
73
+ resolve(author: SpeakerFacts): Speaker | undefined;
74
+ }
75
+
76
+ /** Who holds one tier: user ids, role ids, and (for members) everyone. */
77
+ export interface TierMembers {
78
+ users?: readonly string[];
79
+ roles?: readonly string[];
80
+ everyone?: boolean;
81
+ }
82
+
83
+ export interface SpeakerMap {
84
+ /** User ids of the owners. */
85
+ owners: readonly string[];
86
+ admins?: TierMembers;
87
+ members?: TierMembers;
88
+ }
89
+
90
+ /** The policy of an operator's map; the highest tier an author qualifies for wins. */
91
+ export function speakerPolicy(map: SpeakerMap): SpeakerPolicy {
92
+ const holds = (tier: TierMembers | undefined, author: SpeakerFacts) =>
93
+ tier !== undefined &&
94
+ (tier.everyone === true ||
95
+ tier.users?.includes(author.id) === true ||
96
+ (author.roleIds ?? []).some((role) => tier.roles?.includes(role)));
97
+ const tierOf = (author: SpeakerFacts): Tier | undefined => {
98
+ if (map.owners.includes(author.id)) return "owner";
99
+ if (holds(map.admins, author)) return "admin";
100
+ if (holds(map.members, author)) return "member";
101
+ return undefined;
102
+ };
103
+ return {
104
+ resolve(author) {
105
+ const tier = tierOf(author);
106
+ return tier && { id: author.id, name: author.name, tier };
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Reads a tier's members from text: comma-separated `user:<id>`, `role:<id>`, or `@everyone`;
113
+ * a bare id is a user. `allowEveryone` is false for tiers that cannot be everyone.
114
+ */
115
+ export function parseTierMembers(
116
+ text: string | undefined,
117
+ name: string,
118
+ allowEveryone: boolean,
119
+ ): TierMembers | undefined {
120
+ const items = (text ?? "")
121
+ .split(",")
122
+ .map((item) => item.trim())
123
+ .filter((item) => item !== "");
124
+ if (items.length === 0) return undefined;
125
+ const users: string[] = [];
126
+ const roles: string[] = [];
127
+ let everyone = false;
128
+ for (const item of items) {
129
+ if (item === "@everyone") {
130
+ if (!allowEveryone)
131
+ throw new ConfigError(`${name} cannot include @everyone`);
132
+ everyone = true;
133
+ continue;
134
+ }
135
+ const [kind, id] = item.includes(":") ? item.split(":", 2) : ["user", item];
136
+ if ((kind !== "user" && kind !== "role") || !/^\d{17,20}$/.test(id ?? ""))
137
+ throw new ConfigError(
138
+ `${name} must list user:<id>, role:<id>, or @everyone, got ${item}`,
139
+ );
140
+ (kind === "role" ? roles : users).push(id as string);
141
+ }
142
+ return { users, roles, everyone };
143
+ }
@@ -0,0 +1,52 @@
1
+ import { describe } from "bun:test";
2
+ import type { SQL } from "bun";
3
+ import { type Migration, migrate, openPool } from "../db/migrations.ts";
4
+
5
+ // PostgreSQL-backed tests run only when ROUNDTABLE_TEST_DATABASE_URL points at a test database.
6
+ // CI sets it; without it `bun test` skips those suites instead of failing to connect.
7
+ const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
8
+
9
+ if (process.env.CI && !url) {
10
+ throw new Error(
11
+ "ROUNDTABLE_TEST_DATABASE_URL is not set; CI must run the PostgreSQL-backed tests.",
12
+ );
13
+ }
14
+
15
+ /** The guild the stores of the tests serve. */
16
+ export const TEST_GUILD = "900000000000000001";
17
+
18
+ export const testDatabaseUrl = url ?? "";
19
+
20
+ export const describeDb = url ? describe : describe.skip;
21
+
22
+ /** A test pool with the migrations run, as the host runs them before any store attaches; the caller closes it. */
23
+ export async function migratedPool(...migrations: Migration[]): Promise<SQL> {
24
+ const sql = openPool(testDatabaseUrl);
25
+ await migrate(sql, migrations);
26
+ return sql;
27
+ }
28
+
29
+ /** A store with its own migrated test pool, as stores opened before the host owned the pool. */
30
+ export type TestStore<T> = T & { close(): Promise<void> };
31
+
32
+ /** Migrates a pool for the store alone and attaches it; closing the store closes that pool. */
33
+ export async function openTestStore<T extends object, A extends unknown[]>(
34
+ Store: {
35
+ migration: Migration;
36
+ /** The migrations to run when the store's arguments decide them, such as a guild. */
37
+ migrations?(...args: A): Migration[];
38
+ attach(sql: SQL, ...args: A): T | Promise<T>;
39
+ },
40
+ ...args: A
41
+ ): Promise<TestStore<T>> {
42
+ const sql = await migratedPool(
43
+ ...(Store.migrations?.(...args) ?? [Store.migration]),
44
+ );
45
+ try {
46
+ const store = await Store.attach(sql, ...args);
47
+ return Object.assign(store, { close: () => sql.close() });
48
+ } catch (error) {
49
+ await sql.close();
50
+ throw error;
51
+ }
52
+ }
@@ -0,0 +1,34 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+
4
+ /** The most lines a source file may have before it is split along its responsibilities. */
5
+ export const MAX_SOURCE_LINES = 500;
6
+ /** The most lines a test file may have. */
7
+ export const MAX_TEST_LINES = 700;
8
+
9
+ function typeScriptFiles(dir: string): string[] {
10
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
11
+ if (entry.name === "node_modules") return [];
12
+ const path = join(dir, entry.name);
13
+ if (entry.isDirectory()) return typeScriptFiles(path);
14
+ return /\.tsx?$/.test(entry.name) ? [path] : [];
15
+ });
16
+ }
17
+
18
+ /** The files under `dirs` over their limit, each as `<path> has <n> lines`. */
19
+ export function oversizedFiles(
20
+ root: string,
21
+ dirs: readonly string[],
22
+ ): string[] {
23
+ return dirs
24
+ .flatMap((dir) => typeScriptFiles(join(root, dir)))
25
+ .flatMap((file) => {
26
+ const lines = readFileSync(file, "utf8").split("\n").length - 1;
27
+ const limit = /\.test\.tsx?$/.test(file)
28
+ ? MAX_TEST_LINES
29
+ : MAX_SOURCE_LINES;
30
+ return lines > limit
31
+ ? [`${relative(root, file)} has ${lines} lines, over ${limit}`]
32
+ : [];
33
+ });
34
+ }
@@ -0,0 +1,8 @@
1
+ import { setLocale } from "../i18n/index.ts";
2
+ import { setTimeZone } from "../time.ts";
3
+
4
+ /** The neutral defaults the core's suite runs under; a test that changes them puts them back with this. */
5
+ export function useTestLocale(): void {
6
+ setTimeZone("UTC");
7
+ setLocale("en", { assistant: "Roundtable", root: "roundtable" });
8
+ }
@@ -0,0 +1,97 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import { modulesPlugin } from "../builtin/modules.ts";
3
+ import type { ChannelKey } from "../domain/conversation.ts";
4
+ import { silentLogger } from "../log.ts";
5
+ import type { Contribution, PluginContext } from "../plugin.ts";
6
+ import {
7
+ type CoreDiscord,
8
+ CoreRegistry,
9
+ type CoreStores,
10
+ } from "../services.ts";
11
+
12
+ /** What the modules' tools did, for a test to read. */
13
+ export interface ModuleRecord {
14
+ /** Where each delegation job's thread opened. */
15
+ threadOrigins: (ChannelKey | undefined)[];
16
+ /** The channel each delegated report came back to. */
17
+ reportChannels: ChannelKey[];
18
+ /** How often a conversation without a chat channel fell back to the owner's messages. */
19
+ ownerChannelAsked: number;
20
+ }
21
+
22
+ export const OWNER_CHANNEL: ChannelKey = "discord:owner-dm";
23
+
24
+ /** The modules plugin set up over stand-in stores and surface; returns its contribution and the record. */
25
+ export async function setUpModules(
26
+ options: { agentChannelOf?: (name: string) => ChannelKey } = {},
27
+ ): Promise<{
28
+ contribution: Contribution;
29
+ record: ModuleRecord;
30
+ core: CoreRegistry;
31
+ }> {
32
+ const record: ModuleRecord = {
33
+ threadOrigins: [],
34
+ reportChannels: [],
35
+ ownerChannelAsked: 0,
36
+ };
37
+ const core = new CoreRegistry();
38
+ // SAFETY: the tools under test read no store; each stub is asked for nothing else.
39
+ core.provide("stores", {
40
+ memory: {},
41
+ schedules: {},
42
+ confirmations: {},
43
+ agents: {},
44
+ skills: {},
45
+ } as unknown as CoreStores);
46
+ // SAFETY: the tools under test use the surface's owner channel and the threads' open only.
47
+ core.provide("discord", {
48
+ surface: {
49
+ ownerChannel: async () => {
50
+ record.ownerChannelAsked += 1;
51
+ return OWNER_CHANNEL;
52
+ },
53
+ ownerDiscord: () => ({}),
54
+ },
55
+ threads: {
56
+ open: async (origin: ChannelKey | undefined) => {
57
+ record.threadOrigins.push(origin);
58
+ return undefined;
59
+ },
60
+ },
61
+ guard: {},
62
+ cards: {},
63
+ studio: {},
64
+ } as unknown as CoreDiscord);
65
+ const plugin = modulesPlugin({
66
+ owner: {
67
+ id: "1",
68
+ name: "Owner",
69
+ pronouns: { subject: "they", object: "them", possessive: "their" },
70
+ },
71
+ assistant: "Assistant",
72
+ // SAFETY: a worker of the test's own replaces the one that would read the runtime.
73
+ modelRuntime: {} as ModelRuntime,
74
+ agentDir: "/tmp/agent",
75
+ dataDir: "/tmp/data",
76
+ delegation: {
77
+ model: { provider: "test", id: "worker" },
78
+ thinking: "low",
79
+ worker: { run: async () => "found it" },
80
+ },
81
+ ...(options.agentChannelOf
82
+ ? { agentChannelOf: options.agentChannelOf }
83
+ : {}),
84
+ });
85
+ // SAFETY: setup reads only the logger, the conversations' background, and the core services.
86
+ const context = {
87
+ logger: silentLogger(),
88
+ conversations: {
89
+ background: async (turn: { channel: ChannelKey }) => {
90
+ record.reportChannels.push(turn.channel);
91
+ return { status: "ran" };
92
+ },
93
+ },
94
+ core,
95
+ } as unknown as PluginContext;
96
+ return { contribution: await plugin.setup(context), record, core };
97
+ }
@@ -0,0 +1,15 @@
1
+ import type { OwnerIdentity } from "../identity.ts";
2
+ import type { Speaker } from "../speakers.ts";
3
+
4
+ /** The owner tests speak for; its words match the texts the tests expect. */
5
+ export const TEST_OWNER: OwnerIdentity = {
6
+ name: "Riley",
7
+ pronouns: { subject: "he", object: "him", possessive: "his" },
8
+ };
9
+
10
+ /** The owner as a speaker of the tests. */
11
+ export const OWNER_SPEAKER: Speaker = {
12
+ id: "1",
13
+ name: TEST_OWNER.name,
14
+ tier: "owner",
15
+ };
@@ -0,0 +1,60 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ DispatchThreads,
6
+ type ThreadHost,
7
+ } from "../discord/dispatch-threads.ts";
8
+ import { silentLogger } from "../log.ts";
9
+
10
+ /** A ThreadHost that records what happens; parents in `hostless` cannot host threads. */
11
+ export class FakeThreadHost implements ThreadHost {
12
+ /** Start lines by parent id, as last edited. */
13
+ readonly lines = new Map<string, string>();
14
+ readonly opened: { parentId: string; name: string; id: string }[] = [];
15
+ readonly posts: { threadId: string; text: string }[] = [];
16
+ readonly closed: string[] = [];
17
+ readonly hostless = new Set<string>();
18
+ failOpen = false;
19
+ #next = 900;
20
+
21
+ async open(
22
+ parentId: string,
23
+ name: string,
24
+ line: (thread?: string) => string,
25
+ ): Promise<string | undefined> {
26
+ if (this.hostless.has(parentId)) return undefined;
27
+ if (this.failOpen) throw new Error("Missing Permissions");
28
+ const id = String(this.#next++);
29
+ this.opened.push({ parentId, name, id });
30
+ this.lines.set(parentId, line(`<#${id}>`));
31
+ return id;
32
+ }
33
+
34
+ async post(threadId: string, text: string): Promise<void> {
35
+ this.posts.push({ threadId, text });
36
+ }
37
+
38
+ async close(threadId: string): Promise<void> {
39
+ this.closed.push(threadId);
40
+ }
41
+
42
+ /** The texts posted in one thread, in order. */
43
+ textsIn(threadId: string): string[] {
44
+ return this.posts.filter((p) => p.threadId === threadId).map((p) => p.text);
45
+ }
46
+ }
47
+
48
+ /** Dispatch threads over a fake host, with a ledger in a fresh temporary directory. */
49
+ export function fakeThreads(host = new FakeThreadHost()) {
50
+ const ledgerPath = join(
51
+ mkdtempSync(join(tmpdir(), "roundtable-threads-")),
52
+ "dispatch-threads.json",
53
+ );
54
+ const threads = new DispatchThreads({
55
+ host,
56
+ ledgerPath,
57
+ logger: silentLogger(),
58
+ });
59
+ return { host, threads, ledgerPath };
60
+ }
@@ -0,0 +1,166 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { setLocale } from "./i18n/index.ts";
3
+ import { nextRun, parseRecurrence } from "./modules/schedules/recurrence.ts";
4
+ import { scheduleToolSpecs } from "./shared/schedule-tools.ts";
5
+ import { useTestLocale } from "./testing/locale.ts";
6
+ import {
7
+ setTimeZone,
8
+ timeZone,
9
+ zonedDate,
10
+ zonedInstant,
11
+ zonedStamp,
12
+ zonedToday,
13
+ } from "./time.ts";
14
+
15
+ afterEach(useTestLocale);
16
+
17
+ describe("configured time zone", () => {
18
+ test("defaults to UTC before a host configures the process", () => {
19
+ const result = Bun.spawnSync({
20
+ cmd: [
21
+ "bun",
22
+ "-e",
23
+ 'import { timeZone } from "./src/core/time.ts"; console.log(timeZone())',
24
+ ],
25
+ });
26
+ expect(result.exitCode).toBe(0);
27
+ expect(result.stdout.toString().trim()).toBe("UTC");
28
+ });
29
+
30
+ test("rejects an invalid zone without changing the current one", () => {
31
+ setTimeZone("UTC");
32
+ expect(() => setTimeZone("Not/A_Zone")).toThrow("Not/A_Zone");
33
+ expect(timeZone()).toBe("UTC");
34
+ });
35
+
36
+ test("Taipei agrees with the former +8 arithmetic for a year-long sweep", () => {
37
+ setTimeZone("Asia/Taipei");
38
+ const start = Date.parse("2026-01-01T00:00:00Z");
39
+ for (let i = 0; i < 366; i += 1) {
40
+ const at = new Date(start + i * 86_400_000 + i * 61_000);
41
+ const old = new Date(at.getTime() + 8 * 3_600_000).toISOString();
42
+ expect(zonedDate(at)).toBe(old.slice(0, 10));
43
+ expect(zonedStamp(at)).toBe(old.slice(0, 16).replace("T", " "));
44
+ }
45
+ expect(zonedToday()).toBe(zonedDate(new Date()));
46
+ });
47
+
48
+ test("New York resolves spring gaps later and fall folds to the first occurrence", () => {
49
+ setTimeZone("America/New_York");
50
+ expect(zonedInstant("2026-03-08", "02:30").toISOString()).toBe(
51
+ "2026-03-08T07:30:00.000Z",
52
+ );
53
+ expect(zonedStamp(zonedInstant("2026-03-08", "02:30"))).toBe(
54
+ "2026-03-08 03:30",
55
+ );
56
+ expect(zonedInstant("2026-11-01", "01:30").toISOString()).toBe(
57
+ "2026-11-01T05:30:00.000Z",
58
+ );
59
+ });
60
+
61
+ test("handles +14 and half-hour offsets", () => {
62
+ setTimeZone("Pacific/Kiritimati");
63
+ expect(zonedInstant("2026-07-01", "09:15").toISOString()).toBe(
64
+ "2026-06-30T19:15:00.000Z",
65
+ );
66
+ setTimeZone("America/St_Johns");
67
+ expect(zonedInstant("2026-01-01", "09:15").toISOString()).toBe(
68
+ "2026-01-01T12:45:00.000Z",
69
+ );
70
+ expect(zonedInstant("2026-07-01", "09:15").toISOString()).toBe(
71
+ "2026-07-01T11:45:00.000Z",
72
+ );
73
+ });
74
+
75
+ test("daily, weekly, and interval recurrence stay at wall-clock time across DST", () => {
76
+ setTimeZone("America/New_York");
77
+ const before = new Date("2026-03-07T15:00:00Z");
78
+ const daily = parseRecurrence(
79
+ { time: "09:00", start_date: "2026-03-07" },
80
+ before,
81
+ );
82
+ expect(nextRun(daily, before)?.toISOString()).toBe(
83
+ "2026-03-08T13:00:00.000Z",
84
+ );
85
+ expect(
86
+ nextRun(daily, new Date("2026-03-08T13:00:00Z"))?.toISOString(),
87
+ ).toBe("2026-03-09T13:00:00.000Z");
88
+ expect(
89
+ nextRun(daily, new Date("2026-10-31T13:00:00Z"))?.toISOString(),
90
+ ).toBe("2026-11-01T14:00:00.000Z");
91
+ const weekly = parseRecurrence(
92
+ { time: "09:00", weekdays: ["sun"] },
93
+ before,
94
+ );
95
+ expect(nextRun(weekly, before)?.toISOString()).toBe(
96
+ "2026-03-08T13:00:00.000Z",
97
+ );
98
+ expect(
99
+ nextRun(weekly, new Date("2026-03-08T13:00:00Z"))?.toISOString(),
100
+ ).toBe("2026-03-15T13:00:00.000Z");
101
+ const interval = parseRecurrence(
102
+ { time: "09:00", every_days: 2, start_date: "2026-03-07" },
103
+ before,
104
+ );
105
+ expect(nextRun(interval, before)?.toISOString()).toBe(
106
+ "2026-03-09T13:00:00.000Z",
107
+ );
108
+ expect(
109
+ nextRun(
110
+ { kind: "once", date: "2026-11-01", time: "01:30" },
111
+ new Date("2026-10-31T12:00:00Z"),
112
+ )?.toISOString(),
113
+ ).toBe("2026-11-01T05:30:00.000Z");
114
+ });
115
+
116
+ test("the wording of every catalog derives from the configured zone, with none special", () => {
117
+ const errors = () => [
118
+ () => parseRecurrence({ at: "soon" }, new Date()),
119
+ () => parseRecurrence({ time: "soon" }, new Date()),
120
+ ];
121
+ const messagesFor = (locale: "en" | "zh-TW") => {
122
+ setLocale(locale, { assistant: "Roundtable", root: "roundtable" });
123
+ return errors().map((run) => {
124
+ try {
125
+ run();
126
+ } catch (error) {
127
+ return String(error);
128
+ }
129
+ throw new Error("expected the recurrence to be refused");
130
+ });
131
+ };
132
+ setTimeZone("Europe/Berlin");
133
+ expect(messagesFor("en")).toEqual([
134
+ expect.stringContaining('at must be Europe/Berlin time as "YYYY-MM-DD'),
135
+ expect.stringContaining('"HH:MM", Europe/Berlin) to repeat'),
136
+ ]);
137
+ setTimeZone("America/Argentina/Buenos_Aires");
138
+ expect(messagesFor("zh-TW")).toEqual([
139
+ expect.stringContaining('at must be Buenos Aires time as "YYYY-MM-DD'),
140
+ expect.stringContaining('"HH:MM", Buenos Aires) to repeat'),
141
+ ]);
142
+ setTimeZone("UTC");
143
+ expect(scheduleToolSpecs()[0]?.description).toContain("UTC time");
144
+ });
145
+
146
+ test("relative once scheduling preserves the second occurrence of a fall-back hour", () => {
147
+ setTimeZone("America/New_York");
148
+ const now = new Date("2026-11-01T06:10:00Z");
149
+ const once = parseRecurrence({ in_minutes: 20 }, now);
150
+ expect(nextRun(once, now)?.toISOString()).toBe("2026-11-01T06:30:00.000Z");
151
+ });
152
+
153
+ test("tool descriptions take the zone when built, in the wording of the locale", () => {
154
+ setTimeZone("Asia/Taipei");
155
+ expect(scheduleToolSpecs()[0]?.description).toContain(
156
+ "set Asia/Taipei time",
157
+ );
158
+ setLocale("zh-TW", { assistant: "Roundtable", root: "roundtable" });
159
+ expect(scheduleToolSpecs()[0]?.description).toContain("set Taipei time");
160
+ setLocale("en", { assistant: "Roundtable", root: "roundtable" });
161
+ setTimeZone("America/New_York");
162
+ expect(scheduleToolSpecs()[0]?.description).toContain(
163
+ "America/New_York time",
164
+ );
165
+ });
166
+ });
@@ -0,0 +1,67 @@
1
+ /** The host chooses one IANA zone before starting its services. */
2
+ let zone = "UTC";
3
+
4
+ export function setTimeZone(value: string): void {
5
+ try {
6
+ new Intl.DateTimeFormat("sv-SE", { timeZone: value });
7
+ } catch {
8
+ throw new Error(`Invalid time zone: ${value}`);
9
+ }
10
+ zone = value;
11
+ }
12
+
13
+ export function timeZone(): string {
14
+ return zone;
15
+ }
16
+
17
+ /** The formatter is constructed when used, not at import time, so configuration is observed. */
18
+ function stamp(at: Date): string {
19
+ return new Intl.DateTimeFormat("sv-SE", {
20
+ timeZone: zone,
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ hourCycle: "h23",
27
+ }).format(at);
28
+ }
29
+
30
+ export function zonedDate(at: Date): string {
31
+ return stamp(at).slice(0, 10);
32
+ }
33
+
34
+ export function zonedStamp(at: Date): string {
35
+ return stamp(at);
36
+ }
37
+
38
+ export function zonedToday(): string {
39
+ return zonedDate(new Date());
40
+ }
41
+
42
+ /** Convert a local minute to an instant. Choose the first occurrence in a fold and
43
+ * a later valid wall time in a gap (e.g. 02:30 becomes 03:30).
44
+ * Probe both sides of a transition: a single offset iteration can oscillate in a gap.
45
+ */
46
+ export function zonedInstant(date: string, time: string): Date {
47
+ const wall = `${date} ${time}`;
48
+ const utc = Date.parse(`${date}T${time}:00Z`);
49
+ const minute = 60_000;
50
+ const offsets = new Set<number>();
51
+ for (const delta of [-86_400_000, 0, 86_400_000]) {
52
+ const probe = utc + delta;
53
+ const local = stamp(new Date(probe));
54
+ const localAsUtc = Date.parse(`${local.replace(" ", "T")}:00Z`);
55
+ offsets.add(localAsUtc - Math.floor(probe / minute) * minute);
56
+ }
57
+ const candidates = [...offsets]
58
+ .map((offset) => new Date(utc - offset))
59
+ .sort((a, b) => a.getTime() - b.getTime());
60
+ const fallback = candidates.at(-1);
61
+ if (!fallback) throw new Error(`Could not resolve local time: ${wall}`);
62
+ return (
63
+ candidates.find((candidate) => stamp(candidate) === wall) ??
64
+ candidates.find((candidate) => stamp(candidate) > wall) ??
65
+ fallback
66
+ );
67
+ }