pi-roundtable 0.1.0 → 0.2.1

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 (239) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/README.zh-TW.md +144 -0
  4. package/docs/plugins.md +1448 -66
  5. package/examples/echo-runtime.test.ts +122 -0
  6. package/examples/echo-runtime.ts +107 -0
  7. package/examples/events.test.ts +1 -0
  8. package/examples/events.ts +2 -2
  9. package/examples/fake-surface.test.ts +180 -0
  10. package/examples/fake-surface.ts +109 -0
  11. package/examples/interactions.test.ts +12 -3
  12. package/examples/interactions.ts +19 -19
  13. package/examples/shared-services.test.ts +74 -0
  14. package/examples/shared-services.ts +60 -0
  15. package/examples/study-room.test.ts +69 -0
  16. package/examples/study-room.ts +52 -0
  17. package/examples/support-desk.test.ts +34 -0
  18. package/examples/support-desk.ts +40 -0
  19. package/package.json +11 -3
  20. package/src/cli/checks/database.ts +11 -30
  21. package/src/cli/checks/images.ts +19 -0
  22. package/src/cli/doctor.ts +6 -0
  23. package/src/cli/main.ts +2 -1
  24. package/src/core/agents/agent-claim.ts +42 -16
  25. package/src/core/agents/agent-messages.ts +4 -4
  26. package/src/core/agents/agent-ports.ts +8 -21
  27. package/src/core/agents/agent-prompt.ts +3 -1
  28. package/src/core/agents/agent-store.ts +6 -4
  29. package/src/core/agents/agent-team-fixture.ts +389 -0
  30. package/src/core/agents/agent-team.ts +26 -25
  31. package/src/core/agents/agent-tools.ts +34 -15
  32. package/src/core/agents/avatar-studio.ts +40 -6
  33. package/src/core/agents/fallback-avatar.ts +85 -0
  34. package/src/core/agents/team-editing.ts +49 -10
  35. package/src/core/agents/team-keys.ts +22 -9
  36. package/src/core/agents/team-layout.ts +4 -3
  37. package/src/core/agents/team-lifecycle.ts +28 -2
  38. package/src/core/agents/team-options.ts +9 -5
  39. package/src/core/agents/team-status.ts +4 -4
  40. package/src/core/agents/team-text.ts +8 -6
  41. package/src/core/agents/team-turn-types.ts +4 -4
  42. package/src/core/agents/team-turns.ts +6 -5
  43. package/src/core/builtin/agent-server.ts +187 -121
  44. package/src/core/builtin/discord-admin.ts +41 -0
  45. package/src/core/builtin/discord.ts +65 -40
  46. package/src/core/builtin/modules.ts +67 -58
  47. package/src/core/builtin/session-tool.ts +24 -0
  48. package/src/core/builtin/skills.ts +72 -0
  49. package/src/core/builtin/stores.ts +55 -31
  50. package/src/core/config/config.ts +45 -4
  51. package/src/core/config/schema.ts +6 -0
  52. package/src/core/contract/channels.ts +54 -14
  53. package/src/core/contract/providers.ts +17 -0
  54. package/src/core/contract/runtime.ts +138 -0
  55. package/src/core/contract/services.ts +45 -0
  56. package/src/core/contract/surface.ts +88 -0
  57. package/src/core/db/migrations.ts +118 -3
  58. package/src/core/define-roundtable.ts +70 -46
  59. package/src/core/define.ts +2 -1
  60. package/src/core/discord/agent-commands.ts +39 -14
  61. package/src/core/discord/agent-panel.ts +66 -0
  62. package/src/core/discord/channel-executor.ts +6 -3
  63. package/src/core/discord/channel-operations.ts +14 -11
  64. package/src/core/discord/command-collection.ts +46 -0
  65. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  66. package/src/core/discord/connection.ts +35 -0
  67. package/src/core/discord/discord-surface.ts +32 -94
  68. package/src/core/discord/dispatch-threads.ts +2 -2
  69. package/src/core/discord/inbound-message.ts +99 -0
  70. package/src/core/discord/interaction-module.ts +57 -1
  71. package/src/core/discord/owner-cards.ts +4 -4
  72. package/src/core/discord/owner-command.ts +44 -11
  73. package/src/core/discord/owner-discord.ts +2 -2
  74. package/src/core/discord/schedule-commands.ts +21 -18
  75. package/src/core/discord/stop-button.ts +44 -1
  76. package/src/core/domain/attachment.ts +12 -8
  77. package/src/core/domain/conversation.ts +3 -4
  78. package/src/core/domain/owner-prompts.ts +5 -5
  79. package/src/core/domain/ports.ts +9 -53
  80. package/src/core/freeze.ts +9 -0
  81. package/src/core/host.ts +255 -82
  82. package/src/core/http/listeners.ts +55 -20
  83. package/src/core/i18n/agent-panel.ts +4 -0
  84. package/src/core/i18n/index.ts +12 -2
  85. package/src/core/i18n/owner.ts +7 -1
  86. package/src/core/i18n/schedules.ts +12 -6
  87. package/src/core/identity.ts +1 -1
  88. package/src/core/judging/effort-judge.ts +25 -3
  89. package/src/core/log.ts +21 -3
  90. package/src/core/models.ts +2 -2
  91. package/src/core/modules/background/background-turns.ts +6 -4
  92. package/src/core/modules/delegation/delegate.ts +3 -2
  93. package/src/core/modules/delegation/delegator.ts +15 -13
  94. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  95. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  96. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  97. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  98. package/src/core/modules/memory/owner-memory.ts +9 -13
  99. package/src/core/modules/schedules/recurrence.ts +5 -3
  100. package/src/core/modules/schedules/schedule-store.ts +11 -11
  101. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  102. package/src/core/modules/schedules/scheduler.ts +7 -2
  103. package/src/core/modules/schedules/schedules.ts +9 -3
  104. package/src/core/modules/skills/skill-registry.ts +3 -2
  105. package/src/core/modules/skills/skill-store.ts +2 -15
  106. package/src/core/modules/skills/skill-tools.ts +10 -4
  107. package/src/core/ops/error-reporter.ts +1 -1
  108. package/src/core/plugin.ts +232 -28
  109. package/src/core/registry/contributions.ts +173 -14
  110. package/src/core/registry/providers.ts +35 -5
  111. package/src/core/registry/services.ts +229 -0
  112. package/src/core/routing/channel-queue.ts +5 -0
  113. package/src/core/routing/channel-router.ts +21 -7
  114. package/src/core/routing/conversation-turns.ts +139 -0
  115. package/src/core/routing/message-text.ts +9 -2
  116. package/src/core/routing/surface-port.ts +39 -0
  117. package/src/core/runtime/conversation-sessions.ts +4 -3
  118. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  119. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  120. package/src/core/runtime/mcp.ts +1 -1
  121. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  122. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  123. package/src/core/runtime/prompt-slot.ts +8 -3
  124. package/src/core/runtime/runtime-types.ts +9 -35
  125. package/src/core/runtime/session-factory.ts +21 -5
  126. package/src/core/runtime/text-tools.ts +1 -3
  127. package/src/core/services.ts +238 -90
  128. package/src/core/sessions.ts +3 -2
  129. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  130. package/src/core/shared/delegate-tool.ts +4 -3
  131. package/src/core/shared/schedule-tools.ts +22 -13
  132. package/src/core/shared/session-messages.ts +1 -1
  133. package/src/core/speakers.ts +6 -38
  134. package/src/core/testing/database.ts +1 -7
  135. package/src/core/testing/eager-catalog.ts +30 -0
  136. package/src/core/testing/locale.ts +23 -5
  137. package/src/core/testing/modules.ts +105 -41
  138. package/src/core/testing/test-host.ts +279 -0
  139. package/src/core/testing/tool-set.ts +64 -0
  140. package/src/core/time.ts +6 -1
  141. package/src/core/tool-set.snapshot.json +288 -0
  142. package/src/core/tool-tiers.ts +17 -57
  143. package/src/discord/index.ts +64 -0
  144. package/src/index.ts +181 -10
  145. package/src/kit/channels.ts +14 -0
  146. package/src/kit/domain.ts +18 -0
  147. package/src/kit/holds.ts +4 -0
  148. package/src/kit/index.ts +129 -0
  149. package/src/kit/judging.ts +10 -0
  150. package/src/kit/memory.ts +3 -0
  151. package/src/kit/mirror.ts +19 -0
  152. package/src/kit/presentation.ts +7 -0
  153. package/src/kit/shell.ts +7 -0
  154. package/src/kit/skills.ts +8 -0
  155. package/src/kit/support.ts +21 -0
  156. package/src/kit/threads.ts +7 -0
  157. package/src/kit/tools.ts +14 -0
  158. package/src/kit/worker.ts +16 -0
  159. package/src/testing.ts +394 -34
  160. package/examples/guide.test.ts +0 -52
  161. package/src/cli/add-plugin.test.ts +0 -107
  162. package/src/cli/checks/basic.test.ts +0 -276
  163. package/src/cli/checks/database.test.ts +0 -117
  164. package/src/cli/checks/discord.test.ts +0 -200
  165. package/src/cli/cli.test.ts +0 -122
  166. package/src/cli/config-edit.test.ts +0 -130
  167. package/src/cli/doctor.test.ts +0 -184
  168. package/src/cli/init.test.ts +0 -127
  169. package/src/cli/size.test.ts +0 -9
  170. package/src/cli/templates.test.ts +0 -129
  171. package/src/cli/testing/fixtures.ts +0 -106
  172. package/src/core/agents/agent-claim.test.ts +0 -142
  173. package/src/core/agents/agent-dashboard.test.ts +0 -120
  174. package/src/core/agents/agent-guild.test.ts +0 -124
  175. package/src/core/agents/agent-store.test.ts +0 -205
  176. package/src/core/agents/avatar-studio.test.ts +0 -88
  177. package/src/core/agents/group-round.test.ts +0 -143
  178. package/src/core/agents/owner-identity.test.ts +0 -172
  179. package/src/core/agents/team-turns.test.ts +0 -278
  180. package/src/core/attachments/attachments.test.ts +0 -85
  181. package/src/core/boundary.test.ts +0 -45
  182. package/src/core/builtin/modules.test.ts +0 -127
  183. package/src/core/config/config.test.ts +0 -135
  184. package/src/core/contract/discord.ts +0 -33
  185. package/src/core/db/migrations.test.ts +0 -237
  186. package/src/core/define-roundtable.test.ts +0 -134
  187. package/src/core/define.test.ts +0 -144
  188. package/src/core/discord/agent-commands.test.ts +0 -58
  189. package/src/core/discord/agent-discord.test.ts +0 -70
  190. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  191. package/src/core/discord/dispatch-threads.test.ts +0 -94
  192. package/src/core/discord/owner-cards.test.ts +0 -435
  193. package/src/core/discord/owner-discord.test.ts +0 -325
  194. package/src/core/domain/expression.ts +0 -21
  195. package/src/core/domain/profile.ts +0 -32
  196. package/src/core/drain.test.ts +0 -46
  197. package/src/core/events.test.ts +0 -81
  198. package/src/core/holds.test.ts +0 -54
  199. package/src/core/host.test.ts +0 -536
  200. package/src/core/http/listeners.test.ts +0 -164
  201. package/src/core/i18n/i18n.test.ts +0 -139
  202. package/src/core/identity.test.ts +0 -18
  203. package/src/core/judging/effort-judge.test.ts +0 -112
  204. package/src/core/judging/model-judge.test.ts +0 -126
  205. package/src/core/modules/delegation/delegator.test.ts +0 -170
  206. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  207. package/src/core/modules/schedules/schedule.test.ts +0 -358
  208. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  209. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  210. package/src/core/ops/error-reporter.test.ts +0 -283
  211. package/src/core/presentation/card-cadence.ts +0 -41
  212. package/src/core/presentation/presentation.test.ts +0 -166
  213. package/src/core/public-entry.test.ts +0 -59
  214. package/src/core/registry/contributions.test.ts +0 -215
  215. package/src/core/registry/interactions.test.ts +0 -87
  216. package/src/core/registry/providers.test.ts +0 -103
  217. package/src/core/routing/channel-queue.test.ts +0 -31
  218. package/src/core/routing/channel-router.test.ts +0 -326
  219. package/src/core/routing/conversation-kind.ts +0 -14
  220. package/src/core/routing/settle-turn.test.ts +0 -21
  221. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  222. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  223. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  224. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  225. package/src/core/runtime/session-archive.test.ts +0 -20
  226. package/src/core/runtime/steerable-run.test.ts +0 -264
  227. package/src/core/runtime/text-tools.test.ts +0 -110
  228. package/src/core/runtime/turn-answer.test.ts +0 -72
  229. package/src/core/runtime/worker-task.test.ts +0 -82
  230. package/src/core/services.test.ts +0 -28
  231. package/src/core/sessions.test.ts +0 -85
  232. package/src/core/shared/unix-server.ts +0 -18
  233. package/src/core/size.test.ts +0 -9
  234. package/src/core/speakers.test.ts +0 -78
  235. package/src/core/testing/file-size.ts +0 -34
  236. package/src/core/time.test.ts +0 -166
  237. package/src/core/tool-tiers.test.ts +0 -117
  238. package/src/entries.test.ts +0 -112
  239. package/src/testing.test.ts +0 -156
