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,149 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { PluginError } from "./errors.ts";
3
+ import type { Speaker } from "./speakers.ts";
4
+
5
+ /** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
6
+ export type ChannelKey = `${string}:${string}`;
7
+
8
+ /** Who an agent-server turn belongs to; see the agent team. */
9
+ export interface AgentTurnScope {
10
+ /** The agent's name. */
11
+ name: string;
12
+ /** The conversation and its held actions: the agent's channel, or its seat in a group. */
13
+ session: ChannelKey;
14
+ /** The agent's own channel, where its schedules and delegated reports belong. */
15
+ home: ChannelKey;
16
+ /** The group, for a member's conversation in one. */
17
+ group?: string;
18
+ }
19
+
20
+ /** The tools a turn or a transient task may use: named tools, plus every tool of each named group. */
21
+ export interface ToolSelection {
22
+ tools: readonly string[];
23
+ groups: readonly string[];
24
+ }
25
+
26
+ /** The tools of one turn, named so its held actions and log lines say which selection ran. */
27
+ export interface TurnSelection extends ToolSelection {
28
+ id: string;
29
+ }
30
+
31
+ /** A task that runs now in a fresh, unsaved session and reports its final text. */
32
+ export interface TransientTask {
33
+ selection: ToolSelection;
34
+ text: string;
35
+ /** Aborted after this long. */
36
+ timeoutMs: number;
37
+ signal?: AbortSignal;
38
+ /** Tools of the selection the task may not use. */
39
+ exclude: readonly string[];
40
+ /** Called after the session is disposed, whether the task succeeded or failed. */
41
+ onFinished?(toolCalls: readonly string[]): void;
42
+ }
43
+
44
+ /** What one Pi session is for, as a session tool's factory sees it. */
45
+ export interface SessionContext {
46
+ kind: "owner" | "agent";
47
+ /** Where the session's schedules and delegated reports belong: the agent's own channel, or the owner's. */
48
+ homeChannel: ChannelKey;
49
+ /** Where the session's turns run: a group's channel for an agent's seat in it, else the home channel. */
50
+ turnChannel: ChannelKey;
51
+ /** The agent the session belongs to; absent for the owner's sessions. */
52
+ agent?: AgentTurnScope;
53
+ /** Wraps a compactor so the core's compaction tiers decide when it may answer. */
54
+ compaction: { wrap(compactor: ExtensionFactory): ExtensionFactory };
55
+ /** The person the session's running turn is for; undefined between turns. */
56
+ speaker(): Speaker | undefined;
57
+ /** Runs a task beside this session, under its confirmation gate. */
58
+ runTask(task: TransientTask): Promise<string>;
59
+ }
60
+
61
+ /**
62
+ * One contribution's state for a session being built. A new revision rebuilds each open
63
+ * session on its next turn, keeping its history.
64
+ */
65
+ export interface SessionToolSnapshot {
66
+ revision: number;
67
+ /** The session's extension, or null when the contribution does not apply to it. */
68
+ factory(session: SessionContext): ExtensionFactory | null;
69
+ /** Tool groups a selection may name, in order. */
70
+ groups?: readonly { name: string; tools: readonly string[] }[];
71
+ /** Tools a new session waits for, because they register after it loads. */
72
+ awaitTools?: readonly string[];
73
+ /** Tools startup refuses to run without. */
74
+ requiredTools?: readonly string[];
75
+ }
76
+
77
+ /** A named Pi extension a plugin adds to every conversation session, at its phase's position. */
78
+ export interface SessionTool {
79
+ /** The extension's name, unique across sessions. */
80
+ name: string;
81
+ phase: "tools" | "compaction" | "mcp";
82
+ /**
83
+ * The compaction phase's marker: the `engine` its compactions record in their details, which is how
84
+ * the core tells them from Pi's own.
85
+ */
86
+ engine?: string;
87
+ /** Synchronous and side-effect free; read once per session build and before each turn. */
88
+ snapshot(): SessionToolSnapshot;
89
+ }
90
+
91
+ /** Extensions the core places itself; no contribution may take their names. */
92
+ const CORE_EXTENSIONS = [
93
+ "read-attachment",
94
+ "confirmation-gate",
95
+ "ask-user",
96
+ "self-compact-guard",
97
+ "profile-tools",
98
+ ] as const;
99
+
100
+ /** Session tools grouped by phase, each group in registration order. */
101
+ export interface SessionPlan {
102
+ tools: readonly SessionTool[];
103
+ compaction?: SessionTool;
104
+ mcp: readonly SessionTool[];
105
+ }
106
+
107
+ /** Checks the contributions and orders them by phase; throws PluginError on a clash. */
108
+ export function compileSessionPlan(
109
+ contributions: readonly SessionTool[],
110
+ ): SessionPlan {
111
+ const reserved = new Set<string>(CORE_EXTENSIONS);
112
+ const names = new Set<string>();
113
+ for (const { name } of contributions) {
114
+ if (reserved.has(name))
115
+ throw new PluginError(
116
+ `session tool ${name} takes a core extension name. Rename it.`,
117
+ );
118
+ if (names.has(name))
119
+ throw new PluginError(
120
+ `session tool ${name} is registered twice. Rename one of the two.`,
121
+ );
122
+ names.add(name);
123
+ }
124
+ const compactors = contributions.filter((c) => c.phase === "compaction");
125
+ if (compactors.length > 1)
126
+ throw new PluginError(
127
+ `only one compactor may run; got ${compactors.map((c) => c.name).join(", ")}. Keep one.`,
128
+ );
129
+ const unmarked = compactors.find((c) => !c.engine);
130
+ if (unmarked)
131
+ throw new PluginError(
132
+ `compactor ${unmarked.name} needs an engine: the value its compactions record as details.engine.`,
133
+ );
134
+ const plan: SessionPlan = {
135
+ tools: contributions.filter((c) => c.phase === "tools"),
136
+ mcp: contributions.filter((c) => c.phase === "mcp"),
137
+ };
138
+ if (compactors[0]) plan.compaction = compactors[0];
139
+ return plan;
140
+ }
141
+
142
+ /** A plan's contributions in the order their extensions load. */
143
+ export function planOrder(plan: SessionPlan): readonly SessionTool[] {
144
+ return [
145
+ ...plan.tools,
146
+ ...(plan.compaction ? [plan.compaction] : []),
147
+ ...plan.mcp,
148
+ ];
149
+ }
@@ -0,0 +1,91 @@
1
+ import { basename, join } from "node:path";
2
+ import { extractText, getDocumentProxy } from "unpdf";
3
+
4
+ /** Characters returned per read; the model can ask for a later offset. */
5
+ export const READ_LIMIT = 60_000;
6
+
7
+ const TEXT_EXTENSIONS = new Set([
8
+ ".txt",
9
+ ".md",
10
+ ".markdown",
11
+ ".csv",
12
+ ".tsv",
13
+ ".json",
14
+ ".yaml",
15
+ ".yml",
16
+ ".xml",
17
+ ".html",
18
+ ".htm",
19
+ ".log",
20
+ ".ts",
21
+ ".js",
22
+ ".py",
23
+ ".sh",
24
+ ".toml",
25
+ ".ini",
26
+ ".srt",
27
+ ".vtt",
28
+ ]);
29
+
30
+ function extension(name: string): string {
31
+ const dot = name.lastIndexOf(".");
32
+ return dot < 0 ? "" : name.slice(dot).toLowerCase();
33
+ }
34
+
35
+ function isPdf(name: string, head: Uint8Array): boolean {
36
+ return (
37
+ extension(name) === ".pdf" ||
38
+ new TextDecoder().decode(head.subarray(0, 5)) === "%PDF-"
39
+ );
40
+ }
41
+
42
+ function looksLikeText(bytes: Uint8Array): boolean {
43
+ const sample = bytes.subarray(0, 4096);
44
+ if (sample.includes(0)) return false;
45
+ try {
46
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ export interface ReadResult {
54
+ text: string;
55
+ /** Characters in the whole extracted text. */
56
+ total: number;
57
+ }
58
+
59
+ /**
60
+ * Reads a stored attachment as text: plain text files directly, PDFs through unpdf.
61
+ * Other files are refused with a message naming what can take them.
62
+ * `file` must be a name inside `dir`; paths are rejected.
63
+ */
64
+ export async function readAttachment(
65
+ dir: string,
66
+ file: string,
67
+ offset = 0,
68
+ ): Promise<ReadResult> {
69
+ if (basename(file) !== file || file.startsWith(".")) {
70
+ throw new Error(`"${file}" is not an attachment name`);
71
+ }
72
+ const handle = Bun.file(join(dir, file));
73
+ if (!(await handle.exists())) {
74
+ throw new Error(`no attachment named "${file}" in this channel`);
75
+ }
76
+ const bytes = new Uint8Array(await handle.arrayBuffer());
77
+ let text: string;
78
+ if (isPdf(file, bytes)) {
79
+ const pdf = await getDocumentProxy(bytes);
80
+ const result = await extractText(pdf, { mergePages: true });
81
+ text = `[PDF, ${result.totalPages} pages]\n${result.text}`;
82
+ } else if (TEXT_EXTENSIONS.has(extension(file)) || looksLikeText(bytes)) {
83
+ text = new TextDecoder().decode(bytes);
84
+ } else {
85
+ throw new Error(
86
+ `"${file}" is not a text or PDF file (${bytes.byteLength} bytes). Images are shown to you directly; audio or video can go to a tool that takes files.`,
87
+ );
88
+ }
89
+ const start = Math.max(0, Math.floor(offset));
90
+ return { text: text.slice(start, start + READ_LIMIT), total: text.length };
91
+ }
@@ -0,0 +1,15 @@
1
+ import { Type } from "typebox";
2
+
3
+ /** Hands web research to the cheaper worker model; the same for the owner agent and party roles. */
4
+ export const DELEGATE_TOOL = "delegate_task" as const;
5
+
6
+ export const DELEGATE_TOOL_SPEC = {
7
+ name: DELEGATE_TOOL,
8
+ label: "Delegate task",
9
+ description:
10
+ "Hand research that needs many searches or long reading to a cheaper worker model that has only web search and page reading. It runs in the background, and its report comes back to you in this channel as a new turn, where you answer with it. The worker sees nothing but the task, so write it self-contained: the question, what the answer is for, and what to report. Answer quick lookups yourself.",
11
+ parameters: Type.Object({
12
+ title: Type.String({ description: "A short name for the task." }),
13
+ task: Type.String({ description: "The self-contained task." }),
14
+ }),
15
+ };
@@ -0,0 +1,58 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface McpEndpoint {
4
+ name: string;
5
+ url: string;
6
+ /** Absent when a proxy in front of the endpoint adds the credential. */
7
+ bearerToken?: string;
8
+ }
9
+
10
+ interface McpServerEntry {
11
+ url: string;
12
+ auth?: "bearer";
13
+ bearerToken?: string;
14
+ directTools: boolean;
15
+ lifecycle: "eager";
16
+ toolPrefix: "none";
17
+ }
18
+
19
+ interface McpAdapterModule {
20
+ createMcpAdapter(options: {
21
+ config: { mcpServers: Record<string, McpServerEntry> };
22
+ }): ExtensionFactory;
23
+ }
24
+
25
+ // pi-mcp-adapter ships TypeScript sources that fail this project's strict type check, and
26
+ // skipLibCheck does not cover .ts files. A non-literal specifier keeps tsc from following
27
+ // the import; the interface above declares the part the host uses.
28
+ const ADAPTER_MODULE: string = "pi-mcp-adapter";
29
+ const { createMcpAdapter } = (await import(ADAPTER_MODULE)) as McpAdapterModule;
30
+
31
+ /**
32
+ * pi-mcp-adapter connected to MCP endpoints such as an MCP gateway's virtual servers. Tools
33
+ * register directly under their server-side names (`toolPrefix: "none"`), so the names
34
+ * the host activates are the names the gateway lists, and they stay within Claude's
35
+ * 64-character limit after claude-bridge adds its own prefix.
36
+ */
37
+ export function mcpAdapterExtension(
38
+ endpoints: readonly McpEndpoint[],
39
+ ): ExtensionFactory {
40
+ return createMcpAdapter({
41
+ config: {
42
+ mcpServers: Object.fromEntries(
43
+ endpoints.map((endpoint) => [
44
+ endpoint.name,
45
+ {
46
+ url: endpoint.url,
47
+ ...(endpoint.bearerToken
48
+ ? { auth: "bearer" as const, bearerToken: endpoint.bearerToken }
49
+ : {}),
50
+ directTools: true,
51
+ lifecycle: "eager" as const,
52
+ toolPrefix: "none" as const,
53
+ },
54
+ ]),
55
+ ),
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,9 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname } from "node:path";
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ /** The installed package's folder, as Pi's `additionalExtensionPaths` takes it. */
7
+ export function packageDir(name: string): string {
8
+ return dirname(require.resolve(`${name}/package.json`));
9
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Pins each run's active tools to the current profile. Inline factories load after path
5
+ * extensions, so this handler runs last and undoes additions such as pi-web-access
6
+ * re-adding web_enable or claude-bridge's AskClaude.
7
+ */
8
+ export function profileToolsExtension(
9
+ currentTools: () => readonly string[],
10
+ ): ExtensionFactory {
11
+ return (pi) => {
12
+ pi.on("before_agent_start", () => {
13
+ pi.setActiveTools([...currentTools()]);
14
+ });
15
+ };
16
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { READ_LIMIT, readAttachment } from "./attachment-reader.ts";
4
+
5
+ /** Registers `read_attachment` over one channel's attachment store. */
6
+ export function readAttachmentExtension(dir: string): ExtensionFactory {
7
+ return (pi) => {
8
+ pi.registerTool({
9
+ name: "read_attachment",
10
+ label: "Read attachment",
11
+ description:
12
+ "Read a text or PDF file someone attached in this channel, by the file name listed under Attachments. Long files come in pieces; pass offset to continue.",
13
+ parameters: Type.Object({
14
+ file: Type.String({
15
+ description: "File name from the Attachments list.",
16
+ }),
17
+ offset: Type.Optional(
18
+ Type.Integer({
19
+ minimum: 0,
20
+ description: "Character offset to start from.",
21
+ }),
22
+ ),
23
+ }),
24
+ execute: async (_toolCallId, params) => {
25
+ const { text, total } = await readAttachment(
26
+ dir,
27
+ params.file,
28
+ params.offset ?? 0,
29
+ );
30
+ const start = params.offset ?? 0;
31
+ const end = start + text.length;
32
+ const more =
33
+ end < total
34
+ ? `\n\n[${end} of ${total} characters shown; call again with offset ${end} for more]`
35
+ : "";
36
+ return {
37
+ content: [{ type: "text", text: `${text}${more}` }],
38
+ details: { total, limit: READ_LIMIT },
39
+ };
40
+ },
41
+ });
42
+ };
43
+ }
@@ -0,0 +1,118 @@
1
+ import { type TSchema, Type } from "typebox";
2
+ import { messages } from "../i18n/index.ts";
3
+ import { timeZone } from "../time.ts";
4
+
5
+ /**
6
+ * Schedule tools, the same for the owner agent and party roles. Each acts on the schedules of
7
+ * the channel it runs in; the host decides the channel and speaker, never the model.
8
+ */
9
+ export const SCHEDULE_TOOLS = [
10
+ "schedule_create",
11
+ "schedule_list",
12
+ "schedule_update",
13
+ "schedule_cancel",
14
+ ] as const;
15
+ export type ScheduleToolName = (typeof SCHEDULE_TOOLS)[number];
16
+
17
+ export function isScheduleTool(value: string): value is ScheduleToolName {
18
+ return (SCHEDULE_TOOLS as readonly string[]).includes(value);
19
+ }
20
+
21
+ const timing = () => ({
22
+ in_minutes: Type.Optional(
23
+ Type.Integer({
24
+ minimum: 1,
25
+ description:
26
+ "Run once this many minutes from now; use it for relative times such as in two hours.",
27
+ }),
28
+ ),
29
+ at: Type.Optional(
30
+ Type.String({
31
+ description: `Run once at this ${messages().zoneTime(timeZone())}, "YYYY-MM-DD HH:MM". Give one of in_minutes, at, or time.`,
32
+ }),
33
+ ),
34
+ time: Type.Optional(
35
+ Type.String({
36
+ description: `Repeat at this ${messages().zoneTime(timeZone())} of day, "HH:MM".`,
37
+ }),
38
+ ),
39
+ every_days: Type.Optional(
40
+ Type.Integer({
41
+ minimum: 1,
42
+ maximum: 365,
43
+ description: "With time: run every this many days (default 1, daily).",
44
+ }),
45
+ ),
46
+ start_date: Type.Optional(
47
+ Type.String({
48
+ description:
49
+ 'With every_days: the first day, "YYYY-MM-DD" (default today); later runs count from it.',
50
+ }),
51
+ ),
52
+ weekdays: Type.Optional(
53
+ Type.Array(
54
+ Type.Union(
55
+ ["sun", "mon", "tue", "wed", "thu", "fri", "sat"].map((d) =>
56
+ Type.Literal(d),
57
+ ),
58
+ ),
59
+ {
60
+ description: "With time: run on these weekdays instead of every_days.",
61
+ },
62
+ ),
63
+ ),
64
+ });
65
+
66
+ export interface ScheduleToolSpec {
67
+ name: ScheduleToolName;
68
+ label: string;
69
+ description: string;
70
+ parameters: TSchema;
71
+ }
72
+
73
+ export function scheduleToolSpecs(): readonly ScheduleToolSpec[] {
74
+ return [
75
+ {
76
+ name: "schedule_create",
77
+ label: "Create schedule",
78
+ description: `Schedule a task for yourself in this channel: at the set ${messages().zoneTime(timeZone())} you are woken with the prompt and your answer is posted here. Use it for reminders, follow-ups, and recurring checks someone asks for. Write the prompt as a complete instruction to your future self, including who it is for and what to report; it runs without the current conversation in view.`,
79
+ parameters: Type.Object({
80
+ title: Type.String({ description: "A short name for the schedule." }),
81
+ prompt: Type.String({
82
+ description: "What to do when it runs, self-contained.",
83
+ }),
84
+ ...timing(),
85
+ }),
86
+ },
87
+ {
88
+ name: "schedule_list",
89
+ label: "List schedules",
90
+ description: `List this channel's schedules with their next run, and the current ${messages().zoneTime(timeZone())}. Give id to read one schedule's full prompt.`,
91
+ parameters: Type.Object({
92
+ id: Type.Optional(Type.Integer({ description: "Schedule id." })),
93
+ }),
94
+ },
95
+ {
96
+ name: "schedule_update",
97
+ label: "Update schedule",
98
+ description:
99
+ "Change one of this channel's schedules: its title, its prompt, or its timing (timing fields replace the old timing). A run can use this to keep its own prompt current, such as adding what it already reported.",
100
+ parameters: Type.Object({
101
+ id: Type.Integer({ description: "Schedule id." }),
102
+ title: Type.Optional(Type.String()),
103
+ prompt: Type.Optional(
104
+ Type.String({ description: "The whole new prompt." }),
105
+ ),
106
+ ...timing(),
107
+ }),
108
+ },
109
+ {
110
+ name: "schedule_cancel",
111
+ label: "Cancel schedule",
112
+ description: "Delete one of this channel's schedules.",
113
+ parameters: Type.Object({
114
+ id: Type.Integer({ description: "Schedule id." }),
115
+ }),
116
+ },
117
+ ];
118
+ }
@@ -0,0 +1,50 @@
1
+ /** An assistant message of a Pi session, as far as its final answer needs it. */
2
+ interface AssistantLike {
3
+ role: "assistant";
4
+ content: unknown;
5
+ stopReason?: string;
6
+ errorMessage?: string;
7
+ }
8
+
9
+ /** The text parts of a message's content, joined by newlines. */
10
+ export function textOf(content: unknown): string {
11
+ if (typeof content === "string") return content;
12
+ if (!Array.isArray(content)) return "";
13
+ return content
14
+ .filter(
15
+ (part): part is { type: "text"; text: string } =>
16
+ part?.type === "text" && typeof part.text === "string",
17
+ )
18
+ .map((part) => part.text)
19
+ .join("\n");
20
+ }
21
+
22
+ /**
23
+ * A run's last assistant message, or why there is no usable one: none at all, a failed run, or
24
+ * an aborted one. `aborted` names an abort that carries no error message of its own.
25
+ */
26
+ export function lastAssistant<M extends { role: string }>(
27
+ messages: readonly M[],
28
+ aborted?: string,
29
+ ): { ok: true; message: M & AssistantLike } | { ok: false; error: string } {
30
+ const last = messages.findLast(
31
+ (message): message is M & AssistantLike => message.role === "assistant",
32
+ );
33
+ if (!last)
34
+ return { ok: false, error: "the run produced no assistant message" };
35
+ if (
36
+ last.errorMessage ||
37
+ last.stopReason === "error" ||
38
+ last.stopReason === "aborted"
39
+ ) {
40
+ return {
41
+ ok: false,
42
+ error:
43
+ last.errorMessage ??
44
+ (last.stopReason === "aborted" && aborted
45
+ ? aborted
46
+ : `the run stopped with ${last.stopReason}`),
47
+ };
48
+ }
49
+ return { ok: true, message: last };
50
+ }
@@ -0,0 +1,9 @@
1
+ /** The text a tool returns, in the shape Pi expects. */
2
+ export function toolText(text: string) {
3
+ return { content: [{ type: "text" as const, text }], details: {} };
4
+ }
5
+
6
+ /** A refused call's reason, which the model reads as the tool's error result. */
7
+ export function toolError(text: string) {
8
+ return { ...toolText(text), isError: true };
9
+ }
@@ -0,0 +1,18 @@
1
+ import { rmSync } from "node:fs";
2
+ import type { Server } from "bun";
3
+
4
+ /**
5
+ * Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
6
+ * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
7
+ * types reject the option there, hence the cast. A stale socket file is removed first.
8
+ */
9
+ export function serveUnix(
10
+ socketPath: string,
11
+ fetch: (request: Request) => Response | Promise<Response>,
12
+ ): Server<undefined> {
13
+ rmSync(socketPath, { force: true });
14
+ const options = { unix: socketPath, idleTimeout: 0, fetch };
15
+ return Bun.serve(
16
+ options as unknown as Parameters<typeof Bun.serve>[0],
17
+ ) as Server<undefined>;
18
+ }
@@ -0,0 +1,9 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { oversizedFiles } from "./testing/file-size.ts";
4
+
5
+ test("no file of the core is over the size limit", () => {
6
+ expect(
7
+ oversizedFiles(resolve(import.meta.dir, "../.."), ["src/core"]),
8
+ ).toEqual([]);
9
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ConfigError } from "./domain/errors.ts";
3
+ import { parseTierMembers, speakerPolicy, tierAtLeast } from "./speakers.ts";
4
+
5
+ const OWNER = "100000000000000001";
6
+ const ADMIN = "100000000000000002";
7
+ const MEMBER = "100000000000000003";
8
+ const ROLE = "200000000000000001";
9
+ const STRANGER = "100000000000000009";
10
+ const author = (id: string, roleIds: string[] = []) => ({
11
+ id,
12
+ name: `u${id.slice(-1)}`,
13
+ roleIds,
14
+ });
15
+
16
+ describe("speakerPolicy", () => {
17
+ test("names only the owner by default, so nobody else is a speaker", () => {
18
+ const policy = speakerPolicy({ owners: [OWNER] });
19
+ expect(policy.resolve(author(OWNER))?.tier).toBe("owner");
20
+ expect(policy.resolve(author(STRANGER, [ROLE]))).toBeUndefined();
21
+ });
22
+
23
+ test("admins and members come from users or roles, and the highest tier wins", () => {
24
+ const policy = speakerPolicy({
25
+ owners: [OWNER],
26
+ admins: { users: [ADMIN], roles: [ROLE] },
27
+ members: { users: [MEMBER, ADMIN] },
28
+ });
29
+ expect(policy.resolve(author(ADMIN))?.tier).toBe("admin");
30
+ expect(policy.resolve(author(STRANGER, [ROLE]))?.tier).toBe("admin");
31
+ expect(policy.resolve(author(MEMBER))?.tier).toBe("member");
32
+ expect(policy.resolve(author(OWNER, [ROLE]))?.tier).toBe("owner");
33
+ expect(policy.resolve(author(STRANGER))).toBeUndefined();
34
+ });
35
+
36
+ test("@everyone makes every author a member", () => {
37
+ const policy = speakerPolicy({
38
+ owners: [OWNER],
39
+ members: { everyone: true },
40
+ });
41
+ expect(policy.resolve(author(STRANGER))).toEqual({
42
+ id: STRANGER,
43
+ name: "u9",
44
+ tier: "member",
45
+ });
46
+ });
47
+ });
48
+
49
+ test("tiers order owner above admin above member", () => {
50
+ expect(tierAtLeast("owner", "admin")).toBe(true);
51
+ expect(tierAtLeast("admin", "admin")).toBe(true);
52
+ expect(tierAtLeast("member", "admin")).toBe(false);
53
+ });
54
+
55
+ describe("parseTierMembers", () => {
56
+ test("reads users, roles, and @everyone", () => {
57
+ expect(
58
+ parseTierMembers(
59
+ `user:${ADMIN}, role:${ROLE}, ${MEMBER}, @everyone`,
60
+ "X",
61
+ true,
62
+ ),
63
+ ).toEqual({ users: [ADMIN, MEMBER], roles: [ROLE], everyone: true });
64
+ });
65
+
66
+ test("unset or empty means nobody", () => {
67
+ expect(parseTierMembers(undefined, "X", true)).toBeUndefined();
68
+ expect(parseTierMembers(" , ", "X", true)).toBeUndefined();
69
+ });
70
+
71
+ test("refuses a malformed entry, and @everyone where it is not allowed", () => {
72
+ expect(() => parseTierMembers("nope", "X", true)).toThrow(ConfigError);
73
+ expect(() => parseTierMembers("group:1", "X", true)).toThrow(ConfigError);
74
+ expect(() => parseTierMembers("@everyone", "X", false)).toThrow(
75
+ /@everyone/,
76
+ );
77
+ });
78
+ });