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,193 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Model,
6
+ } from "@earendil-works/pi-ai";
7
+ import type {
8
+ ChoiceAnswer,
9
+ ChoiceQuestion,
10
+ Judge,
11
+ ScoreQuestion,
12
+ YesNoQuestion,
13
+ } from "../contract/providers.ts";
14
+ import { JudgeError } from "../errors.ts";
15
+
16
+ /** One completion: a system prompt and a user message in, the model's text out. */
17
+ export type JudgeModel = (system: string, prompt: string) => Promise<string>;
18
+
19
+ const SYSTEM = [
20
+ "You judge a situation for a chat assistant. Read the state and the question, then answer",
21
+ "with exactly one JSON object in the requested format: no prose, no code fence.",
22
+ "Every number is a probability from 0 to 1. For a yes-or-no question it is the probability",
23
+ "that the answer is yes, so a clear no is near 0 and a clear yes near 1.",
24
+ ].join(" ");
25
+
26
+ type Json = Record<string, unknown>;
27
+
28
+ // pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
29
+ const isRecord = (value: unknown): value is Json =>
30
+ typeof value === "object" && value !== null && !Array.isArray(value);
31
+
32
+ // pi-lens-ignore: no-unknown-parameters — a type guard over parsed JSON
33
+ const isProbability = (value: unknown): value is number =>
34
+ typeof value === "number" && value >= 0 && value <= 1;
35
+
36
+ /** The one JSON object in the model's answer, tolerating a code fence or stray words around it. */
37
+ function answerObject(text: string): Json {
38
+ const start = text.indexOf("{");
39
+ const end = text.lastIndexOf("}");
40
+ if (start < 0 || end < start)
41
+ throw new JudgeError(
42
+ `the judge did not answer with JSON: ${text.slice(0, 200)}`,
43
+ );
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(text.slice(start, end + 1));
47
+ } catch {
48
+ throw new JudgeError(
49
+ `the judge answered malformed JSON: ${text.slice(0, 200)}`,
50
+ );
51
+ }
52
+ if (!isRecord(parsed))
53
+ throw new JudgeError("the judge's answer is not a JSON object");
54
+ return parsed;
55
+ }
56
+
57
+ function prompt(state: Json, question: string, format: string): string {
58
+ return [
59
+ "State:",
60
+ JSON.stringify(state, null, 2),
61
+ "",
62
+ question,
63
+ "",
64
+ `Answer with: ${format}`,
65
+ ].join("\n");
66
+ }
67
+
68
+ /**
69
+ * The core's default judge: asks a model for each judgment instead of a hosted judging service.
70
+ * Answers are checked strictly, so a model that strays throws and the caller falls back.
71
+ */
72
+ // pi-lens-ignore: large-class — one method per Judge question type, sharing one prompt and parser
73
+ export class ModelJudge implements Judge {
74
+ readonly #complete: JudgeModel;
75
+
76
+ constructor(complete: JudgeModel) {
77
+ this.#complete = complete;
78
+ }
79
+
80
+ async #ask(state: Json, question: string, format: string): Promise<Json> {
81
+ return answerObject(
82
+ await this.#complete(SYSTEM, prompt(state, question, format)),
83
+ );
84
+ }
85
+
86
+ async askYesNo(
87
+ state: Json,
88
+ questions: Record<string, YesNoQuestion>,
89
+ ): Promise<Record<string, number>> {
90
+ const names = Object.keys(questions);
91
+ const listed = names.map((name) => {
92
+ const { instructions, criteria } = questions[name] as YesNoQuestion;
93
+ const yes = criteria?.true ? ` Yes means: ${criteria.true}.` : "";
94
+ const no = criteria?.false ? ` No means: ${criteria.false}.` : "";
95
+ return `- ${name}: ${instructions}${yes}${no}`;
96
+ });
97
+ const answer = await this.#ask(
98
+ state,
99
+ `Answer each yes-or-no question:\n${listed.join("\n")}`,
100
+ `{${names.map((name) => `"${name}": <probability that the answer is yes>`).join(", ")}}`,
101
+ );
102
+ // pi-lens-ignore: no-known-value-widening — filled per question name below
103
+ const result: Record<string, number> = {};
104
+ for (const name of names) {
105
+ const value = answer[name];
106
+ if (!isProbability(value))
107
+ throw new JudgeError(`the judge gave no probability for ${name}`);
108
+ result[name] = value;
109
+ }
110
+ return result;
111
+ }
112
+
113
+ async askChoice(
114
+ state: Json,
115
+ name: string,
116
+ question: ChoiceQuestion,
117
+ ): Promise<ChoiceAnswer> {
118
+ const options = Object.entries(question.criteria).map(
119
+ ([option, criterion]) => `- ${option}: ${criterion}`,
120
+ );
121
+ const answer = await this.#ask(
122
+ state,
123
+ `${name}: ${question.instructions}\nOptions:\n${options.join("\n")}`,
124
+ '{"choice": "<one option name>", "confidence": <probability>}',
125
+ );
126
+ const { choice, confidence } = answer;
127
+ if (typeof choice !== "string" || !(choice in question.criteria))
128
+ throw new JudgeError(`the judge chose no listed option for ${name}`);
129
+ if (!isProbability(confidence))
130
+ throw new JudgeError(`the judge gave no confidence for ${name}`);
131
+ return { choice, confidence };
132
+ }
133
+
134
+ async askScore(
135
+ state: Json,
136
+ name: string,
137
+ question: ScoreQuestion,
138
+ ): Promise<number[]> {
139
+ const levels = question.criteria.map(
140
+ (criterion, index) => `${index}. ${criterion}`,
141
+ );
142
+ const answer = await this.#ask(
143
+ state,
144
+ `${name}: ${question.instructions}\nLevels, in order:\n${levels.join("\n")}`,
145
+ `{"probabilities": [<one probability per level, ${levels.length} numbers, summing to 1>]}`,
146
+ );
147
+ const { probabilities } = answer;
148
+ if (
149
+ !Array.isArray(probabilities) ||
150
+ probabilities.length !== question.criteria.length ||
151
+ !probabilities.every(isProbability)
152
+ )
153
+ throw new JudgeError(
154
+ `the judge gave no probability per level for ${name}`,
155
+ );
156
+ const total = probabilities.reduce((sum, p) => sum + p, 0);
157
+ if (total <= 0)
158
+ throw new JudgeError(
159
+ `the judge's probabilities for ${name} are all zero`,
160
+ );
161
+ return probabilities.map((p) => p / total);
162
+ }
163
+ }
164
+
165
+ /** The text of a completed answer; an errored one throws. */
166
+ function answerText(message: AssistantMessage): string {
167
+ if (message.stopReason === "error" || message.stopReason === "aborted")
168
+ throw new JudgeError(
169
+ `the judge's model failed: ${message.errorMessage ?? message.stopReason}`,
170
+ );
171
+ return message.content
172
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
173
+ .join("");
174
+ }
175
+
176
+ /** A Pi model as a judge's completion, through the runtime that holds its credentials. */
177
+ export function piJudgeModel(
178
+ runtime: {
179
+ completeSimple(
180
+ model: Model<Api>,
181
+ context: Context,
182
+ ): Promise<AssistantMessage>;
183
+ },
184
+ model: Model<Api>,
185
+ ): JudgeModel {
186
+ return async (system, text) =>
187
+ answerText(
188
+ await runtime.completeSimple(model, {
189
+ systemPrompt: system,
190
+ messages: [{ role: "user", content: text, timestamp: Date.now() }],
191
+ }),
192
+ );
193
+ }
@@ -0,0 +1,45 @@
1
+ import pino, { type Logger } from "pino";
2
+
3
+ export type { Logger };
4
+
5
+ /** One log line as pino writes it: `level`, `time`, `msg`, `err`, and the logged fields. */
6
+ export type LogEntry = Record<string, unknown>;
7
+
8
+ /**
9
+ * JSON lines on stdout, each tagged with `app`, which journald keeps on the host. `onError`,
10
+ * when given, also receives every `error` and `fatal` entry; it must not throw.
11
+ */
12
+ export function createLogger(
13
+ app: string,
14
+ onError?: (entry: LogEntry) => void,
15
+ ): Logger {
16
+ const options = {
17
+ base: { app },
18
+ level: process.env.LOG_LEVEL ?? "info",
19
+ };
20
+ if (!onError) return pino(options);
21
+ return pino(
22
+ options,
23
+ pino.multistream([
24
+ // The logger's own level filters; this stream takes whatever passes it.
25
+ { level: "trace", stream: pino.destination(1) },
26
+ {
27
+ level: "error",
28
+ stream: {
29
+ write(line: string) {
30
+ try {
31
+ onError(JSON.parse(line) as LogEntry);
32
+ } catch {
33
+ // A broken sink never breaks logging.
34
+ }
35
+ },
36
+ },
37
+ },
38
+ ]),
39
+ );
40
+ }
41
+
42
+ /** A logger that drops everything, for tests. */
43
+ export function silentLogger(): Logger {
44
+ return pino({ level: "silent" });
45
+ }
@@ -0,0 +1,52 @@
1
+ import { messages } from "./i18n/index.ts";
2
+ /** Models and thinking levels as Pi names them, and the settings built on them. */
3
+
4
+ export type ThinkingLevel =
5
+ | "off"
6
+ | "minimal"
7
+ | "low"
8
+ | "medium"
9
+ | "high"
10
+ | "xhigh";
11
+
12
+ export const THINKING_LEVELS: readonly ThinkingLevel[] = [
13
+ "off",
14
+ "minimal",
15
+ "low",
16
+ "medium",
17
+ "high",
18
+ "xhigh",
19
+ ];
20
+
21
+ /** An agent's or the assistant's thinking setting: a fixed level, or the judge's choice each turn. */
22
+ export const AUTO_THINKING = "auto";
23
+ export type ThinkingSetting = ThinkingLevel | typeof AUTO_THINKING;
24
+
25
+ /** A thinking setting as the owner reads it in Discord. */
26
+ export function thinkingLabel(setting: ThinkingSetting): string {
27
+ return setting === AUTO_THINKING ? messages().thinkingAuto : setting;
28
+ }
29
+
30
+ /** A model as Pi's model runtime looks it up; written `<provider>/<id>`. */
31
+ export interface ModelRef {
32
+ provider: string;
33
+ id: string;
34
+ }
35
+
36
+ /** `<provider>/<id>` as a ModelRef, or undefined when either part is missing. */
37
+ export function parseModelRef(text: string): ModelRef | undefined {
38
+ const slash = text.indexOf("/");
39
+ if (slash <= 0 || slash === text.length - 1) return undefined;
40
+ return { provider: text.slice(0, slash), id: text.slice(slash + 1) };
41
+ }
42
+
43
+ export const formatModelRef = (model: ModelRef): string =>
44
+ `${model.provider}/${model.id}`;
45
+
46
+ /** Picks one turn's thinking level from its message and the previous turn's reply and level. */
47
+ export interface ThinkingPicker {
48
+ judge(
49
+ message: string,
50
+ previous?: { reply?: string; level?: ThinkingLevel },
51
+ ): Promise<ThinkingLevel>;
52
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ ConversationPort,
3
+ ScheduledOutcome,
4
+ } from "../../contract/channels.ts";
5
+ import type { ChannelKey } from "../../domain/conversation.ts";
6
+ import type { Logger } from "../../log.ts";
7
+ import { zonedStamp } from "../../time.ts";
8
+ import {
9
+ type DelegationJob,
10
+ type DelegationOutcome,
11
+ delegatedTurnText,
12
+ } from "../delegation/delegator.ts";
13
+ import type { Schedule } from "../schedules/schedule-store.ts";
14
+ import { scheduledTurnText } from "../schedules/schedule-tools.ts";
15
+
16
+ export interface BackgroundTurnsOptions {
17
+ conversations: Pick<ConversationPort, "background">;
18
+ /** Who a turn the process itself starts, such as a logged error's report, is written by. */
19
+ system: { id: string; name: string };
20
+ logger: Logger;
21
+ }
22
+
23
+ /**
24
+ * Turns nobody wrote, each answered in its channel by the claim that owns it: a due schedule's,
25
+ * a delegated task's report, and the process's own logged error.
26
+ */
27
+ export class BackgroundTurns {
28
+ readonly #options: BackgroundTurnsOptions;
29
+
30
+ constructor(options: BackgroundTurnsOptions) {
31
+ this.#options = options;
32
+ }
33
+
34
+ /** A due schedule's turn, run as its creator's. */
35
+ runScheduled(schedule: Schedule, firedAt: Date): Promise<ScheduledOutcome> {
36
+ return this.#options.conversations.background({
37
+ channel: schedule.channel,
38
+ mode: schedule.mode,
39
+ author: { id: schedule.createdById, name: schedule.createdByName },
40
+ tier: schedule.createdTier,
41
+ turnId: `schedule-${schedule.id}-${firedAt.getTime()}`,
42
+ text: scheduledTurnText(schedule, firedAt),
43
+ });
44
+ }
45
+
46
+ /** A delegated task's report, answered in its channel under the same rules as a schedule. */
47
+ async runDelegated(
48
+ job: DelegationJob,
49
+ result: DelegationOutcome,
50
+ ): Promise<void> {
51
+ const outcome = await this.#options.conversations.background({
52
+ channel: job.channel,
53
+ mode: job.mode,
54
+ author: job.author,
55
+ ...(job.author.tier ? { tier: job.author.tier } : {}),
56
+ turnId: `delegate-${job.id}-${job.startedAt.getTime()}`,
57
+ text: delegatedTurnText(job, result, zonedStamp(job.startedAt)),
58
+ report: true,
59
+ });
60
+ if (outcome.status !== "ran")
61
+ this.#options.logger.warn(
62
+ { job: job.id, channel: job.channel, outcome },
63
+ "delegated report not answered",
64
+ );
65
+ }
66
+
67
+ /** The process's own logged error, reported to an agent in its channel as a report turn. */
68
+ runErrorReport(channel: ChannelKey, text: string): Promise<ScheduledOutcome> {
69
+ return this.#options.conversations.background({
70
+ channel,
71
+ mode: "owner",
72
+ author: this.#options.system,
73
+ turnId: `error-${Date.now()}`,
74
+ text,
75
+ report: true,
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,54 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../../domain/conversation.ts";
3
+ import { DelegationError } from "../../domain/errors.ts";
4
+ import type { OwnerIdentity } from "../../identity.ts";
5
+ import { textToolsExtension } from "../../runtime/text-tools.ts";
6
+ import { DELEGATE_TOOL_SPEC } from "../../shared/delegate-tool.ts";
7
+ import type { Speaker } from "../../speakers.ts";
8
+ import type { Delegator } from "./delegator.ts";
9
+
10
+ export interface OwnerDelegation {
11
+ delegator: Pick<Delegator, "start">;
12
+ owner: { id: string; name: string };
13
+ /** The chat channel a conversation's reports go to, as for schedules. */
14
+ channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
15
+ }
16
+
17
+ /**
18
+ * Registers delegate_task for one owner conversation. `origin` is the channel its turns run
19
+ * in, where each job's thread opens: a group's, for an agent's seat in one.
20
+ */
21
+ export function delegateExtension(
22
+ delegation: OwnerDelegation,
23
+ channel: ChannelKey,
24
+ identity: OwnerIdentity,
25
+ origin: ChannelKey = channel,
26
+ /** The person the running turn is for; the report comes back at their tier. */
27
+ speaker: () => Speaker | undefined = () => undefined,
28
+ ): ExtensionFactory {
29
+ return textToolsExtension(
30
+ [
31
+ {
32
+ ...DELEGATE_TOOL_SPEC,
33
+ run: async (input) => {
34
+ const { title, task } = input as { title: string; task: string };
35
+ const target = await delegation.channelFor(channel);
36
+ const job = delegation.delegator.start({
37
+ channel: target,
38
+ origin,
39
+ mode: "owner",
40
+ author: speaker() ?? delegation.owner,
41
+ title,
42
+ task,
43
+ });
44
+ const where =
45
+ target === channel
46
+ ? "here"
47
+ : `in ${identity.name}'s Discord direct messages`;
48
+ return `Delegated as task #${job.id}; the report comes back ${where} as a new turn.`;
49
+ },
50
+ },
51
+ ],
52
+ DelegationError,
53
+ );
54
+ }
@@ -0,0 +1,170 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChannelKey } from "../../domain/conversation.ts";
3
+ import { messages } from "../../i18n/index.ts";
4
+ import { silentLogger } from "../../log.ts";
5
+ import { fakeThreads } from "../../testing/thread-host.ts";
6
+ import {
7
+ type DelegationJob,
8
+ type DelegationOutcome,
9
+ Delegator,
10
+ delegatedTurnText,
11
+ } from "./delegator.ts";
12
+
13
+ const OWNER = { id: "1", name: "Riley" };
14
+
15
+ function setup(
16
+ run: (task: string, signal: AbortSignal) => Promise<string>,
17
+ timeoutMs?: number,
18
+ ) {
19
+ const reports: [DelegationJob, DelegationOutcome][] = [];
20
+ const { host, threads } = fakeThreads();
21
+ /** Threads already archived when each report was delivered. */
22
+ const archived: string[][] = [];
23
+ const delegator = new Delegator({
24
+ worker: { run },
25
+ deliver: async (job, outcome) => {
26
+ archived.push([...host.closed]);
27
+ reports.push([job, outcome]);
28
+ },
29
+ threads,
30
+ logger: silentLogger(),
31
+ ...(timeoutMs ? { timeoutMs } : {}),
32
+ });
33
+ return { delegator, reports, host, archived };
34
+ }
35
+
36
+ const request = (channel: ChannelKey = "discord:1") => ({
37
+ channel,
38
+ mode: "party" as const,
39
+ author: OWNER,
40
+ title: "t",
41
+ task: "find it",
42
+ });
43
+
44
+ describe("Delegator", () => {
45
+ test("a report comes back with its job, after start has returned", async () => {
46
+ const { delegator, reports } = setup(async (task) => `report for ${task}`);
47
+ const job = delegator.start(request());
48
+ expect(reports).toEqual([]);
49
+ await delegator.idle();
50
+ expect(reports).toEqual([
51
+ [job, { ok: true, report: "report for find it" }],
52
+ ]);
53
+ });
54
+
55
+ test("a channel holds only so many running jobs; other channels are not affected", async () => {
56
+ let release = () => {};
57
+ const gate = new Promise<void>((resolve) => {
58
+ release = resolve;
59
+ });
60
+ const { delegator } = setup(async () => {
61
+ await gate;
62
+ return "done";
63
+ });
64
+ delegator.start(request());
65
+ delegator.start(request());
66
+ expect(() => delegator.start(request())).toThrow("already has 2");
67
+ expect(delegator.start(request("discord:2")).id).toBe(3);
68
+ release();
69
+ await delegator.idle();
70
+ expect(delegator.start(request()).id).toBe(4);
71
+ await delegator.idle();
72
+ });
73
+
74
+ test("a failing or slow worker reports the failure", async () => {
75
+ const { delegator, reports } = setup(
76
+ (_task, signal) =>
77
+ new Promise((_resolve, reject) =>
78
+ signal.addEventListener("abort", () =>
79
+ reject(new Error("timed out")),
80
+ ),
81
+ ),
82
+ 10,
83
+ );
84
+ delegator.start(request());
85
+ await delegator.idle();
86
+ expect(reports[0]?.[1]).toEqual({ ok: false, error: "timed out" });
87
+ });
88
+
89
+ test("an owner job runs in a thread of its origin, archived with the report before the turn", async () => {
90
+ const { delegator, reports, host, archived } = setup(async () => "R");
91
+ delegator.start({
92
+ ...request("discord:dm"),
93
+ mode: "owner",
94
+ origin: "discord:10",
95
+ title: "check the Bun version",
96
+ });
97
+ await delegator.idle();
98
+ expect(host.opened).toEqual([
99
+ { parentId: "10", name: "check the Bun version", id: "900" },
100
+ ]);
101
+ expect(host.lines.get("10")).toBe(
102
+ messages().threadStarted("check the Bun version", "<#900>"),
103
+ );
104
+ expect(host.textsIn("900")).toEqual([
105
+ messages().delegationTask("find it"),
106
+ messages().delegationDone("R"),
107
+ ]);
108
+ expect(archived).toEqual([["900"]]);
109
+ const [job, outcome] = reports[0] ?? [];
110
+ expect(job?.channel).toBe("discord:dm");
111
+ expect(
112
+ delegatedTurnText(
113
+ job as DelegationJob,
114
+ outcome as DelegationOutcome,
115
+ "x",
116
+ ),
117
+ ).toContain("in the thread <#900>");
118
+ });
119
+
120
+ test("a failed or timed-out job's thread gets the failure and is archived", async () => {
121
+ const { delegator, host } = setup(
122
+ (_task, signal) =>
123
+ new Promise((_resolve, reject) =>
124
+ signal.addEventListener("abort", () =>
125
+ reject(new Error("timed out")),
126
+ ),
127
+ ),
128
+ 10,
129
+ );
130
+ delegator.start({ ...request(), origin: "discord:10" });
131
+ await delegator.idle();
132
+ expect(host.textsIn("900").at(-1)).toBe(
133
+ messages().delegationFailed("timed out"),
134
+ );
135
+ expect(host.closed).toEqual(["900"]);
136
+ });
137
+
138
+ test("a party job, or one whose thread cannot open, reports only in its channel", async () => {
139
+ const { delegator, reports, host } = setup(async () => "R");
140
+ delegator.start(request());
141
+ host.failOpen = true;
142
+ delegator.start({ ...request("discord:2"), origin: "discord:2" });
143
+ await delegator.idle();
144
+ expect(host.posts).toEqual([]);
145
+ expect(reports.map(([job]) => job.thread)).toEqual([undefined, undefined]);
146
+ expect(reports.map(([, outcome]) => outcome.ok)).toEqual([true, true]);
147
+ });
148
+
149
+ test("an empty task is refused", () => {
150
+ const { delegator } = setup(async () => "x");
151
+ expect(() => delegator.start({ ...request(), task: " " })).toThrow(
152
+ "required",
153
+ );
154
+ });
155
+
156
+ test("the report turn carries the task and the report", () => {
157
+ const job: DelegationJob = { ...request(), id: 5, startedAt: new Date(0) };
158
+ const text = delegatedTurnText(
159
+ job,
160
+ { ok: true, report: "R" },
161
+ "2026-09-27 01:00",
162
+ );
163
+ expect(text).toContain("## Delegated task #5: t");
164
+ expect(text).toContain("### Task\nfind it");
165
+ expect(text).toContain("### Report\nR");
166
+ expect(delegatedTurnText(job, { ok: false, error: "boom" }, "x")).toContain(
167
+ "failed: boom",
168
+ );
169
+ });
170
+ });