@@ -3,15 +3,9 @@ import type { SQL } from "bun";
3
3
  import { type Migration, migrate, openPool } from "../db/migrations.ts";
4
4
 
5
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.
6
+ // Repository CI requires it in the workflow; consumers without it skip these suites.
7
7
  const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
8
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
9
  /** The guild the stores of the tests serve. */
16
10
  export const TEST_GUILD = "900000000000000001";
17
11
 
@@ -0,0 +1,30 @@
1
+ import { messages, setLocale } from "../i18n/index.ts";
2
+ import { setTimeZone } from "../time.ts";
3
+
4
+ const EAGER = "EAGER:";
5
+
6
+ /**
7
+ * Puts the process under a catalog whose every text carries a mark and a foreign time zone, so a
8
+ * value built before the host applied its environment can be recognised with `eagerText`;
9
+ * `useTestLocale()` gives the neutral defaults back.
10
+ */
11
+ export function useEagerCatalog(): void {
12
+ setTimeZone("America/New_York");
13
+ setLocale("en", { assistant: "Before", root: "before" });
14
+ const marked: Record<string, string> = {};
15
+ for (const [key, value] of Object.entries(messages()))
16
+ if (typeof value === "string") marked[key] = `${EAGER}${key}`;
17
+ setLocale("en", {
18
+ assistant: "Before",
19
+ root: "before",
20
+ overrides: marked as Partial<ReturnType<typeof messages>>,
21
+ });
22
+ }
23
+
24
+ /** Every marked string under the value; functions are not called, so lazy text stays hidden. */
25
+ export function eagerText(value: unknown, seen = new Set<unknown>()): string[] {
26
+ if (typeof value === "string") return value.startsWith(EAGER) ? [value] : [];
27
+ if (typeof value !== "object" || value === null || seen.has(value)) return [];
28
+ seen.add(value);
29
+ return Object.values(value).flatMap((child) => eagerText(child, seen));
30
+ }
@@ -1,8 +1,26 @@
1
- import { setLocale } from "../i18n/index.ts";
1
+ import { type Locale, setLocale } from "../i18n/index.ts";
2
2
  import { setTimeZone } from "../time.ts";
