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,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type { OwnerAnswer, OwnerQuestion } from "../../domain/owner-prompts.ts";
4
+ import { messages } from "../../i18n/index.ts";
5
+ import { TEST_OWNER as OWNER } from "../../testing/owner.ts";
6
+ import { PromptSlot } from "../prompt-slot.ts";
7
+ import { ASK_USER_TOOL, askUserExtension } from "./ask-user.ts";
8
+
9
+ interface Tool {
10
+ name: string;
11
+ execute: (
12
+ id: string,
13
+ params: unknown,
14
+ signal?: AbortSignal,
15
+ ) => Promise<{ content: { text: string }[] }>;
16
+ }
17
+
18
+ /** ask_user over a slot whose cards answer with `answer`, recording each question. */
19
+ function askUser(answer: OwnerAnswer | undefined, bound = true) {
20
+ const asked: { title: string; question: OwnerQuestion }[] = [];
21
+ const slot = new PromptSlot();
22
+ slot.bind(
23
+ bound
24
+ ? {
25
+ confirm: async () => "expired",
26
+ ask: async (title, question) => {
27
+ asked.push({ title, question });
28
+ return answer;
29
+ },
30
+ }
31
+ : undefined,
32
+ "infra",
33
+ );
34
+ let tool: Tool | undefined;
35
+ askUserExtension(
36
+ slot,
37
+ OWNER,
38
+ )({
39
+ registerTool: (t: Tool) => {
40
+ tool = t;
41
+ },
42
+ } as unknown as ExtensionAPI);
43
+ if (tool?.name !== ASK_USER_TOOL) throw new Error("ask_user not registered");
44
+ const t = tool;
45
+ return {
46
+ asked,
47
+ call: async (params: unknown) =>
48
+ (await t.execute("call-1", params)).content[0]?.text ?? "",
49
+ };
50
+ }
51
+
52
+ describe("ask_user", () => {
53
+ test("a single choice comes back as the chosen label", async () => {
54
+ const { asked, call } = askUser({ choices: ["Sunday"] });
55
+ const text = await call({
56
+ question: "Which day?",
57
+ options: [
58
+ { label: "Saturday" },
59
+ { label: "Sunday", description: "afternoon" },
60
+ ],
61
+ });
62
+ expect(text).toBe("Riley chose: Sunday");
63
+ expect(asked[0]).toEqual({
64
+ title: messages().askTitle("infra"),
65
+ question: {
66
+ question: "Which day?",
67
+ options: [
68
+ { label: "Saturday" },
69
+ { label: "Sunday", description: "afternoon" },
70
+ ],
71
+ multi: false,
72
+ allowOther: false,
73
+ },
74
+ });
75
+ });
76
+
77
+ test("several choices and the owner's own words", async () => {
78
+ const { asked, call } = askUser({
79
+ choices: ["Saturday", "Monday"],
80
+ text: "Wednesday works too",
81
+ });
82
+ const text = await call({
83
+ question: "Which days?",
84
+ options: [{ label: "Saturday" }, { label: "Monday" }],
85
+ multi: true,
86
+ allow_other: true,
87
+ });
88
+ expect(text).toBe(
89
+ "Riley chose: Saturday; Monday\nRiley wrote: Wednesday works too",
90
+ );
91
+ expect(asked[0]?.question).toMatchObject({ multi: true, allowOther: true });
92
+ });
93
+
94
+ test("a free-text question", async () => {
95
+ const { asked, call } = askUser({ choices: [], text: "call me Nova" });
96
+ expect(await call({ question: "What name?" })).toBe(
97
+ "Riley wrote: call me Nova",
98
+ );
99
+ expect(asked[0]?.question.options).toEqual([]);
100
+ });
101
+
102
+ test("no answer in time says so", async () => {
103
+ const { call } = askUser(undefined);
104
+ expect(await call({ question: "?" })).toStartWith("No answer");
105
+ });
106
+
107
+ test("a turn he did not start tells the model to ask in its reply", async () => {
108
+ const { asked, call } = askUser({ choices: ["x"] }, false);
109
+ expect(await call({ question: "?" })).toContain(
110
+ "Ask him in your reply instead",
111
+ );
112
+ expect(asked).toHaveLength(0);
113
+ });
114
+ });
@@ -0,0 +1,98 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { OwnerAnswer, OwnerQuestion } from "../../domain/owner-prompts.ts";
4
+ import { messages } from "../../i18n/index.ts";
5
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
6
+ import { toolText } from "../../shared/tool-result.ts";
7
+ import type { PromptSlot } from "../prompt-slot.ts";
8
+
9
+ export const ASK_USER_TOOL = "ask_user";
10
+
11
+ /** The tool's result: what the owner chose and wrote. */
12
+ export function answerText(
13
+ answer: OwnerAnswer | undefined,
14
+ owner: OwnerIdentity,
15
+ ): string {
16
+ const o = ownerWords(owner);
17
+ if (!answer)
18
+ return `No answer: ${o.name} did not answer in time. Go on without it if you safely can, otherwise ask in your reply.`;
19
+ const lines = [
20
+ ...(answer.choices.length > 0
21
+ ? [`${o.name} chose: ${answer.choices.join("; ")}`]
22
+ : []),
23
+ ...(answer.text !== undefined ? [`${o.name} wrote: ${answer.text}`] : []),
24
+ ];
25
+ return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
26
+ }
27
+
28
+ /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
29
+ export function askUserExtension(
30
+ slot: PromptSlot,
31
+ owner: OwnerIdentity,
32
+ /** Whom the running turn is for, when not the owner; the answer's words name them. */
33
+ addressee?: () => OwnerIdentity,
34
+ ): ExtensionFactory {
35
+ const o = ownerWords(owner);
36
+ return (pi) => {
37
+ pi.registerTool({
38
+ name: ASK_USER_TOOL,
39
+ label: `Ask ${o.name}`,
40
+ description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
41
+ parameters: Type.Object(
42
+ {
43
+ question: Type.String({ minLength: 1, maxLength: 1500 }),
44
+ options: Type.Optional(
45
+ Type.Array(
46
+ Type.Object(
47
+ {
48
+ label: Type.String({ minLength: 1, maxLength: 100 }),
49
+ description: Type.Optional(Type.String({ maxLength: 100 })),
50
+ },
51
+ { additionalProperties: false },
52
+ ),
53
+ { maxItems: 25 },
54
+ ),
55
+ ),
56
+ multi: Type.Optional(
57
+ Type.Boolean({ description: "Several options may be chosen." }),
58
+ ),
59
+ allow_other: Type.Optional(
60
+ Type.Boolean({
61
+ description: `${o.He} may write an answer besides the options.`,
62
+ }),
63
+ ),
64
+ },
65
+ { additionalProperties: false },
66
+ ),
67
+ execute: async (_toolCallId, params, signal) => {
68
+ const prompts = slot.prompts;
69
+ if (!prompts)
70
+ return toolText(
71
+ `${o.name} cannot answer buttons in this turn, such as a schedule's. Ask ${o.him} in your reply instead.`,
72
+ );
73
+ const p = params as {
74
+ question: string;
75
+ options?: { label: string; description?: string }[];
76
+ multi?: boolean;
77
+ allow_other?: boolean;
78
+ };
79
+ const question: OwnerQuestion = {
80
+ question: p.question,
81
+ options: p.options ?? [],
82
+ multi: p.multi === true,
83
+ allowOther: p.allow_other === true,
84
+ };
85
+ return toolText(
86
+ answerText(
87
+ await prompts.ask(
88
+ messages().askTitle(slot.asker),
89
+ question,
90
+ signal,
91
+ ),
92
+ addressee?.() ?? owner,
93
+ ),
94
+ );
95
+ },
96
+ });
97
+ };
98
+ }
@@ -0,0 +1,247 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ HeldCall,
4
+ PendingConfirmation,
5
+ } from "../../domain/conversation.ts";
6
+ import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
7
+ import type { ProfileId } from "../../domain/profile.ts";
8
+ import type { HoldCheck } from "../../holds.ts";
9
+ import { messages } from "../../i18n/index.ts";
10
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
11
+ import type { ToolTiers } from "../../tool-tiers.ts";
12
+ import type { PromptSlot } from "../prompt-slot.ts";
13
+
14
+ /** How much of a held call's input its approval card shows. */
15
+ const CARD_INPUT_CHARS = 1_500;
16
+
17
+ /** Held actions older than this cannot be approved any more. */
18
+ export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
19
+
20
+ /** JSON with object keys sorted at every level, so equal inputs compare equal. */
21
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
22
+ export function canonicalJson(value: unknown): string {
23
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
24
+ return JSON.stringify(value, (_key, v: unknown) =>
25
+ isPlainObject(v) ? withSortedKeys(v) : v,
26
+ );
27
+ }
28
+
29
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
30
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
31
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ /** The same object with its keys in code-unit order. */
35
+ function withSortedKeys(
36
+ value: Record<string, unknown>,
37
+ ): Record<string, unknown> {
38
+ return Object.fromEntries(
39
+ Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b)),
40
+ );
41
+ }
42
+
43
+ function byCodeUnit(a: string, b: string): number {
44
+ if (a < b) return -1;
45
+ return a > b ? 1 : 0;
46
+ }
47
+
48
+ /**
49
+ * Per-channel state of held actions. With the owner's prompts bound, a call that needs his
50
+ * confirmation waits for his answer on a card and runs or is refused in the same turn. Otherwise,
51
+ * or when the card expires, the call is held: it records its tool and exact input, and the
52
+ * owner's next message either confirms them, which lets each identical call run once in that
53
+ * turn, or drops them.
54
+ */
55
+ // pi-lens-ignore: large-class — one gate per conversation; its state is the held calls and their approvals
56
+ export class ConfirmationGate {
57
+ #profile: ProfileId = "general";
58
+ #pending: PendingConfirmation | undefined;
59
+ #approved: HeldCall[] = [];
60
+ readonly #holds: HoldCheck;
61
+ readonly #owner: OwnerIdentity;
62
+ /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
63
+ #addressee: OwnerIdentity;
64
+ readonly #workspace: string | undefined;
65
+ readonly #tiers: ToolTiers | undefined;
66
+
67
+ /**
68
+ * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
69
+ * and its writes outside the workspace are held too.
70
+ */
71
+ constructor(
72
+ holds: HoldCheck,
73
+ owner: OwnerIdentity,
74
+ pending?: PendingConfirmation,
75
+ workspace?: string,
76
+ tiers?: ToolTiers,
77
+ ) {
78
+ this.#tiers = tiers;
79
+ this.#holds = holds;
80
+ this.#owner = owner;
81
+ this.#addressee = owner;
82
+ this.#pending = pending;
83
+ this.#workspace = workspace;
84
+ }
85
+
86
+ /** Held actions awaiting the owner's answer; expired ones count as none. */
87
+ pending(now = new Date()): PendingConfirmation | undefined {
88
+ const pending = this.#pending;
89
+ if (!pending) return undefined;
90
+ return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
91
+ ? undefined
92
+ : pending;
93
+ }
94
+
95
+ /** `addressee` is who the turn is for; without one, the owner. */
96
+ beginTurn(
97
+ profile: ProfileId,
98
+ confirmed: boolean,
99
+ addressee?: OwnerIdentity,
100
+ ): void {
101
+ this.#profile = profile;
102
+ this.#addressee = addressee ?? this.#owner;
103
+ this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
104
+ this.#pending = undefined;
105
+ }
106
+
107
+ endTurn(): void {
108
+ this.#approved = [];
109
+ }
110
+
111
+ /** Returns the reason a call is held, or undefined to let it run. */
112
+ hold(tool: string, input: Record<string, unknown>): string | undefined {
113
+ const call = this.#needing(tool, input);
114
+ return call ? this.#record(call) : undefined;
115
+ }
116
+
117
+ /**
118
+ * Like `hold`, but asks the owner on a card first when `prompts` is given: his approval runs
119
+ * the call, his refusal blocks it, and only an expired card holds it.
120
+ */
121
+ async review(
122
+ tool: string,
123
+ input: Record<string, unknown>,
124
+ ask?: { prompts: OwnerPrompts; asker: string; signal?: AbortSignal },
125
+ ): Promise<string | undefined> {
126
+ return (await this.decide(tool, input, ask)).reason;
127
+ }
128
+
129
+ /**
130
+ * `review`, plus whether a card's approval released the call, so its result can say so:
131
+ * the card's own message is shown to the person, never to the model.
132
+ */
133
+ async decide(
134
+ tool: string,
135
+ input: Record<string, unknown>,
136
+ ask?: { prompts: OwnerPrompts; asker: string; signal?: AbortSignal },
137
+ ): Promise<{ reason?: string; approvedOnCard?: true }> {
138
+ const call = this.#needing(tool, input);
139
+ if (!call) return {};
140
+ if (!ask) return { reason: this.#record(call) };
141
+ const answer = await ask.prompts.confirm(
142
+ messages().confirmTitle(ask.asker),
143
+ approvalCard(call),
144
+ ask.signal,
145
+ this.#tiers?.minTier(call.tool),
146
+ );
147
+ if (answer === "approved") return { approvedOnCard: true };
148
+ if (answer === "declined") {
149
+ const o = ownerWords(this.#addressee);
150
+ return {
151
+ reason: `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`,
152
+ };
153
+ }
154
+ if (answer === "cancelled") return { reason: "The turn was stopped." };
155
+ return { reason: this.#record(call) };
156
+ }
157
+
158
+ /** The call when it needs the owner's confirmation and no approval releases it. */
159
+ #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
160
+ const action = this.#holds(
161
+ tool,
162
+ input,
163
+ this.#workspace === undefined ? {} : { workspace: this.#workspace },
164
+ );
165
+ if (!action) return undefined;
166
+ const call: HeldCall = { tool, input: canonicalJson(input), action };
167
+ const approved = this.#approved.findIndex(
168
+ (c) => c.tool === call.tool && c.input === call.input,
169
+ );
170
+ if (approved !== -1) {
171
+ this.#approved.splice(approved, 1);
172
+ return undefined;
173
+ }
174
+ return call;
175
+ }
176
+
177
+ #record(call: HeldCall): string {
178
+ const pending = this.#pending ?? {
179
+ profile: this.#profile,
180
+ heldAt: new Date(),
181
+ calls: [],
182
+ };
183
+ if (
184
+ !pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
185
+ )
186
+ pending.calls.push(call);
187
+ this.#pending = pending;
188
+ const o = ownerWords(this.#addressee);
189
+ return `Held for ${o.name}'s confirmation: this would ${call.action}. Do not retry it in this turn. Tell ${o.him} exactly what will happen and ask ${o.him} to confirm; once ${o.he} agrees in ${o.his} next message, make the same call with the same input and it runs.`;
190
+ }
191
+ }
192
+
193
+ /** The owner's approving message, preceded by the held calls it releases, so the model makes them again. */
194
+ export function confirmedTurnText(
195
+ pending: PendingConfirmation,
196
+ text: string,
197
+ owner: OwnerIdentity,
198
+ ): string {
199
+ const calls = pending.calls
200
+ .map((call) => `- ${call.action}: ${call.tool} ${call.input}`)
201
+ .join("\n");
202
+ return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
203
+ }
204
+
205
+ /** What an approval card says: the held action's words, then the exact call. */
206
+ export function approvalCard(call: HeldCall): string {
207
+ const input =
208
+ call.input.length > CARD_INPUT_CHARS
209
+ ? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
210
+ : call.input;
211
+ return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\``;
212
+ }
213
+
214
+ /** Without a slot, or with an empty one, every call that needs confirmation is held. */
215
+ export function confirmationGateExtension(
216
+ gate: ConfirmationGate,
217
+ slot?: PromptSlot,
218
+ ): ExtensionFactory {
219
+ return (pi) => {
220
+ /** Calls a card's approval released, until their results are back. */
221
+ const approved = new Set<string>();
222
+ pi.on("tool_result", (event) => {
223
+ if (!approved.delete(event.toolCallId)) return undefined;
224
+ const outcome = event.isError
225
+ ? "It was approved on its card and ran, but it failed:"
226
+ : "It was approved on its card and ran; its result follows.";
227
+ return {
228
+ content: [{ type: "text", text: outcome }, ...event.content],
229
+ };
230
+ });
231
+ pi.on("tool_call", async (event, ctx) => {
232
+ const prompts = slot?.prompts;
233
+ let ask: Parameters<ConfirmationGate["review"]>[2];
234
+ if (prompts && slot) {
235
+ ask = { prompts, asker: slot.asker };
236
+ if (ctx.signal) ask.signal = ctx.signal;
237
+ }
238
+ const { reason, approvedOnCard } = await gate.decide(
239
+ event.toolName,
240
+ event.input as Record<string, unknown>,
241
+ ask,
242
+ );
243
+ if (approvedOnCard) approved.add(event.toolCallId);
244
+ return reason ? { block: true, reason } : undefined;
245
+ });
246
+ };
247
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ COMPACT_TOOL,
5
+ selfCompactGuardExtension,
6
+ } from "./self-compact-guard.ts";
7
+
8
+ type Handler = (event: {
9
+ toolName: string;
10
+ input: unknown;
11
+ }) => { block: true; reason: string } | undefined;
12
+
13
+ function handler(): Handler {
14
+ let registered: Handler | undefined;
15
+ selfCompactGuardExtension()({
16
+ on: (event: string, h: Handler) => {
17
+ if (event === "tool_call") registered = h;
18
+ },
19
+ } as unknown as ExtensionAPI);
20
+ if (!registered) throw new Error("no tool_call handler");
21
+ return registered;
22
+ }
23
+
24
+ describe("selfCompactGuardExtension", () => {
25
+ test("refuses compact_session with resume, with the reason", () => {
26
+ const result = handler()({
27
+ toolName: COMPACT_TOOL,
28
+ input: { resume: "continue the report" },
29
+ });
30
+ expect(result?.block).toBe(true);
31
+ expect(result?.reason).toContain("without resume");
32
+ });
33
+
34
+ test("lets compact_session without resume, or with a blank one, run", () => {
35
+ const h = handler();
36
+ expect(h({ toolName: COMPACT_TOOL, input: {} })).toBeUndefined();
37
+ expect(
38
+ h({ toolName: COMPACT_TOOL, input: { instructions: "keep paths" } }),
39
+ ).toBeUndefined();
40
+ expect(
41
+ h({ toolName: COMPACT_TOOL, input: { resume: " " } }),
42
+ ).toBeUndefined();
43
+ });
44
+
45
+ test("ignores other tools", () => {
46
+ expect(
47
+ handler()({ toolName: "bash", input: { resume: "x" } }),
48
+ ).toBeUndefined();
49
+ });
50
+ });
@@ -0,0 +1,23 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** pi-self-compact's tool, active in every session. */
4
+ export const COMPACT_TOOL = "compact_session";
5
+
6
+ const RESUME_REFUSED =
7
+ "compact_session cannot take resume here: a resumed run would start outside this conversation and its reply would reach no one. Finish this turn's work, then call compact_session again without resume; the next message starts from the compacted context.";
8
+
9
+ /**
10
+ * Refuses `compact_session` with `resume`. A turn starts only from the owner, a schedule, or
11
+ * another agent, so a run pi-self-compact starts on its own would answer no one.
12
+ */
13
+ export function selfCompactGuardExtension(): ExtensionFactory {
14
+ return (pi) => {
15
+ pi.on("tool_call", (event) => {
16
+ if (event.toolName !== COMPACT_TOOL) return undefined;
17
+ const { resume } = event.input as { resume?: unknown };
18
+ // pi-self-compact ignores a blank resume, so only a real one is refused.
19
+ if (typeof resume !== "string" || !resume.trim()) return undefined;
20
+ return { block: true, reason: RESUME_REFUSED };
21
+ });
22
+ };
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { mcpAdapterExtension } from "../shared/mcp-adapter.ts";
3
+
4
+ /** An MCP server a session connects to, with the tools it offers. */
5
+ export interface VirtualServer {
6
+ name: string;
7
+ url: string;
8
+ tools: string[];
9
+ }
10
+
11
+ /** The owner's MCP gateway virtual servers, reached with the process's admin token. */
12
+ export function mcpExtension(
13
+ servers: readonly VirtualServer[],
14
+ token: string,
15
+ ): ExtensionFactory {
16
+ return mcpAdapterExtension(
17
+ servers.map((server) => ({
18
+ name: server.name,
19
+ url: server.url,
20
+ bearerToken: token,
21
+ })),
22
+ );
23
+ }
@@ -0,0 +1,52 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { PendingConfirmation } from "../domain/conversation.ts";
4
+ import {
5
+ describeDb,
6
+ openTestStore,
7
+ type TestStore,
8
+ testDatabaseUrl,
9
+ } from "../testing/database.ts";
10
+ import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
11
+
12
+ const held: PendingConfirmation = {
13
+ profile: "workspace",
14
+ heldAt: new Date("2026-09-27T01:00:00Z"),
15
+ calls: [
16
+ {
17
+ tool: "google-send-gmail-message",
18
+ input: '{"subject":"hi","to":"a@b.c"}',
19
+ action: "send an email to a@b.c",
20
+ },
21
+ ],
22
+ };
23
+
24
+ // Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
25
+ describeDb("PendingConfirmationStore", () => {
26
+ let store: TestStore<PendingConfirmationStore>;
27
+
28
+ beforeAll(async () => {
29
+ const admin = new SQL(testDatabaseUrl);
30
+ await admin`DROP TABLE IF EXISTS pending_confirmations`;
31
+ await admin.close();
32
+ store = await openTestStore(PendingConfirmationStore);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await store.close();
37
+ });
38
+
39
+ test("held actions survive a restart, are replaced, and are cleared", async () => {
40
+ expect(await store.load("discord:1")).toBeUndefined();
41
+ await store.save("discord:1", held);
42
+ expect(await store.load("discord:1")).toEqual(held);
43
+
44
+ const other = { ...held, calls: [] };
45
+ await store.save("discord:1", other);
46
+ expect(await store.load("discord:1")).toEqual(other);
47
+ expect(await store.load("discord:2")).toBeUndefined();
48
+
49
+ await store.save("discord:1", undefined);
50
+ expect(await store.load("discord:1")).toBeUndefined();
51
+ });
52
+ });
@@ -0,0 +1,75 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "../db/migrations.ts";
3
+ import type {
4
+ ChannelKey,
5
+ HeldCall,
6
+ PendingConfirmation,
7
+ } from "../domain/conversation.ts";
8
+ import type { ProfileId } from "../domain/profile.ts";
9
+
10
+ interface Row {
11
+ profile: ProfileId;
12
+ held_at: Date;
13
+ calls: string;
14
+ }
15
+
16
+ /** Each owner channel's held actions, in the database, so a restart keeps them. */
17
+ export class PendingConfirmationStore {
18
+ readonly #sql: SQL;
19
+
20
+ private constructor(sql: SQL) {
21
+ this.#sql = sql;
22
+ }
23
+
24
+ /** The store's tables; the host runs this before any store attaches. */
25
+ static readonly migration: Migration = {
26
+ name: "pending-confirmations",
27
+ up: async (sql) => {
28
+ await sql`
29
+ CREATE TABLE IF NOT EXISTS pending_confirmations (
30
+ channel_key text PRIMARY KEY,
31
+ profile text NOT NULL,
32
+ held_at timestamptz NOT NULL,
33
+ calls text NOT NULL
34
+ )`;
35
+ },
36
+ };
37
+
38
+ /** The store over the host's migrated pool. */
39
+ static async attach(sql: SQL): Promise<PendingConfirmationStore> {
40
+ return new PendingConfirmationStore(sql);
41
+ }
42
+
43
+ async load(channel: ChannelKey): Promise<PendingConfirmation | undefined> {
44
+ const rows: Row[] = await this.#sql`
45
+ SELECT profile, held_at, calls FROM pending_confirmations
46
+ WHERE channel_key = ${channel}`;
47
+ const row = rows[0];
48
+ return row
49
+ ? {
50
+ profile: row.profile,
51
+ heldAt: row.held_at,
52
+ // pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
53
+ calls: JSON.parse(row.calls) as HeldCall[],
54
+ }
55
+ : undefined;
56
+ }
57
+
58
+ /** Replaces the channel's held actions; none deletes the row. */
59
+ async save(
60
+ channel: ChannelKey,
61
+ pending: PendingConfirmation | undefined,
62
+ ): Promise<void> {
63
+ if (!pending) {
64
+ await this
65
+ .#sql`DELETE FROM pending_confirmations WHERE channel_key = ${channel}`;
66
+ return;
67
+ }
68
+ await this.#sql`
69
+ INSERT INTO pending_confirmations (channel_key, profile, held_at, calls)
70
+ VALUES (${channel}, ${pending.profile}, ${pending.heldAt},
71
+ ${JSON.stringify(pending.calls)})
72
+ ON CONFLICT (channel_key) DO UPDATE SET profile = EXCLUDED.profile,
73
+ held_at = EXCLUDED.held_at, calls = EXCLUDED.calls`;
74
+ }
75
+ }