pi-roundtable 0.1.0 → 0.2.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 (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -1,244 +0,0 @@
1
- import {
2
- afterAll,
3
- beforeAll,
4
- beforeEach,
5
- describe,
6
- expect,
7
- test,
8
- } from "bun:test";
9
- import { SQL } from "bun";
10
- import { MemoryError } from "../../domain/errors.ts";
11
- import {
12
- describeDb,
13
- TEST_GUILD as OWNER_ID,
14
- openTestStore,
15
- type TestStore,
16
- testDatabaseUrl,
17
- } from "../../testing/database.ts";
18
- import { useTestLocale } from "../../testing/locale.ts";
19
- import { setTimeZone, zonedDate } from "../../time.ts";
20
- import { OwnerMemoryStore, searchTerms } from "./owner-memory-store.ts";
21
-
22
- // Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
23
- describeDb("PostgreSQL", () => {
24
- let store: TestStore<OwnerMemoryStore>;
25
-
26
- const facts = async () => (await store.list()).map((memory) => memory.fact);
27
-
28
- beforeAll(async () => {
29
- const admin = new SQL(testDatabaseUrl);
30
- await admin`DROP TABLE IF EXISTS owner_memory`;
31
- // The milestone 1 table, so opening it exercises the upgrade.
32
- await admin`
33
- CREATE TABLE owner_memory (
34
- id bigserial PRIMARY KEY,
35
- fact text NOT NULL CHECK (length(btrim(fact)) > 0),
36
- created_at timestamptz NOT NULL DEFAULT now()
37
- )`;
38
- await admin`INSERT INTO owner_memory (fact) VALUES ('old fact')`;
39
- await admin.close();
40
- store = await openTestStore(OwnerMemoryStore, OWNER_ID);
41
- });
42
-
43
- afterAll(async () => {
44
- await store.close();
45
- });
46
-
47
- describe("OwnerMemoryStore", () => {
48
- test("a milestone 1 row becomes a core fact", async () => {
49
- expect(await store.list()).toMatchObject([
50
- { kind: "core", fact: "old fact", eventDate: null },
51
- ]);
52
- });
53
-
54
- describe("after the upgrade", () => {
55
- beforeEach(async () => {
56
- for (const fact of await facts()) await store.remove(fact);
57
- });
58
-
59
- test("each speaker has a memory of their own, which no other reads or changes", async () => {
60
- const other = store.forSpeaker("900000000000000077");
61
- await store.add("lives in Taipei");
62
- await other.add("lives in Kaohsiung");
63
- await other.add("loves coffee", "note");
64
- expect((await store.list()).map((m) => m.fact)).toEqual([
65
- "lives in Taipei",
66
- ]);
67
- expect((await other.list()).map((m) => m.fact)).toEqual([
68
- "lives in Kaohsiung",
69
- "loves coffee",
70
- ]);
71
- expect((await store.search("coffee")).map((m) => m.fact)).toEqual([]);
72
- expect((await store.forPrompt("2026-09-30")).core).toHaveLength(1);
73
- const theirs = (await other.list())[0];
74
- expect(await store.removeById(theirs?.id ?? 0)).toBe(false);
75
- expect(
76
- await store.update(theirs?.id ?? 0, { fact: "x", kind: "core" }),
77
- ).toBeUndefined();
78
- expect(await store.remove("lives in")).toEqual(["lives in Taipei"]);
79
- expect((await other.list()).map((m) => m.fact)).toEqual([
80
- "lives in Kaohsiung",
81
- "loves coffee",
82
- ]);
83
- for (const fact of await other.list()) await other.removeById(fact.id);
84
- });
85
-
86
- test("adds facts of each kind in order and trims them", async () => {
87
- await store.add(" no coffee ");
88
- await store.add("writing a dungeon adventure", "note");
89
- await store.add("going to a whisky fair", "event", "2026-10-03");
90
- expect(await store.list()).toMatchObject([
91
- { kind: "core", fact: "no coffee", eventDate: null },
92
- {
93
- kind: "note",
94
- fact: "writing a dungeon adventure",
95
- eventDate: null,
96
- },
97
- {
98
- kind: "event",
99
- fact: "going to a whisky fair",
100
- eventDate: "2026-10-03",
101
- },
102
- ]);
103
- });
104
-
105
- test("an event needs a date, and only an event has one", async () => {
106
- expect(store.add("a meeting", "event")).rejects.toBeInstanceOf(
107
- MemoryError,
108
- );
109
- expect(store.add("a meeting", "event", "10/3")).rejects.toBeInstanceOf(
110
- MemoryError,
111
- );
112
- expect(
113
- store.add("no coffee", "core", "2026-10-03"),
114
- ).rejects.toBeInstanceOf(MemoryError);
115
- });
116
-
117
- test("the prompt carries core facts and events not yet passed, never notes", async () => {
118
- await store.add("no coffee");
119
- await store.add("writing a dungeon adventure", "note");
120
- await store.add("last week's game", "event", "2026-09-20");
121
- await store.add("today's game", "event", "2026-09-26");
122
- await store.add("next week's game", "event", "2026-10-03");
123
- const prompt = await store.forPrompt("2026-09-26");
124
- expect(prompt.core.map((m) => m.fact)).toEqual(["no coffee"]);
125
- expect(prompt.events.map((m) => m.fact)).toEqual([
126
- "today's game",
127
- "next week's game",
128
- ]);
129
- });
130
-
131
- test("search finds every kind, most matched terms first", async () => {
132
- await store.add("Obsidian knowledge base uses MOC notes", "note");
133
- await store.add("Obsidian path is ~/obsidian", "note");
134
- await store.add("last week's game", "event", "2026-09-20");
135
- await store.add("no coffee");
136
- const found = await store.search("obsidian MOC");
137
- expect(found.map((m) => m.fact)).toEqual([
138
- "Obsidian knowledge base uses MOC notes",
139
- "Obsidian path is ~/obsidian",
140
- ]);
141
- expect((await store.search("game")).map((m) => m.kind)).toEqual([
142
- "event",
143
- ]);
144
- expect(await store.search("tea")).toEqual([]);
145
- expect(store.search(" ")).rejects.toBeInstanceOf(MemoryError);
146
- });
147
-
148
- test("search treats terms literally, not as patterns", async () => {
149
- await store.add("100% sure");
150
- await store.add("1000 sure");
151
- expect((await store.search("0%")).map((m) => m.fact)).toEqual([
152
- "100% sure",
153
- ]);
154
- });
155
-
156
- test("facts survive a new connection", async () => {
157
- await store.add("lives in Taipei");
158
- const other = await openTestStore(OwnerMemoryStore, OWNER_ID);
159
- expect((await other.list()).map((m) => m.fact)).toEqual([
160
- "lives in Taipei",
161
- ]);
162
- await other.close();
163
- });
164
-
165
- test("remove deletes every fact containing the text, case-insensitively", async () => {
166
- await store.add("Prefers Bun over Node");
167
- await store.add("bun lover", "note");
168
- await store.add("no coffee");
169
- expect(await store.remove("BUN")).toEqual([
170
- "Prefers Bun over Node",
171
- "bun lover",
172
- ]);
173
- expect(await facts()).toEqual(["no coffee"]);
174
- expect(await store.remove("tea")).toEqual([]);
175
- });
176
-
177
- test("update replaces one memory's text, kind, and date under the same rules", async () => {
178
- const note = await store.add("writing a dungeon adventure", "note");
179
- const other = await store.add("no coffee");
180
- expect(
181
- await store.update(note.id, {
182
- fact: " dungeon session ",
183
- kind: "event",
184
- eventDate: "2026-10-03",
185
- }),
186
- ).toEqual({
187
- id: note.id,
188
- kind: "event",
189
- fact: "dungeon session",
190
- eventDate: "2026-10-03",
191
- });
192
- expect(
193
- await store.update(note.id, {
194
- fact: "dungeon session",
195
- kind: "note",
196
- }),
197
- ).toMatchObject({ kind: "note", eventDate: null });
198
- expect(
199
- store.update(note.id, { fact: "a meeting", kind: "event" }),
200
- ).rejects.toBeInstanceOf(MemoryError);
201
- expect(
202
- store.update(note.id, { fact: " ", kind: "note" }),
203
- ).rejects.toBeInstanceOf(MemoryError);
204
- expect(
205
- await store.update(other.id + 1000, { fact: "x", kind: "core" }),
206
- ).toBeUndefined();
207
- expect(await facts()).toEqual(["dungeon session", "no coffee"]);
208
- });
209
-
210
- test("removeById deletes exactly one memory", async () => {
211
- const first = await store.add("Prefers Bun");
212
- await store.add("Prefers Bun too");
213
- expect(await store.removeById(first.id)).toBe(true);
214
- expect(await store.removeById(first.id)).toBe(false);
215
- expect(await facts()).toEqual(["Prefers Bun too"]);
216
- });
217
-
218
- test("empty input is rejected", async () => {
219
- expect(store.add(" ")).rejects.toBeInstanceOf(MemoryError);
220
- expect(store.remove("")).rejects.toBeInstanceOf(MemoryError);
221
- });
222
- });
223
- });
224
- });
225
-
226
- describe("helpers", () => {
227
- test("search terms split on whitespace, lowercased and deduplicated", () => {
228
- expect(searchTerms(" Obsidian MOC obsidian\nknowledge ")).toEqual([
229
- "obsidian",
230
- "moc",
231
- "knowledge",
232
- ]);
233
- });
234
-
235
- test("today is the date in the configured zone", () => {
236
- setTimeZone("Asia/Taipei");
237
- try {
238
- expect(zonedDate(new Date("2026-09-26T16:30:00Z"))).toBe("2026-09-27");
239
- expect(zonedDate(new Date("2026-09-26T15:30:00Z"))).toBe("2026-09-26");
240
- } finally {
241
- useTestLocale();
242
- }
243
- });
244
- });
@@ -1,358 +0,0 @@
1
- import {
2
- afterAll,
3
- beforeAll,
4
- beforeEach,
5
- describe,
6
- expect,
7
- test,
8
- } from "bun:test";
9
- import { SQL } from "bun";
10
- import { ScheduleError } from "../../domain/errors.ts";
11
- import { messages } from "../../i18n/index.ts";
12
- import { silentLogger } from "../../log.ts";
13
- import {
14
- describeDb,
15
- openTestStore,
16
- type TestStore,
17
- testDatabaseUrl,
18
- } from "../../testing/database.ts";
19
- import { useTestLocale } from "../../testing/locale.ts";
20
- import { setTimeZone, zonedStamp } from "../../time.ts";
21
- import { describeRecurrence, nextRun, parseRecurrence } from "./recurrence.ts";
22
- import { type Schedule, ScheduleStore } from "./schedule-store.ts";
23
- import {
24
- callScheduleTool,
25
- SCHEDULE_LIMITS,
26
- type ScheduleToolContext,
27
- scheduledTurnText,
28
- } from "./schedule-tools.ts";
29
- import { type ScheduledOutcome, Scheduler } from "./scheduler.ts";
30
-
31
- const taipei = (stamp: string) =>
32
- new Date(`${stamp.replace(" ", "T")}:00+08:00`);
33
-
34
- // The schedules below are written in Taipei time; the suite's other files keep the neutral zone.
35
- beforeAll(() => setTimeZone("Asia/Taipei"));
36
- afterAll(useTestLocale);
37
-
38
- describe("recurrence", () => {
39
- const now = taipei("2026-09-26 23:30");
40
-
41
- test("a one-time run is the given Taipei time, and none once it has passed", () => {
42
- const once = parseRecurrence({ at: "2026-09-27 09:00" }, now);
43
- expect(nextRun(once, now)).toEqual(taipei("2026-09-27 09:00"));
44
- expect(nextRun(once, taipei("2026-09-27 09:00"))).toBeUndefined();
45
- });
46
-
47
- test("in_minutes is one run that many minutes ahead, rounded up to the minute", () => {
48
- const at = new Date(taipei("2026-09-26 23:58").getTime() + 30_000);
49
- const relative = parseRecurrence({ in_minutes: 2 }, at);
50
- expect(relative).toEqual({
51
- kind: "once",
52
- date: "2026-09-27",
53
- time: "00:01",
54
- });
55
- expect(() =>
56
- parseRecurrence({ in_minutes: 2, at: "2026-09-27 09:00" }, at),
57
- ).toThrow(ScheduleError);
58
- expect(() => parseRecurrence({ in_minutes: 0 }, at)).toThrow(ScheduleError);
59
- });
60
-
61
- test("every n days counts from the start date and never returns the current instant", () => {
62
- const every = parseRecurrence(
63
- { time: "09:00", every_days: 3, start_date: "2026-09-16" },
64
- now,
65
- );
66
- expect(nextRun(every, now)).toEqual(taipei("2026-09-28 09:00"));
67
- expect(nextRun(every, taipei("2026-09-28 09:00"))).toEqual(
68
- taipei("2026-10-01 09:00"),
69
- );
70
- expect(describeRecurrence(every)).toBe(
71
- messages().scheduleEveryDays(3, "09:00", "2026-09-16"),
72
- );
73
- });
74
-
75
- test("daily defaults to starting today, so a time still ahead runs today", () => {
76
- const daily = parseRecurrence({ time: "23:45" }, now);
77
- expect(nextRun(daily, now)).toEqual(taipei("2026-09-26 23:45"));
78
- expect(describeRecurrence(daily)).toBe(messages().scheduleDaily("23:45"));
79
- });
80
-
81
- test("weekly runs on the listed weekdays", () => {
82
- // 2026-09-26 is a Saturday.
83
- const weekly = parseRecurrence(
84
- { time: "08:00", weekdays: ["wed", "mon"] },
85
- now,
86
- );
87
- expect(nextRun(weekly, now)).toEqual(taipei("2026-09-28 08:00"));
88
- expect(nextRun(weekly, taipei("2026-09-28 08:00"))).toEqual(
89
- taipei("2026-09-30 08:00"),
90
- );
91
- expect(describeRecurrence(weekly)).toBe(
92
- messages().scheduleWeekly(
93
- [messages().scheduleWeekday("mon"), messages().scheduleWeekday("wed")],
94
- "08:00",
95
- ),
96
- );
97
- });
98
-
99
- test("bad timing is refused with a message the model can act on", () => {
100
- const cases = [
101
- {},
102
- { at: "2026-02-30 09:00" },
103
- { at: "tomorrow" },
104
- { at: "2026-09-27 09:00", time: "09:00" },
105
- { time: "25:00" },
106
- { time: "09:00", every_days: 0 },
107
- { time: "09:00", weekdays: ["someday"] },
108
- { time: "09:00", weekdays: ["mon"], every_days: 2 },
109
- ];
110
- for (const input of cases)
111
- expect(() => parseRecurrence(input, now)).toThrow(ScheduleError);
112
- });
113
- });
114
-
115
- // Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
116
- describeDb("PostgreSQL", () => {
117
- let store: TestStore<ScheduleStore>;
118
-
119
- beforeAll(async () => {
120
- const admin = new SQL(testDatabaseUrl);
121
- await admin`DROP TABLE IF EXISTS schedules`;
122
- await admin.close();
123
- store = await openTestStore(ScheduleStore);
124
- });
125
-
126
- afterAll(async () => {
127
- await store.close();
128
- });
129
-
130
- beforeEach(async () => {
131
- const admin = new SQL(testDatabaseUrl);
132
- await admin`TRUNCATE schedules`;
133
- await admin.close();
134
- });
135
-
136
- const now = taipei("2026-09-26 23:30");
137
- const ctx = (
138
- over: Partial<ScheduleToolContext> = {},
139
- ): ScheduleToolContext => ({
140
- store,
141
- channel: "discord:a",
142
- mode: "party",
143
- author: { id: "u1", name: "Sam" },
144
- now,
145
- ...over,
146
- });
147
-
148
- describe("schedule tools", () => {
149
- test("a schedule keeps its creator's tier, and a lower tier cannot change it", async () => {
150
- const admin = { id: "u2", name: "Ada", tier: "admin" } as const;
151
- const member = { id: "u3", name: "Max", tier: "member" } as const;
152
- await callScheduleTool(ctx({ author: admin }), "schedule_create", {
153
- title: "patrol",
154
- prompt: "check the disk",
155
- time: "09:00",
156
- every_days: 1,
157
- });
158
- const [schedule] = await store.forChannel("discord:a");
159
- const id = schedule?.id ?? 0;
160
- expect(schedule?.createdTier).toBe("admin");
161
- for (const tool of ["schedule_update", "schedule_cancel"] as const)
162
- await expect(
163
- callScheduleTool(ctx({ author: member }), tool, {
164
- id,
165
- title: "renamed",
166
- }),
167
- ).rejects.toThrow(/higher tier/);
168
- await callScheduleTool(ctx({ author: admin }), "schedule_update", {
169
- id,
170
- title: "renamed",
171
- });
172
- expect((await store.get(id))?.title).toBe("renamed");
173
- // The owner's own schedules default to the owner's tier.
174
- await callScheduleTool(ctx(), "schedule_create", {
175
- title: "morning report",
176
- prompt: "report",
177
- time: "10:00",
178
- every_days: 1,
179
- });
180
- const owners = await store.forChannel("discord:a");
181
- expect(owners.map((x) => x.createdTier).sort()).toEqual([
182
- "admin",
183
- "owner",
184
- ]);
185
- });
186
-
187
- test("create, list, update, and cancel act on the channel's schedules", async () => {
188
- const created = await callScheduleTool(ctx(), "schedule_create", {
189
- title: "jobs",
190
- prompt: "find pastry shop openings",
191
- time: "09:00",
192
- every_days: 3,
193
- start_date: "2026-09-16",
194
- });
195
- expect(created).toContain("first run 2026-09-28 09:00");
196
- const [schedule] = await store.forChannel("discord:a");
197
- expect(schedule?.createdByName).toBe("Sam");
198
- expect(schedule?.mode).toBe("party");
199
-
200
- const id = schedule?.id ?? 0;
201
- expect(await callScheduleTool(ctx(), "schedule_list", {})).toContain(
202
- `#${id} jobs`,
203
- );
204
- await callScheduleTool(ctx(), "schedule_update", {
205
- id,
206
- prompt: "find pastry shop openings; already seen: Acme",
207
- });
208
- expect(await callScheduleTool(ctx(), "schedule_list", { id })).toContain(
209
- "already seen: Acme",
210
- );
211
- // Changing only the prompt keeps the timing.
212
- expect((await store.get(id))?.nextRun).toEqual(
213
- taipei("2026-09-28 09:00"),
214
- );
215
-
216
- await callScheduleTool(ctx(), "schedule_update", { id, time: "20:00" });
217
- expect((await store.get(id))?.nextRun).toEqual(
218
- taipei("2026-09-27 20:00"),
219
- );
220
-
221
- expect(
222
- callScheduleTool(ctx({ channel: "discord:b" }), "schedule_cancel", {
223
- id,
224
- }),
225
- ).rejects.toThrow(ScheduleError);
226
- await callScheduleTool(ctx(), "schedule_cancel", { id });
227
- expect(await store.forChannel("discord:a")).toEqual([]);
228
- });
229
-
230
- test("a party channel is limited in count, prompt size, and distance", async () => {
231
- const { perChannel, promptChars } = SCHEDULE_LIMITS.party;
232
- for (let i = 0; i < perChannel; i += 1)
233
- await callScheduleTool(ctx(), "schedule_create", {
234
- title: `t${i}`,
235
- prompt: "p",
236
- time: "10:00",
237
- });
238
- expect(
239
- callScheduleTool(ctx(), "schedule_create", {
240
- title: "one more",
241
- prompt: "p",
242
- time: "10:00",
243
- }),
244
- ).rejects.toThrow("cancel one first");
245
- expect(
246
- callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
247
- title: "long",
248
- prompt: "x".repeat(promptChars + 1),
249
- time: "10:00",
250
- }),
251
- ).rejects.toThrow(`within ${promptChars}`);
252
- expect(
253
- callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
254
- title: "far",
255
- prompt: "p",
256
- at: "2027-09-26 10:00",
257
- }),
258
- ).rejects.toThrow("within 90 days");
259
- expect(
260
- callScheduleTool(ctx({ channel: "discord:b" }), "schedule_create", {
261
- title: "past",
262
- prompt: "p",
263
- at: "2026-09-26 10:00",
264
- }),
265
- ).rejects.toThrow("already passed");
266
- });
267
-
268
- test("the scheduled message names the schedule and carries its prompt", async () => {
269
- await callScheduleTool(ctx(), "schedule_create", {
270
- title: "drink water",
271
- prompt: "remind Sam to drink water",
272
- at: "2026-09-27 09:00",
273
- });
274
- const [schedule] = await store.forChannel("discord:a");
275
- if (!schedule) throw new Error("missing schedule");
276
- const text = scheduledTurnText(schedule, taipei("2026-09-27 09:00"));
277
- expect(text).toContain(`#${schedule.id}: drink water`);
278
- expect(text).toContain("2026-09-27 09:00");
279
- expect(text.endsWith("remind Sam to drink water")).toBe(true);
280
- });
281
- });
282
-
283
- describe("scheduler", () => {
284
- function scheduler(
285
- clock: { now: Date },
286
- fired: Schedule[],
287
- outcome: ScheduledOutcome,
288
- ) {
289
- return new Scheduler({
290
- store,
291
- runner: {
292
- runScheduled: async (schedule) => {
293
- fired.push(schedule);
294
- return outcome;
295
- },
296
- },
297
- logger: silentLogger(),
298
- now: () => clock.now,
299
- });
300
- }
301
-
302
- test("a due one-time schedule fires once and is deleted", async () => {
303
- await callScheduleTool(ctx(), "schedule_create", {
304
- title: "once",
305
- prompt: "p",
306
- at: "2026-09-27 09:00",
307
- });
308
- const clock = { now: taipei("2026-09-27 08:59") };
309
- const fired: Schedule[] = [];
310
- const s = scheduler(clock, fired, { status: "ran" });
311
- await s.tick();
312
- expect(fired).toHaveLength(0);
313
- clock.now = taipei("2026-09-27 09:00");
314
- await s.tick();
315
- await s.tick();
316
- await s.idle();
317
- expect(fired.map((f) => f.title)).toEqual(["once"]);
318
- expect(await store.forChannel("discord:a")).toEqual([]);
319
- });
320
-
321
- test("a repeating schedule moves to its next run and keeps the outcome", async () => {
322
- await callScheduleTool(ctx(), "schedule_create", {
323
- title: "daily",
324
- prompt: "p",
325
- time: "09:00",
326
- });
327
- const clock = { now: taipei("2026-09-27 09:00") };
328
- const fired: Schedule[] = [];
329
- const s = scheduler(clock, fired, {
330
- status: "skipped",
331
- reason: "party mode is off",
332
- });
333
- await s.tick();
334
- await s.idle();
335
- const [after] = await store.forChannel("discord:a");
336
- expect(fired).toHaveLength(1);
337
- expect(after && zonedStamp(after.nextRun)).toBe("2026-09-28 09:00");
338
- expect(after?.lastStatus).toBe("skipped: party mode is off");
339
- });
340
-
341
- test("a run found long after it was due is skipped, not caught up", async () => {
342
- await callScheduleTool(ctx(), "schedule_create", {
343
- title: "daily",
344
- prompt: "p",
345
- time: "09:00",
346
- });
347
- const clock = { now: taipei("2026-09-29 12:00") };
348
- const fired: Schedule[] = [];
349
- const s = scheduler(clock, fired, { status: "ran" });
350
- await s.tick();
351
- await s.idle();
352
- const [after] = await store.forChannel("discord:a");
353
- expect(fired).toHaveLength(0);
354
- expect(after && zonedStamp(after.nextRun)).toBe("2026-09-30 09:00");
355
- expect(after?.lastStatus).toBe("skipped: missed while offline");
356
- });
357
- });
358
- });
@@ -1,55 +0,0 @@
1
- import { afterEach, beforeEach, expect, test } from "bun:test";
2
- import { SQL } from "bun";
3
- import { migrate } from "../../db/migrations.ts";
4
- import {
5
- describeDb,
6
- migratedPool,
7
- TEST_GUILD,
8
- testDatabaseUrl,
9
- } from "../../testing/database.ts";
10
- import { LEGACY_WRITTEN_KIND, SkillStore } from "./skill-store.ts";
11
-
12
- // Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
13
- describeDb("the written skill kind", () => {
14
- let sql: SQL;
15
-
16
- beforeEach(async () => {
17
- const admin = new SQL(testDatabaseUrl);
18
- for (const table of ["skills", "skill_groups", "agent_skills"])
19
- await admin.unsafe(`DROP TABLE IF EXISTS ${table}`);
20
- await admin.close();
21
- // The tables as they were when an agent-written skill was stored under the legacy kind.
22
- sql = await migratedPool(SkillStore.migration);
23
- await sql`INSERT INTO skills (name, kind, description, body)
24
- VALUES ('notes', ${LEGACY_WRITTEN_KIND}, 'Take notes.', 'Body.')`;
25
- await sql`INSERT INTO skills (name, kind, repo, path)
26
- VALUES ('linked-one', 'linked', 'owner/repo', 'skills/linked-one')`;
27
- });
28
-
29
- afterEach(async () => {
30
- await sql.close();
31
- });
32
-
33
- const kinds = async () =>
34
- (await sql`SELECT name, kind FROM skills ORDER BY name`) as {
35
- name: string;
36
- kind: string;
37
- }[];
38
-
39
- test("a legacy row becomes written, a linked row is left alone, and the store reads it", async () => {
40
- await migrate(sql, SkillStore.migrations(TEST_GUILD));
41
- expect(await kinds()).toEqual([
42
- { name: "linked-one", kind: "linked" },
43
- { name: "notes", kind: "written" },
44
- ]);
45
- const store = await SkillStore.attach(sql, TEST_GUILD);
46
- expect(store.skill("notes")?.kind).toBe("written");
47
- });
48
-
49
- test("running the migration again changes nothing", async () => {
50
- await migrate(sql, SkillStore.migrations(TEST_GUILD));
51
- const before = await kinds();
52
- await migrate(sql, SkillStore.migrations(TEST_GUILD));
53
- expect(await kinds()).toEqual(before);
54
- });
55
- });