3
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" });
4
+ /** What a test file runs under; the core's own suite uses the neutral defaults. */
5
+ export interface TestLocale {
6
+ locale?: Locale;
7
+ /** An IANA time zone. */
8
+ timeZone?: string;
9
+ /** The assistant's display name in catalog text. */
10
+ assistant?: string;
11
+ /** The root slash command's name, without the slash. */
12
+ root?: string;
13
+ }
14
+
15
+ /**
16
+ * Puts the process under a locale and time zone, UTC and English with neutral names unless given.
17
+ * Call it with nothing to hand back the neutral defaults a test that changed them found. It takes
18
+ * a defaulted object so a hook can receive it without waiting for a callback.
19
+ */
20
+ export function useTestLocale(options: TestLocale = {}): void {
21
+ setTimeZone(options.timeZone ?? "UTC");
22
+ setLocale(options.locale ?? "en", {
23
+ assistant: options.assistant ?? "Roundtable",
24
+ root: options.root ?? "roundtable",
25
+ });
8
26
  }
@@ -1,13 +1,24 @@
1
1
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import type { SQL } from "bun";
3
+ import { OWNER_TARGET } from "../agents/agent-claim.ts";
4
+ import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
5
+ import { discordAdminPlugin } from "../builtin/discord-admin.ts";
2
6
  import { modulesPlugin } from "../builtin/modules.ts";
