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,477 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ type AgentSession,
5
+ type ModelRuntime,
6
+ SessionManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
9
+ import { withAttachmentsBlock } from "../attachments/prompt-block.ts";
10
+ import { NO_ATTACHMENTS, type TurnAttachments } from "../domain/attachment.ts";
11
+ import type {
12
+ ChannelKey,
13
+ PendingConfirmation,
14
+ TranscriptEntry,
15
+ TurnResult,
16
+ } from "../domain/conversation.ts";
17
+ import { AgentRunError, ConfigError } from "../domain/errors.ts";
18
+ import type { AgentRuntime, TurnRequest } from "../domain/ports.ts";
19
+ import { assistantName } from "../i18n/index.ts";
20
+ import {
21
+ AUTO_THINKING,
22
+ parseModelRef,
23
+ type ThinkingLevel,
24
+ type ThinkingSetting,
25
+ } from "../models.ts";
26
+ import { planOrder, type TransientTask } from "../sessions.ts";
27
+ import { textOf } from "../shared/session-messages.ts";
28
+ import { addressee, type Speaker, type Tier } from "../speakers.ts";
29
+ import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
30
+ import { ConversationSessions } from "./conversation-sessions.ts";
31
+ import { ASK_USER_TOOL } from "./extensions/ask-user.ts";
32
+ import {
33
+ ConfirmationGate,
34
+ confirmedTurnText,
35
+ } from "./extensions/confirmation-gate.ts";
36
+ import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
37
+ import { PromptSlot, workTimeout } from "./prompt-slot.ts";
38
+ import {
39
+ type PiAgentRuntimeOptions,
40
+ TRANSCRIPT_ENTRY_CHARS,
41
+ type TurnMessage,
42
+ } from "./runtime-types.ts";
43
+ import { archiveSessions } from "./session-archive.ts";
44
+ import { SessionFactory } from "./session-factory.ts";
45
+ import { type PromptImages, SteerableRun } from "./steerable-run.ts";
46
+ import { lastReply, turnAnswer } from "./turn-answer.ts";
47
+ import { workerReport } from "./worker-task.ts";
48
+
49
+ export {
50
+ type CoreExtensions,
51
+ type LoadedSkill,
52
+ type PiAgentRuntimeOptions,
53
+ sessionExtensions,
54
+ } from "./runtime-types.ts";
55
+
56
+ /** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
57
+ const STOPPED = "stopped by the owner";
58
+
59
+ // pi-lens-ignore: large-class — pi-roundtable batches 2b and 4 move tools, dispatch, and routing out of it
60
+ export class PiAgentRuntime implements AgentRuntime {
61
+ readonly #options: PiAgentRuntimeOptions;
62
+ readonly #modelRuntime: ModelRuntime;
63
+ readonly #tiers: ToolTiers;
64
+ readonly #factory: SessionFactory;
65
+ readonly #sessions: ConversationSessions;
66
+ /** The tier and speaker of each conversation's running turn. */
67
+ readonly #turns = new Map<
68
+ ChannelKey,
69
+ { tier: Tier; speaker: Speaker | undefined }
70
+ >();
71
+ /** Each conversation's turn in progress, which the owner may steer or stop. */
72
+ readonly #running = new Map<ChannelKey, SteerableRun>();
73
+
74
+ /** Builds the runtime without starting a session; the preflight proves it can run. */
75
+ constructor(options: PiAgentRuntimeOptions) {
76
+ this.#options = options;
77
+ this.#modelRuntime = options.modelRuntime;
78
+ this.#tiers = options.toolTiers ?? toolTiers();
79
+ this.#factory = new SessionFactory(options, {
80
+ speaker: (channel) => this.#turns.get(channel)?.speaker,
81
+ runTask: (channel, task) => this.#runTask(channel, task),
82
+ });
83
+ this.#sessions = new ConversationSessions(
84
+ options,
85
+ this.#factory,
86
+ this.#tiers,
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Proves the model and every required tool resolve, in a throwaway session that runs no turn;
92
+ * startup stops here before anything connects.
93
+ */
94
+ async preflight(): Promise<void> {
95
+ const probe = await this.#factory.create(
96
+ "probe:startup",
97
+ SessionManager.inMemory(this.#factory.workDir()),
98
+ new ConfirmationGate(this.#factory.link().holds, this.#options.owner),
99
+ new PromptSlot(),
100
+ join(this.#factory.workDir(), "probe-attachments"),
101
+ undefined,
102
+ );
103
+ const registered = new Set(
104
+ probe.session.getAllTools().map((tool) => tool.name),
105
+ );
106
+ probe.session.dispose();
107
+ const expected = [
108
+ ...this.#options.requiredTools,
109
+ COMPACT_TOOL,
110
+ ASK_USER_TOOL,
111
+ ...planOrder(this.#factory.plan).flatMap(
112
+ (tool) => tool.snapshot().requiredTools ?? [],
113
+ ),
114
+ ];
115
+ const missing = expected.filter((name) => !registered.has(name));
116
+ if (missing.length > 0) {
117
+ throw new ConfigError(
118
+ `profile tools are not registered: ${missing.join(", ")}`,
119
+ );
120
+ }
121
+ }
122
+
123
+ // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
124
+ async runTurn(request: TurnRequest): Promise<TurnResult> {
125
+ const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
126
+ const key = request.agent?.session ?? request.channel;
127
+ const channelSession = await this.#sessions.freshSession(key, request);
128
+ const { session } = channelSession;
129
+ const level = await this.#thinkingLevel(key, session, request);
130
+ if (session.thinkingLevel !== level) session.setThinkingLevel(level);
131
+ const gate = await this.#sessions.gate(key, request.agent !== undefined);
132
+ const pending = gate.pending();
133
+ const registered = new Set(session.getAllTools().map((tool) => tool.name));
134
+ // The speaker's tier limits the tools; a turn nobody spoke in, such as the owner's own chat, is the owner's.
135
+ const tier = request.speaker?.tier ?? "owner";
136
+ // An approving turn also has the held calls' tools, which a dispatched worker may have used.
137
+ const wanted = toolsForTier(
138
+ [
139
+ ...new Set([
140
+ ...this.#factory.toolsFor(request.selection),
141
+ ...(request.confirmed && pending
142
+ ? pending.calls.map((call) => call.tool)
143
+ : []),
144
+ ]),
145
+ ],
146
+ tier,
147
+ this.#tiers,
148
+ );
149
+ this.#turns.set(key, { tier, speaker: request.speaker });
150
+ const missing = wanted.filter((name) => !registered.has(name));
151
+ if (missing.length > 0) {
152
+ logger.warn(
153
+ {
154
+ channel: request.channel,
155
+ profile: request.selection.id,
156
+ missing,
157
+ },
158
+ "profile tools missing; running without them",
159
+ );
160
+ }
161
+ channelSession.tools = wanted.filter((name) => registered.has(name));
162
+ session.setActiveToolsByName([...channelSession.tools]);
163
+ gate.beginTurn(
164
+ request.selection.id,
165
+ request.confirmed === true,
166
+ addressee(request.speaker, this.#options.owner),
167
+ );
168
+ if (request.confirmed && pending) {
169
+ logger.info(
170
+ {
171
+ channel: request.channel,
172
+ tools: pending.calls.map((call) => call.tool),
173
+ },
174
+ "confirmed actions released",
175
+ );
176
+ }
177
+
178
+ // Collected as they end: a compaction during the turn shortens session.messages.
179
+ const turnMessages: TurnMessage[] = [];
180
+ const toolCalls: string[] = [];
181
+ const unsubscribe = session.subscribe((event) => {
182
+ if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
183
+ if (event.type === "message_end") turnMessages.push(event.message);
184
+ });
185
+ const slot = this.#sessions.slot(key);
186
+ slot.bind(
187
+ request.interactive
188
+ ? this.#options.prompts?.(request.channel, request.speaker)
189
+ : undefined,
190
+ request.agent?.name ?? assistantName(),
191
+ );
192
+ // Time spent waiting on the owner's cards does not count towards the timeout.
193
+ const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
194
+ logger.warn(
195
+ { channel: request.channel, turnTimeoutMs },
196
+ "turn timed out; aborting",
197
+ );
198
+ void session.abort();
199
+ });
200
+ const running = new SteerableRun(
201
+ session,
202
+ () => request.steerable === true && !gate.pending(),
203
+ );
204
+ this.#running.set(key, running);
205
+
206
+ try {
207
+ const attachments = request.attachments ?? NO_ATTACHMENTS;
208
+ const text =
209
+ request.confirmed && pending
210
+ ? confirmedTurnText(
211
+ pending,
212
+ request.text,
213
+ addressee(request.speaker, this.#options.owner),
214
+ )
215
+ : request.text;
216
+ await running.run(() =>
217
+ session.prompt(
218
+ withAttachmentsBlock(text, attachments),
219
+ attachments.images.length > 0
220
+ ? { images: promptImages(attachments) }
221
+ : undefined,
222
+ ),
223
+ );
224
+ } catch (error) {
225
+ if (running.stopped)
226
+ return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
227
+ return {
228
+ ok: false,
229
+ error: new AgentRunError(`prompt failed: ${String(error)}`),
230
+ };
231
+ } finally {
232
+ this.#running.delete(key);
233
+ this.#turns.delete(key);
234
+ cancelTimeout();
235
+ slot.unbind();
236
+ unsubscribe();
237
+ gate.endTurn();
238
+ const usage = session.getContextUsage();
239
+ if (usage)
240
+ this.#sessions.usage.set(key, {
241
+ tokens: usage.tokens,
242
+ contextWindow: usage.contextWindow,
243
+ });
244
+ const held = gate.pending();
245
+ await this.#options.confirmations
246
+ .save(key, held)
247
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
248
+ .catch((error: unknown) =>
249
+ logger.error({ channel: key, err: error }, "held actions not saved"),
250
+ );
251
+ if (held)
252
+ logger.info(
253
+ { channel: key, held: held.calls.map((call) => call.action) },
254
+ "actions held for confirmation",
255
+ );
256
+ logger.info(
257
+ {
258
+ channel: key,
259
+ profile: request.selection.id,
260
+ toolCalls,
261
+ thinking: session.thinkingLevel,
262
+ // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
263
+ ...(request.agent
264
+ ? { model: `${session.model?.provider}/${session.model?.id}` }
265
+ : {}),
266
+ },
267
+ "turn finished",
268
+ );
269
+ }
270
+
271
+ if (running.stopped)
272
+ return { ok: false, error: new AgentRunError(STOPPED), stopped: true };
273
+ return turnAnswer(turnMessages, running.steered);
274
+ }
275
+
276
+ async steer(
277
+ channel: ChannelKey,
278
+ text: string,
279
+ attachments: TurnAttachments,
280
+ speakerId?: string,
281
+ ): Promise<boolean> {
282
+ const running = this.#running.get(channel);
283
+ if (!running) return false;
284
+ // A message from another speaker waits for its own turn instead of joining this one.
285
+ const started = this.#turns.get(channel)?.speaker?.id;
286
+ if (
287
+ speakerId !== undefined &&
288
+ started !== undefined &&
289
+ speakerId !== started
290
+ )
291
+ return false;
292
+ try {
293
+ const steered = await running.steer(
294
+ withAttachmentsBlock(text, attachments),
295
+ promptImages(attachments),
296
+ );
297
+ if (steered)
298
+ this.#options.logger.info({ channel }, "message steered into the turn");
299
+ return steered;
300
+ } catch (error) {
301
+ this.#options.logger.warn({ channel, err: error }, "steer refused");
302
+ return false;
303
+ }
304
+ }
305
+
306
+ stop(channel: ChannelKey): boolean {
307
+ const stopped = this.#running.get(channel)?.stop() ?? false;
308
+ if (stopped)
309
+ this.#options.logger.info({ channel }, "turn stopped by the owner");
310
+ return stopped;
311
+ }
312
+
313
+ /**
314
+ * Runs a task in a fresh, unsaved session with the selected tools and the channel's
315
+ * confirmation gate, and returns its final text.
316
+ */
317
+ // pi-lens-ignore: high-fan-out — builds, runs, and disposes one transient session; batch 4 turns it into the core's child-run API
318
+ async #runTask(channel: ChannelKey, task: TransientTask): Promise<string> {
319
+ const { dataDir } = this.#options;
320
+ const { signal } = task;
321
+ // A worker asks nothing: its held actions wait for the owner's next message.
322
+ const worker = await this.#factory.create(
323
+ channel,
324
+ SessionManager.inMemory(this.#factory.workDir()),
325
+ await this.#sessions.gate(channel, false),
326
+ new PromptSlot(),
327
+ ownerAttachmentDir(dataDir, channel),
328
+ undefined,
329
+ );
330
+ const { session } = worker;
331
+ const toolCalls: string[] = [];
332
+ const unsubscribe = session.subscribe((event) => {
333
+ if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
334
+ });
335
+ const abort = () => void session.abort();
336
+ signal?.addEventListener("abort", abort, { once: true });
337
+ const timer = setTimeout(abort, task.timeoutMs);
338
+ try {
339
+ const registered = new Set(session.getAllTools().map((t) => t.name));
340
+ const excluded = new Set([...task.exclude, ASK_USER_TOOL]);
341
+ // A task works at the tier of the turn that started it.
342
+ const tier = this.#turns.get(channel)?.tier ?? "owner";
343
+ worker.tools = this.#factory
344
+ .toolsFor(task.selection)
345
+ .filter(
346
+ (name) =>
347
+ !excluded.has(name) &&
348
+ registered.has(name) &&
349
+ this.#tiers.allows(tier, name),
350
+ );
351
+ session.setActiveToolsByName([...worker.tools]);
352
+ await session.prompt(task.text);
353
+ return workerReport(
354
+ session.messages,
355
+ "the worker stopped before it reported",
356
+ );
357
+ } finally {
358
+ clearTimeout(timer);
359
+ signal?.removeEventListener("abort", abort);
360
+ unsubscribe();
361
+ session.dispose();
362
+ task.onFinished?.(toolCalls);
363
+ }
364
+ }
365
+
366
+ async startFresh(channel: ChannelKey): Promise<void> {
367
+ await this.#sessions.forget(channel);
368
+ const archived = archiveSessions(this.#factory.sessionDir(channel));
369
+ this.#options.logger.info(
370
+ { channel, archived },
371
+ "conversation started over",
372
+ );
373
+ }
374
+
375
+ async deleteConversation(channel: ChannelKey): Promise<void> {
376
+ await this.#sessions.forget(channel);
377
+ rmSync(this.#factory.sessionDir(channel), { recursive: true, force: true });
378
+ this.#options.logger.info({ channel }, "conversation deleted");
379
+ }
380
+
381
+ /** Undefined until the conversation has run a turn since startup. */
382
+ // pi-lens-ignore: pass-through-wrappers — AgentRuntime's port method over the private usage map
383
+ contextUsage(
384
+ channel: ChannelKey,
385
+ ): { tokens: number | null; contextWindow: number } | undefined {
386
+ return this.#sessions.usage.get(channel);
387
+ }
388
+
389
+ pendingConfirmation(channel: ChannelKey): PendingConfirmation | undefined {
390
+ return this.#sessions.pending(channel);
391
+ }
392
+
393
+ /** An agent conversation's held actions, restored from the store after a restart. */
394
+ async heldActions(
395
+ session: ChannelKey,
396
+ ): Promise<PendingConfirmation | undefined> {
397
+ const gate = await this.#sessions.gate(session, true);
398
+ return gate.pending();
399
+ }
400
+
401
+ async recentTranscript(
402
+ channel: ChannelKey,
403
+ limit: number,
404
+ ): Promise<TranscriptEntry[]> {
405
+ const { session } = await this.#sessions.session(channel);
406
+ const entries: TranscriptEntry[] = [];
407
+ for (const message of session.messages) {
408
+ if (message.role !== "user" && message.role !== "assistant") continue;
409
+ const text = textOf(message.content).trim();
410
+ if (text)
411
+ entries.push({
412
+ role: message.role,
413
+ text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
414
+ });
415
+ }
416
+ return entries.slice(-limit);
417
+ }
418
+
419
+ /**
420
+ * The turn's thinking level: an agent's fixed setting, or the judge's pick, which is kept for the
421
+ * next turn while the judge is unsure. An agent session also switches to the agent's model here.
422
+ */
423
+ async #thinkingLevel(
424
+ key: ChannelKey,
425
+ session: AgentSession,
426
+ request: TurnRequest,
427
+ ): Promise<ThinkingLevel> {
428
+ const setting = request.agent
429
+ ? await this.#useAgentModel(session, request.agent.name)
430
+ : AUTO_THINKING;
431
+ if (setting !== AUTO_THINKING) {
432
+ this.#sessions.judged.delete(key);
433
+ return setting;
434
+ }
435
+ const level = await this.#options.effort.judge(request.text, {
436
+ reply: lastReply(session.messages),
437
+ level: this.#sessions.judged.get(key),
438
+ });
439
+ this.#sessions.judged.set(key, level);
440
+ return level;
441
+ }
442
+
443
+ /** Switches an agent session to the agent's current model; returns its thinking setting. */
444
+ async #useAgentModel(
445
+ session: AgentSession,
446
+ name: string,
447
+ ): Promise<ThinkingSetting> {
448
+ const agents = this.#options.agents;
449
+ if (!agents)
450
+ throw new ConfigError("agent turns need the runtime's agents option");
451
+ const { model, thinking } = agents.modelOf(name);
452
+ const ref = parseModelRef(model);
453
+ if (
454
+ session.model?.provider !== ref?.provider ||
455
+ session.model?.id !== ref?.id
456
+ ) {
457
+ const resolved = ref && this.#modelRuntime.getModel(ref.provider, ref.id);
458
+ if (!resolved)
459
+ throw new AgentRunError(
460
+ `${name}'s model ${model} is not available on this host`,
461
+ );
462
+ await session.setModel(resolved);
463
+ }
464
+ return thinking;
465
+ }
466
+
467
+ dispose(): void {
468
+ this.#sessions.dispose();
469
+ }
470
+ }
471
+
472
+ function promptImages(attachments: TurnAttachments): PromptImages {
473
+ return attachments.images.map((image) => ({
474
+ type: "image" as const,
475
+ ...image,
476
+ }));
477
+ }
@@ -0,0 +1,81 @@
1
+ import type { OwnerPrompts, OwnerQuestion } from "../domain/owner-prompts.ts";
2
+ import { assistantName } from "../i18n/index.ts";
3
+
4
+ /**
5
+ * A run's way to ask the owner while it works. A turn he started or a report turn binds the
6
+ * cards of its channel, and a coding worker those of its dispatch thread; a schedule's turn, or
7
+ * a session with no Discord channel, leaves the slot empty and held actions wait for his next
8
+ * message instead. The slot also counts how long the run waited on him, which its time limit
9
+ * leaves out.
10
+ */
11
+ export class PromptSlot {
12
+ #prompts: OwnerPrompts | undefined;
13
+ #asker = assistantName();
14
+ #open = 0;
15
+ #openSince = 0;
16
+ #waited = 0;
17
+
18
+ /** Binds the turn's prompts; `asker` names who asks, the assistant or an agent. */
19
+ bind(prompts: OwnerPrompts | undefined, asker: string): void {
20
+ this.#prompts = prompts;
21
+ this.#asker = asker;
22
+ this.#open = 0;
23
+ this.#waited = 0;
24
+ }
25
+
26
+ unbind(): void {
27
+ this.#prompts = undefined;
28
+ }
29
+
30
+ get asker(): string {
31
+ return this.#asker;
32
+ }
33
+
34
+ /** The turn's prompts, each open card counted as waiting; undefined when none is bound. */
35
+ get prompts(): OwnerPrompts | undefined {
36
+ const prompts = this.#prompts;
37
+ if (!prompts) return undefined;
38
+ return {
39
+ confirm: (title, message, signal) =>
40
+ this.#waiting(() => prompts.confirm(title, message, signal)),
41
+ ask: (title, question: OwnerQuestion, signal) =>
42
+ this.#waiting(() => prompts.ask(title, question, signal)),
43
+ };
44
+ }
45
+
46
+ /** Time this turn has spent with at least one card open. */
47
+ waitedMs(now = Date.now()): number {
48
+ return this.#waited + (this.#open > 0 ? now - this.#openSince : 0);
49
+ }
50
+
51
+ async #waiting<T>(ask: () => Promise<T>): Promise<T> {
52
+ if (this.#open++ === 0) this.#openSince = Date.now();
53
+ try {
54
+ return await ask();
55
+ } finally {
56
+ if (--this.#open === 0) this.#waited += Date.now() - this.#openSince;
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Calls `expire` once the run has worked `ms`, not counting time the slot spent waiting on the
63
+ * owner's cards; returns a function that cancels it.
64
+ */
65
+ export function workTimeout(
66
+ ms: number,
67
+ slot: Pick<PromptSlot, "waitedMs">,
68
+ expire: () => void,
69
+ ): () => void {
70
+ const started = Date.now();
71
+ let timer: ReturnType<typeof setTimeout> | undefined;
72
+ const arm = (delay: number) => {
73
+ timer = setTimeout(() => {
74
+ const left = ms - (Date.now() - started - slot.waitedMs());
75
+ if (left > 0) return arm(left);
76
+ expire();
77
+ }, delay);
78
+ };
79
+ arm(ms);
80
+ return () => clearTimeout(timer);
81
+ }
@@ -0,0 +1,150 @@
1
+ import type {
2
+ AgentSession,
3
+ AgentSessionEvent,
4
+ ExtensionFactory,
5
+ ModelRuntime,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { ChannelKey } from "../domain/conversation.ts";
8
+ import type { OwnerPrompts } from "../domain/owner-prompts.ts";
9
+ import type { AgentTurnScope } from "../domain/ports.ts";
10
+ import type { OwnerIdentity } from "../identity.ts";
11
+ import type { Logger } from "../log.ts";
12
+ import type {
13
+ ThinkingLevel,
14
+ ThinkingPicker,
15
+ ThinkingSetting,
16
+ } from "../models.ts";
17
+ import type { LinkedSessions } from "../plugin.ts";
18
+ import {
19
+ planOrder,
20
+ type SessionContext,
21
+ type SessionPlan,
22
+ type SessionTool,
23
+ } from "../sessions.ts";
24
+ import type { Speaker } from "../speakers.ts";
25
+ import type { ToolTiers } from "../tool-tiers.ts";
26
+ import type { PendingConfirmationStore } from "./pending-confirmation-store.ts";
27
+
28
+ export type TurnMessage = Extract<
29
+ AgentSessionEvent,
30
+ { type: "message_end" }
31
+ >["message"];
32
+ export type CompactionEnd = Extract<
33
+ AgentSessionEvent,
34
+ { type: "compaction_end" }
35
+ >;
36
+ export const TRANSCRIPT_ENTRY_CHARS = 1000;
37
+
38
+ export interface PiAgentRuntimeOptions {
39
+ /** Who the conversations serve, as prompts and tool results name them. */
40
+ owner: OwnerIdentity;
41
+ agentDir: string;
42
+ /** Shared with the party broker, so logins refresh in one place. */
43
+ modelRuntime: ModelRuntime;
44
+ dataDir: string;
45
+ model: { provider: string; id: string };
46
+ /** A new session's level until its first turn; each turn then runs at a level of its own. */
47
+ thinking: ThinkingLevel;
48
+ /** Picks the level of the assistant's turns and of agents whose thinking is `auto`. */
49
+ effort: ThinkingPicker;
50
+ persona: string;
51
+ /** Held actions survive a restart here, so a confirmation after one still runs. */
52
+ confirmations: Pick<PendingConfirmationStore, "load" | "save">;
53
+ logger: Logger;
54
+ /**
55
+ * The plugins' linked session parts: hold rules, Pi packages, and the extensions placed by
56
+ * phase around the core's own. Read once the host has linked them, from the preflight on.
57
+ * A changed snapshot revision rebuilds each session on its next turn, keeping its history.
58
+ */
59
+ sessions: () => LinkedSessions;
60
+ /** Tools startup refuses to run without, besides those the session tools require. */
61
+ requiredTools: readonly string[];
62
+ /** The agent server's sessions: their shared workspace, model, and skills. */
63
+ agents?: AgentSessions;
64
+ /**
65
+ * Cards the owner answers in a channel, for interactive turns; undefined for a channel that
66
+ * cannot show them. Without it every held action waits for his next message.
67
+ */
68
+ prompts?: (
69
+ channel: ChannelKey,
70
+ speaker?: Speaker,
71
+ ) => OwnerPrompts | undefined;
72
+ /** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
73
+ toolTiers?: ToolTiers;
74
+ /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
75
+ turnTimeoutMs?: number;
76
+ /** How long a new session waits for its MCP tools to register. */
77
+ mcpConnectTimeoutMs?: number;
78
+ }
79
+
80
+ export interface AgentSessions {
81
+ /** The shell's working directory, shared by every agent; writes outside it are held. */
82
+ workDir: string;
83
+ /**
84
+ * The skills the agent carries, read at the start of every run; a change rebuilds its
85
+ * sessions, keeping their history (repos-and-skills spec behavior 21).
86
+ */
87
+ skills(name: string): readonly LoadedSkill[];
88
+ /** The agent's model (`<provider>/<id>`) and thinking setting, read at the start of every run. */
89
+ modelOf(name: string): { model: string; thinking: ThinkingSetting };
90
+ /** The channel the scope's turns run in: the group's for a seat in one, else the agent's own. */
91
+ turnChannel(scope: AgentTurnScope): ChannelKey;
92
+ }
93
+
94
+ /** A skill file a session loads; only its name and description enter the prompt. */
95
+ export interface LoadedSkill {
96
+ name: string;
97
+ description: string;
98
+ file: string;
99
+ }
100
+
101
+ export const skillsKey = (skills: readonly LoadedSkill[]) =>
102
+ JSON.stringify(skills);
103
+ export const revisionsKey = (plan: SessionPlan) =>
104
+ JSON.stringify(planOrder(plan).map((tool) => tool.snapshot().revision));
105
+
106
+ /** The core's own extensions of one session, which the plan's contributions surround. */
107
+ export interface CoreExtensions {
108
+ readAttachment: ExtensionFactory;
109
+ confirmationGate: ExtensionFactory;
110
+ askUser: ExtensionFactory;
111
+ selfCompactGuard: ExtensionFactory;
112
+ profileTools: ExtensionFactory;
113
+ }
114
+
115
+ /**
116
+ * A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
117
+ * and compact guard, the compactor, the MCP phase, and profile-tools last, so its
118
+ * before_agent_start handler runs after every other extension's.
119
+ */
120
+ export function sessionExtensions(
121
+ plan: SessionPlan,
122
+ session: SessionContext,
123
+ core: CoreExtensions,
124
+ ): { name: string; factory: ExtensionFactory }[] {
125
+ const contributed = (tools: readonly SessionTool[]) =>
126
+ tools.flatMap((tool) => {
127
+ const factory = tool.snapshot().factory(session);
128
+ return factory ? [{ name: tool.name, factory }] : [];
129
+ });
130
+ return [
131
+ ...contributed(plan.tools),
132
+ { name: "read-attachment", factory: core.readAttachment },
133
+ { name: "confirmation-gate", factory: core.confirmationGate },
134
+ { name: "ask-user", factory: core.askUser },
135
+ { name: "self-compact-guard", factory: core.selfCompactGuard },
136
+ ...contributed(plan.compaction ? [plan.compaction] : []),
137
+ ...contributed(plan.mcp),
138
+ { name: "profile-tools", factory: core.profileTools },
139
+ ];
140
+ }
141
+
142
+ export interface ChannelSession {
143
+ session: AgentSession;
144
+ /** Read by the profile-tools extension at the start of every run. */
145
+ tools: readonly string[];
146
+ /** The session tools' revisions the session was built with, as revisionsKey. */
147
+ revisions: string;
148
+ /** The skills an agent session was built with, as skillsKey. */
149
+ skills: string;
150
+ }