7
+ import { skillsPlugin } from "../builtin/skills.ts";
8
+ import { memoryPlugin } from "../builtin/stores.ts";
3
9
  import type { ChannelKey } from "../domain/conversation.ts";
4
10
  import { silentLogger } from "../log.ts";
5
- import type { Contribution, PluginContext } from "../plugin.ts";
11
+ import type { SkillStore } from "../modules/skills/skill-store.ts";
6
12
  import {
7
- type CoreDiscord,
8
- CoreRegistry,
9
- type CoreStores,
10
- } from "../services.ts";
13
+ type Contribution,
14
+ type PluginContext,
15
+ pluginContext,
16
+ type RoundtablePlugin,
17
+ } from "../plugin.ts";
18
+ import { ServiceRegistry } from "../registry/services.ts";
19
+ import { surfacePort } from "../routing/surface-port.ts";
20
+ import type { AgentServer, MemoryStore, ScheduleStore } from "../services.ts";
21
+ import { AGENTS, MEMORY, SCHEDULES } from "../services.ts";
11
22
 
12
23
  /** What the modules' tools did, for a test to read. */
13
24
  export interface ModuleRecord {
@@ -27,41 +38,13 @@ export async function setUpModules(
27
38
  ): Promise<{
28
39
  contribution: Contribution;
29
40
  record: ModuleRecord;
30
- core: CoreRegistry;
41
+ services: ServiceRegistry;
31
42
  }> {
32
43
  const record: ModuleRecord = {
33
44
  threadOrigins: [],
34
45
  reportChannels: [],
35
46
  ownerChannelAsked: 0,
36
47
  };
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
48
  const plugin = modulesPlugin({
66
49
  owner: {
67
50
  id: "1",
@@ -78,20 +61,101 @@ export async function setUpModules(
78
61
  thinking: "low",
79
62
  worker: { run: async () => "found it" },
80
63
  },
81
- ...(options.agentChannelOf
82
- ? { agentChannelOf: options.agentChannelOf }
83
- : {}),
84
64
  });
85
- // SAFETY: setup reads only the logger, the conversations' background, and the core services.
65
+ const services = new ServiceRegistry([plugin]);
66
+ // SAFETY: the tools under test read no store; each stub is asked for nothing else.
67
+ services.preset(MEMORY, {} as MemoryStore);
68
+ services.preset(SCHEDULES, {} as ScheduleStore);
69
+ // SAFETY: the schedule tools ask the agent team for a channel and nothing else.
70
+ if (options.agentChannelOf)
71
+ services.preset(AGENTS, {
72
+ team: { channelOf: options.agentChannelOf },
73
+ } as unknown as AgentServer);
74
+ // SAFETY: the tools under test use the connection's owner channel and the threads' open only.
75
+ services.preset(DISCORD, {
76
+ connection: {
77
+ ownerChannel: async () => {
78
+ record.ownerChannelAsked += 1;
79
+ return OWNER_CHANNEL;
80
+ },
81
+ ownerOperations: () => ({}),
82
+ },
83
+ threads: {
84
+ open: async (origin: ChannelKey | undefined) => {
85
+ record.threadOrigins.push(origin);
86
+ return undefined;
87
+ },
88
+ },
89
+ guard: {},
90
+ commands: { add: () => undefined },
91
+ } as unknown as DiscordServices);
92
+ // SAFETY: setup reads only the logger, the conversations' background and targets, the surfaces, and the services.
86
93
  const context = {
87
94
  logger: silentLogger(),
88
95
  conversations: {
96
+ target: (name: string) =>
97
+ name === OWNER_TARGET.name ? OWNER_TARGET : undefined,
89
98
  background: async (turn: { channel: ChannelKey }) => {
90
99
  record.reportChannels.push(turn.channel);
91
100
  return { status: "ran" };
92
101
  },
93
102
  },
94
- core,
95
- } as unknown as PluginContext;
96
- return { contribution: await plugin.setup(context), record, core };
103
+ // Only Discord's channels have a surface; any other conversation has none to report in.
104
+ surfaces: surfacePort(() => [
105
+ {
106
+ surface: "discord",
107
+ start: async () => undefined,
108
+ sendReply: async () => undefined,
109
+ },
110
+ ]),
111
+ } as unknown as Omit<PluginContext, "services">;
112
+ const contribution = await services.setUp(plugin, () =>
113
+ plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
114
+ );
115
+ return { contribution, record, services };
116
+ }
117
+
118
+ /** What each addon plugin contributes, set up over stand-ins as far as setup reads them. */
119
+ export async function setUpAddons(): Promise<{
120
+ memory: Contribution;
121
+ discordAdmin: Contribution;
122
+ skills: Contribution;
123
+ }> {
124
+ const owner = {
125
+ id: "1",
126
+ name: "Owner",
127
+ pronouns: { subject: "they", object: "them", possessive: "their" },
128
+ } as const;
129
+ const memory = memoryPlugin({ owner });
130
+ const admin = discordAdminPlugin({ owner });
131
+ // SAFETY: the tools under test read the store only when a skill tool runs.
132
+ const store = { skills: () => [] } as unknown as SkillStore;
133
+ const skills = skillsPlugin(
134
+ {
135
+ guildId: "g1",
136
+ reposDir: "/tmp/repos",
137
+ writtenDir: "/tmp/written",
138
+ builtinDir: "/tmp/builtin",
139
+ },
140
+ async () => store,
141
+ );
142
+ const services = new ServiceRegistry([memory, admin, skills]);
143
+ // SAFETY: the admin tools ask the surface for the owner's Discord only when one runs.
144
+ services.preset(DISCORD, {
145
+ connection: { ownerOperations: () => ({}) },
146
+ } as unknown as DiscordServices);
147
+ // SAFETY: setup reads only the logger, the database handle, and the services.
148
+ const context = {
149
+ logger: silentLogger(),
150
+ database: () => ({}) as SQL,
151
+ } as unknown as Omit<PluginContext, "services">;
152
+ const setUp = (plugin: RoundtablePlugin) =>
153
+ services.setUp(plugin, () =>
154
+ plugin.setup(pluginContext(plugin, context, services.forPlugin(plugin))),
155
+ );
156
+ return {
157
+ memory: await setUp(memory),
158
+ discordAdmin: await setUp(admin),
159
+ skills: await setUp(skills),
160
+ };
97
161
  }
@@ -0,0 +1,279 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ type ExtensionAPI,
6
+ type ExtensionFactory,
7
+ SessionManager,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { DISCORD, type DiscordServices } from "../builtin/discord.ts";
10
+ import { type RoundtableConfig, resolveConfig } from "../config/config.ts";
11
+ import type { ConversationPort } from "../contract/channels.ts";
12
+ import type { AgentRuntime } from "../contract/runtime.ts";
13
+ import type { ChatSurface } from "../contract/surface.ts";
14
+ import { defineRoundtable } from "../define-roundtable.ts";
15
+ import { CommandCollection } from "../discord/command-collection.ts";
16
+ import type { ComposedCommands } from "../discord/compose-commands.ts";
17
+ import type { DiscordConnection } from "../discord/connection.ts";
18
+ import type {
19
+ CommandGuard,
20
+ InteractionContribution,
21
+ } from "../discord/interaction-module.ts";
22
+ import { OwnerGuard, ownerRootCommand } from "../discord/owner-command.ts";
23
+ import { Roundtable } from "../host.ts";
24
+ import { silentLogger } from "../log.ts";
25
+ import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
26
+ import { CompactionTiers } from "../runtime/compaction-tiers.ts";
27
+ import { sessionExtensions } from "../runtime/runtime-types.ts";
28
+ import type { AgentTurnScope, SessionContext } from "../sessions.ts";
29
+ import { testDatabaseUrl } from "./database.ts";
30
+
31
+ export interface TestHostOptions {
32
+ /**
33
+ * Over a test configuration: an owner, a guild, a temporary `dataDir`, and the test database
34
+ * (`ROUNDTABLE_TEST_DATABASE_URL`).
35
+ */
36
+ config?: Partial<RoundtableConfig>;
37
+ /** The app's plugins, after the built-in ones, as `defineRoundtable` places them. */
38
+ plugins?: readonly RoundtablePlugin[];
39
+ /** The runtime every turn runs on; by default one that answers "" and builds no Pi session. */
40
+ runtime?: AgentRuntime;
41
+ /** What the stand-in Discord hands out. */
42
+ discord?: Partial<Pick<DiscordConnection, "agentChannels" | "ownerChannel">>;
43
+ }
44
+
45
+ export interface TestHost {
46
+ /** A probe plugin's context, set up after every other plugin. */
47
+ context: PluginContext;
48
+ /** The host's conversations, as a surface would drive them. */
49
+ conversations: ConversationPort;
50
+ commands: {
51
+ /** Every slash-command contribution the plugins handed Discord, in the order added. */
52
+ added: readonly InteractionContribution[];
53
+ /** The tree Discord would register under the root command; closes the collection to later additions. */
54
+ composed(): ComposedCommands;
55
+ };
56
+ /** The tools each extension of a session registers, in plan order; the owner's session unless a scope is given. */
57
+ sessionTools(
58
+ scope?: AgentTurnScope,
59
+ ): Promise<{ extension: string; tools: string[] }[]>;
60
+ /** A session context as the runtime builds it, with the real compaction wrapper. */
61
+ sessionContext(scope?: AgentTurnScope): SessionContext;
62
+ /** Stops the host, services first-started last. */
63
+ stop(): Promise<void>;
64
+ }
65
+
66
+ function defaultConfig(): RoundtableConfig {
67
+ const dataDir = mkdtempSync(join(tmpdir(), "roundtable-test-host-"));
68
+ return {
69
+ owner: { id: "100000000000000001", name: "Ada" },
70
+ discord: {
71
+ token: "token",
72
+ guild: "900000000000000001",
73
+ entryChannel: "900000000000000002",
74
+ },
75
+ database: { url: testDatabaseUrl },
76
+ dataDir,
77
+ model: "anthropic/claude-sonnet-5-5",
78
+ http: {
79
+ publicUrl: "https://bot.example.com",
80
+ socketPath: join(dataDir, "public.sock"),
81
+ },
82
+ agents: [
83
+ {
84
+ name: "librarian",
85
+ displayName: "Librarian",
86
+ prompt: "You keep the reading list.",
87
+ avatarPrompt: "A calm librarian",
88
+ },
89
+ ],
90
+ };
91
+ }
92
+
93
+ /** A runtime that answers nothing, so the agent server builds no Pi session. */
94
+ function quietRuntime(): AgentRuntime {
95
+ return {
96
+ runTurn: async () => ({ ok: true, text: "" }),
97
+ steer: async () => false,
98
+ stop: () => false,
99
+ startFresh: async () => undefined,
100
+ deleteConversation: async () => undefined,
101
+ pendingConfirmation: () => undefined,
102
+ heldActions: async () => undefined,
103
+ recentTranscript: async () => [],
104
+ };
105
+ }
106
+
107
+ /** A Discord that connects to nothing, recording the commands added to it, standing in for the built-in plugin that does. */
108
+ function standInDiscord(
109
+ commands: CommandCollection,
110
+ guard: CommandGuard,
111
+ given: TestHostOptions["discord"],
112
+ ): RoundtablePlugin {
113
+ const surface: ChatSurface = {
114
+ surface: "discord",
115
+ start: async () => undefined,
116
+ sendReply: async () => undefined,
117
+ };
118
+ return {
119
+ name: "test-discord",
120
+ provides: [DISCORD],
121
+ replaces: [DISCORD],
122
+ setup: ({ services }) => {
123
+ // SAFETY: the plugins after it read only these members while they set up; the rest is read when a tool runs, which a test host does not do.
124
+ services.provide(DISCORD, {
125
+ connection: {
126
+ agentChannels: () => ({}),
127
+ agentDashboard: () => ({ show: async () => undefined }),
128
+ onChannelDeleted: () => undefined,
129
+ ownerChannel: async () => "discord:owner-dm",
130
+ ownerOperations: () => ({}),
131
+ ...given,
132
+ },
133
+ commands: commands.registrar,
134
+ guard,
135
+ threads: { open: async () => undefined, sweep: async () => undefined },
136
+ } as unknown as DiscordServices);
137
+ return { surfaces: [surface] };
138
+ },
139
+ };
140
+ }
141
+
142
+ /** Anything an extension may call or read on the Pi API besides registering a tool: accepted, does nothing. */
143
+ type Ignored = (...args: readonly never[]) => Ignored;
144
+
145
+ function ignored(): Ignored {
146
+ return new Proxy((() => ignored()) as Ignored, {
147
+ get: (target, key) => (key === "then" ? undefined : target),
148
+ });
149
+ }
150
+
151
+ /** The tools a factory registers, by name; every other registration (handlers, commands, events) is accepted and ignored. */
152
+ async function registeredBy(factory: ExtensionFactory): Promise<string[]> {
153
+ const names: string[] = [];
154
+ // SAFETY: a session factory only registers tools, handlers and commands; the fake records the tool names and ignores the rest.
155
+ const registerTool = (tool: { name: string }) => names.push(tool.name);
156
+ const api = new Proxy(
157
+ {},
158
+ {
159
+ get: (_target, key) =>
160
+ key === "registerTool" ? registerTool : ignored(),
161
+ },
162
+ ) as unknown as ExtensionAPI;
163
+ await factory(api);
164
+ return names;
165
+ }
166
+
167
+ const noop: ExtensionFactory = () => undefined;
168
+ /** The core's own extensions, which a session here does not load: only the plugins' registrations are listed. */
169
+ const CORE_EXTENSIONS = {
170
+ readAttachment: noop,
171
+ confirmationGate: noop,
172
+ askUser: noop,
173
+ selfCompactGuard: noop,
174
+ activeTools: noop,
175
+ };
176
+
177
+ /**
178
+ * Boots `defineRoundtable` over the test database with Discord and the runtime standing in, so
179
+ * a test sees the built-in plugins and the app's plugins as a host runs them. It needs
180
+ * PostgreSQL: run it under `describeDb`. Stop it when the test ends.
181
+ */
182
+ export async function testHost(
183
+ options: TestHostOptions = {},
184
+ ): Promise<TestHost> {
185
+ const commands = new CommandCollection();
186
+ const runtime = options.runtime ?? quietRuntime();
187
+ let captured: PluginContext | undefined;
188
+ const probe: RoundtablePlugin = {
189
+ name: "probe",
190
+ setup: (context) => {
191
+ captured = context;
192
+ return { services: [{ name: "probe" }] };
193
+ },
194
+ };
195
+ const base: RoundtableConfig = { ...defaultConfig(), ...options.config };
196
+ const resolved = resolveConfig(base);
197
+ const { rootCommand } = resolved.discord;
198
+ const guard = new OwnerGuard(resolved.owner.id, silentLogger(), rootCommand);
199
+ const config: RoundtableConfig = {
200
+ ...base,
201
+ plugins: [
202
+ standInDiscord(commands, guard, options.discord),
203
+ {
204
+ name: "test-runtime",
205
+ providers: { runtime: () => runtime },
206
+ setup: () => ({}),
207
+ },
208
+ ...(options.plugins ?? []),
209
+ probe,
210
+ ],
211
+ };
212
+ const defined = await defineRoundtable(config, { logger: silentLogger() });
213
+ const roundtable = new Roundtable(defined.options, defined.plugins);
214
+ await roundtable.run();
215
+ if (!captured) throw new Error("the probe was not set up");
216
+ const context = captured;
217
+ const ownerChannel = await (
218
+ options.discord?.ownerChannel ?? (async () => "discord:owner-dm" as const)
219
+ )();
220
+ /** A group seat's turns run in the group's channel; an agent's in its own. */
221
+ const turnChannelOf = (scope?: AgentTurnScope) =>
222
+ scope?.group ? scope.session : scope?.home;
223
+ const sessionContext = (scope?: AgentTurnScope): SessionContext => {
224
+ const tiers = new CompactionTiers(
225
+ SessionManager.inMemory(tmpdir()),
226
+ () => undefined,
227
+ context.sessions().plan.compaction?.engine,
228
+ );
229
+ const built: SessionContext = {
230
+ kind: scope ? "agent" : "owner",
231
+ homeChannel: scope?.home ?? ownerChannel,
232
+ turnChannel: turnChannelOf(scope) ?? ownerChannel,
233
+ compaction: {
234
+ wrap: (compactor) => tiers.wrapCompactor(compactor, () => undefined),
235
+ },
236
+ speaker: () => undefined,
237
+ runTask: async () => {
238
+ throw new Error("a test host session cannot run tasks");
239
+ },
240
+ };
241
+ if (scope) built.agent = scope;
242
+ return built;
243
+ };
244
+ return {
245
+ context,
246
+ conversations: context.conversations,
247
+ commands: {
248
+ added: commands.added(),
249
+ composed: () => commands.compose(ownerRootCommand(rootCommand)),
250
+ },
251
+ sessionTools: async (scope) => {
252
+ const tools: { extension: string; tools: string[] }[] = [];
253
+ for (const extension of sessionExtensions(
254
+ context.sessions().plan,
255
+ sessionContext(scope),
256
+ CORE_EXTENSIONS,
257
+ ))
258
+ tools.push({
259
+ extension: extension.name,
260
+ tools: await registeredBy(extension.factory),
261
+ });
262
+ return tools;
263
+ },
264
+ sessionContext,
265
+ stop: async () => {
266
+ await roundtable.shutdown("test");
267
+ },
268
+ };
269
+ }
270
+
271
+ /** The default delegation worker reads pi-web-access, which a checkout has and the exported package does not list. */
272
+ export function hasWebAccess(): boolean {
273
+ try {
274
+ import.meta.resolve("pi-web-access/package.json");
275
+ return true;
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
@@ -0,0 +1,64 @@
1
+ import { ASK_USER_TOOL } from "../runtime/extensions/ask-user.ts";
2
+ import { COMPACT_TOOL } from "../runtime/extensions/self-compact-guard.ts";
3
+ import { AGENTS } from "../services.ts";
4
+ import type { AgentTurnScope } from "../sessions.ts";
5
+ import type { TestHost } from "./test-host.ts";
6
+
7
+ export const scout: AgentTurnScope = {
8
+ name: "scout",
9
+ session: "discord:scout",
10
+ home: "discord:scout",
11
+ };
12
+ export const seat: AgentTurnScope = {
13
+ name: "scout",
14
+ session: "discord:war-room",
15
+ home: "discord:scout",
16
+ group: "war-room",
17
+ };
18
+
19
+ /** Tools the core's own extensions and pi-web-access register, which no fake session here loads. */
20
+ const NAMED_ELSEWHERE = [
21
+ ASK_USER_TOOL,
22
+ COMPACT_TOOL,
23
+ "read_attachment",
24
+ "web_search",
25
+ "fetch_content",
26
+ "get_search_content",
27
+ ];
28
+
29
+ export interface ToolSet {
30
+ sessions: Record<string, { tools: string[]; selection?: string[] }>;
31
+ tiers: Record<string, string>;
32
+ }
33
+
34
+ /** The tools each kind of session registers or selects under the host's plugins, and the tier each needs. */
35
+ export async function captureToolSet(host: TestHost): Promise<ToolSet> {
36
+ const out: ToolSet = { sessions: {}, tiers: {} };
37
+ const names = new Set<string>(NAMED_ELSEWHERE);
38
+ const seatSelection = (scope: AgentTurnScope | undefined) => {
39
+ if (!scope) return undefined;
40
+ // SAFETY: the concrete team reads its selection, which no port publishes.
41
+ const team = host.context.services.get(AGENTS).team as unknown as {
42
+ selection(scope: AgentTurnScope): { tools: string[] };
43
+ };
44
+ return [...team.selection(scope).tools].sort();
45
+ };
46
+ for (const [label, scope] of [
47
+ ["owner", undefined],
48
+ ["agent", scout],
49
+ ["group seat", seat],
50
+ ] as const) {
51
+ const tools = (await host.sessionTools(scope)).flatMap(
52
+ (extension) => extension.tools,
53
+ );
54
+ const selection = seatSelection(scope);
55
+ out.sessions[label] = {
56
+ tools: [...tools].sort(),
57
+ ...(selection ? { selection } : {}),
58
+ };
59
+ for (const name of [...tools, ...(selection ?? [])]) names.add(name);
60
+ }
61
+ for (const name of [...names].sort())
62
+ out.tiers[name] = host.context.toolTiers.minTier(name);
63
+ return out;
64
+ }
package/src/core/time.ts CHANGED
@@ -16,8 +16,13 @@ export function timeZone(): string {
16
16
 
17
17
  /** The formatter is constructed when used, not at import time, so configuration is observed. */
18
18
  function stamp(at: Date): string {
19
+ return zonedStampIn(at, zone);
20
+ }
21
+
22
+ /** `at` as "YYYY-MM-DD HH:MM" in the named IANA time zone, whatever the host chose. */
23
+ export function zonedStampIn(at: Date, timeZone: string): string {
19
24
  return new Intl.DateTimeFormat("sv-SE", {
20
- timeZone: zone,
25
+ timeZone,
21
26
  year: "numeric",
22
27
  month: "2-digit",
23
28
  day: "2-digit",