pi-roundtable 0.22.5 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +351 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import type { CharacterInput, CharacterSpec } from "../characters/index.ts";
2
3
  import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
3
4
  import type { AvatarMode } from "./agents/agent-tools.ts";
4
5
  import type { TeamStatus } from "./agents/team-status.ts";
@@ -14,6 +15,7 @@ import type { HttpRoute } from "./http/listeners.ts";
14
15
  import type { IdentityLinks } from "./identity/identity-links.ts";
15
16
  import type { IdentityService } from "./identity/identity-service.ts";
16
17
  import type { ThinkingLevel, ThinkingSetting } from "./models.ts";
18
+ import type { ConversationModes } from "./modes/conversation-modes.ts";
17
19
  import type {
18
20
  DelegationJob,
19
21
  DelegationOutcome,
@@ -37,6 +39,7 @@ import type {
37
39
  SkillSet,
38
40
  } from "./modules/skills/skill-rules.ts";
39
41
  import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
42
+ import type { DispatchThreads } from "./threads/dispatch-threads.ts";
40
43
 
41
44
  // The services the built-in plugins provide, and the ports they keep to. Each port is an
42
45
  // interface, so an object with the same methods satisfies it without being one of the built-in
@@ -71,6 +74,22 @@ export const RUNTIME: ServiceKey<AgentRuntime> =
71
74
  */
72
75
  export const CONVERSATIONS: ServiceKey<ConversationRegistry> =
73
76
  serviceKey<ConversationRegistry>("roundtable.conversations");
77
+ /**
78
+ * The per-conversation modes, yolo and long-running: read a conversation's with `peek`, set them
79
+ * with `set` as an owner. They are stored per conversation key and survive a restart. Provided by
80
+ * the `modes` plugin, which every host registers.
81
+ */
82
+ export const MODES: ServiceKey<ConversationModes> =
83
+ serviceKey<ConversationModes>("roundtable.modes");
84
+ /**
85
+ * The dispatch threads of the host: a background dispatch (a coding task, a delegated task, a
86
+ * message between agents) opens its thread beside the channel that started it, on the surface
87
+ * that serves the channel, when that surface hosts threads (`ChatSurface.threads`). One instance
88
+ * serves every surface; `DISCORD.threads` is the same instance. Provided by the `threads` plugin,
89
+ * which every host registers.
90
+ */
91
+ export const THREADS: ServiceKey<DispatchThreads> =
92
+ serviceKey<DispatchThreads>("roundtable.threads");
74
93
  /** The agent server: its team, its directory, the runtime that runs every agent turn. Provided by the agent-server plugin. */
75
94
  export const AGENTS: ServiceKey<AgentServer> =
76
95
  serviceKey<AgentServer>("roundtable.agents");
@@ -136,17 +155,22 @@ export interface AgentServer {
136
155
  avatars: AvatarStudio;
137
156
  }
138
157
 
139
- /** Agent pictures: drawn by an image provider, or made from the agent's name, kept and served by content hash. */
158
+ /** Agent pictures: rendered from the agent's character, kept and served by content hash. */
140
159
  export interface AvatarStudio {
141
- /** Whether an image provider is configured; without one, only `fallback` makes pictures. */
160
+ /** @deprecated Agents are no longer drawn, so this only tells whether an image provider is configured (it gates the generate_image hint); removed in 1.0. */
142
161
  readonly canDraw: boolean;
143
162
  /** The public URL of a picture, or of the default when there is none. */
144
163
  url(hash: string | undefined): string;
145
- /** Draws a new picture from an avatar prompt; returns its hash. */
164
+ /**
165
+ * Renders a character as the agent's picture (512 px PNG, modern light disc, idle face), stores
166
+ * it by content hash, and returns the hash; the same spec gives the same hash.
167
+ */
168
+ render(spec: CharacterSpec): Promise<string>;
169
+ /** @deprecated Agents are composed characters and no longer drawn; removed in 1.0. Draws a new picture from an avatar prompt; returns its hash. */
146
170
  draw(avatarPrompt: string): Promise<string>;
147
- /** Edits a stored picture by an instruction; returns the new picture's hash. */
171
+ /** @deprecated Removed in 1.0. Edits a stored picture by an instruction; returns the new picture's hash. */
148
172
  edit(hash: string | undefined, instruction: string): Promise<string>;
149
- /** A picture made from an agent's display name and name, for a host without an image provider; returns its hash. */
173
+ /** @deprecated Removed in 1.0. A picture made from an agent's display name and name, for a host without an image provider; returns its hash. */
150
174
  fallback(displayName: string, name: string): Promise<string>;
151
175
  /** The pictures, public on the named listener. */
152
176
  route(listener: string): HttpRoute;
@@ -176,21 +200,40 @@ export interface AgentChange {
176
200
 
177
201
  /** The agent team as plugins use it: who is in it, what model they run, and their channels. */
178
202
  export interface AgentTeam {
203
+ /** The team's id: the Discord guild, or `"local"` on a host without Discord. */
179
204
  readonly guildId: string;
180
205
  /** Called when a turn starts or ends and when an agent or group changes. */
181
206
  onChange(listener: () => void): void;
182
207
  /** Every active agent and group, with what each is doing now. */
183
208
  status(): Promise<TeamStatus>;
184
- /** The agent or group that owns a channel of the agent server. */
209
+ /**
210
+ * The agent or group that owns a channel of the agent server: a Discord channel, or a room
211
+ * (`<surface>:agent.<name>`, `<surface>:group.<name>`) on a surface that declares `agentRooms`.
212
+ */
185
213
  owns(channel: ChannelKey): "agent" | "group" | undefined;
214
+ /**
215
+ * The name of the coordinator: the agent that answers in the entry channel, or, on a host without
216
+ * Discord, the one `coordinator` names (the first seed by default); undefined while none is active.
217
+ */
218
+ coordinator(): string | undefined;
219
+ /**
220
+ * Every key an agent or group is addressed by: its Discord channel when it has one, then its
221
+ * room on each surface with `agentRooms`; empty for an unknown name.
222
+ */
223
+ roomsOf(name: string): ChannelKey[];
186
224
  /** The model and thinking setting of an agent's next run: its own, or the assistant's. */
187
225
  modelOf(name: string): { model: string; thinking: ThinkingSetting };
188
226
  /** The assistant's model and thinking setting, which agents without their own follow. */
189
227
  defaultModel(): { model: string; thinking: ThinkingSetting };
190
228
  /** Models an agent can be set to: every one the host runs, `current` first when given. */
191
229
  usableModels(current?: string): Promise<string[]>;
192
- /** An active agent's channel; throws ScheduleError for an unknown agent, as the schedule tools catch it. */
193
- channelOf(name: string): ChannelKey;
230
+ /**
231
+ * An active agent's conversation, for reading its schedules: its room on the surface `near`
232
+ * (the asker's own conversation) is on when that surface hosts rooms, otherwise its Discord
233
+ * channel, otherwise its room on the first surface that hosts rooms. Throws ScheduleError for an
234
+ * unknown agent or one with none, as the schedule tools catch it.
235
+ */
236
+ channelOf(name: string, near?: ChannelKey): ChannelKey;
194
237
  /** The channel the scope's turns run in: the group's in a group round, otherwise its own. */
195
238
  turnChannel(scope: AgentTurnScope): ChannelKey;
196
239
  /** Posts text under the caller's name in the channel of its turn. */
@@ -199,7 +242,16 @@ export interface AgentTeam {
199
242
  announce(text: string): Promise<void>;
200
243
  /** Changes an agent's display name, prompt, model, or thinking level; a model must be one the host runs. */
201
244
  update(name: string, change: AgentChange): Promise<string>;
202
- /** Draws and stores a new picture for an agent. */
245
+ /**
246
+ * Changes an agent's look: `input` is resolved over its current character (a new class keeps
247
+ * its colors unless the input gives a palette or colors), rendered, and stored with the new
248
+ * picture. Throws AgentError naming the field of a bad input, and for an unknown or archived agent.
249
+ */
250
+ dress(name: string, input: CharacterInput): Promise<Agent>;
251
+ /**
252
+ * @deprecated Agents are composed characters and no longer drawn; use `dress`. Removed in 1.0.
253
+ * Draws and stores a new picture for an agent; it still needs an image provider.
254
+ */
203
255
  redrawAvatar(name: string, mode: AvatarMode, text?: string): Promise<Agent>;
204
256
  }
205
257
 
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { extractText, getDocumentProxy } from "unpdf";
3
3
  import { wasExpired } from "../attachments/attachment-expiry.ts";
4
+ import { MAX_ATTACHMENT_BYTES } from "../attachments/attachment-fetcher.ts";
4
5
  import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
5
6
  import { AttachmentExpiredError } from "../attachments/tool-attachment.ts";
6
7
 
@@ -78,6 +79,11 @@ export async function readAttachment(
78
79
  throw new AttachmentExpiredError(plainText(file));
79
80
  throw new Error(`no attachment named "${file}" in this channel`);
80
81
  }
82
+ // A file a surface took past the default limit is for tools that take files, not for reading whole.
83
+ if (handle.size > MAX_ATTACHMENT_BYTES)
84
+ throw new Error(
85
+ `"${plainText(file)}" is too large to read as text (${handle.size} bytes); it is kept as a file for tools that take files.`,
86
+ );
81
87
  let bytes: Uint8Array;
82
88
  try {
83
89
  bytes = new Uint8Array(await handle.arrayBuffer());
@@ -6,6 +6,7 @@ import type { Server, WebSocketHandler } from "bun";
6
6
  * turns and quiet model streams. Bun 1.4.2 honors `idleTimeout` on unix sockets, but its
7
7
  * types reject the option there, hence the cast. A stale socket file is removed first.
8
8
  * `error` answers a failure raised outside the fetch handler; without it Bun's own page is sent.
9
+ * `maxRequestBodySize` is the most bytes of request body, 413 over it; without it Bun's default.
9
10
  */
10
11
  export function serveUnix(
11
12
  socketPath: string,
@@ -13,7 +14,10 @@ export function serveUnix(
13
14
  request: Request,
14
15
  server: Server<undefined>,
15
16
  ) => Response | Promise<Response>,
16
- options?: { error?: (error: Error) => Response | Promise<Response> },
17
+ options?: {
18
+ error?: (error: Error) => Response | Promise<Response>;
19
+ maxRequestBodySize?: number;
20
+ },
17
21
  ): Server<undefined>;
18
22
  /**
19
23
  * Serves HTTP and WebSockets on a unix socket: `websocket` serves the sockets `fetch` upgrades
@@ -27,6 +31,7 @@ export function serveUnix<WebSocketData>(
27
31
  ) => Response | undefined | Promise<Response | undefined>,
28
32
  options: {
29
33
  error?: (error: Error) => Response | Promise<Response>;
34
+ maxRequestBodySize?: number;
30
35
  websocket: WebSocketHandler<WebSocketData>;
31
36
  },
32
37
  ): Server<WebSocketData>;
@@ -38,6 +43,7 @@ export function serveUnix<WebSocketData>(
38
43
  ) => Response | undefined | Promise<Response | undefined>,
39
44
  options: {
40
45
  error?: (error: Error) => Response | Promise<Response>;
46
+ maxRequestBodySize?: number;
41
47
  websocket?: WebSocketHandler<WebSocketData>;
42
48
  } = {},
43
49
  ): Server<WebSocketData> {
@@ -47,6 +53,9 @@ export function serveUnix<WebSocketData>(
47
53
  idleTimeout: 0,
48
54
  fetch,
49
55
  ...(options.error ? { error: options.error } : {}),
56
+ ...(options.maxRequestBodySize === undefined
57
+ ? {}
58
+ : { maxRequestBodySize: options.maxRequestBodySize }),
50
59
  ...(options.websocket ? { websocket: options.websocket } : {}),
51
60
  };
52
61
  // SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
@@ -31,8 +31,10 @@ import {
31
31
  IDENTITY,
32
32
  MEMORY,
33
33
  SCHEDULES,
34
+ THREADS,
34
35
  } from "../services.ts";
35
36
  import type { Tier } from "../speakers.ts";
37
+ import type { DispatchThreads } from "../threads/dispatch-threads.ts";
36
38
  import { toolTiers } from "../tool-tiers.ts";
37
39
 
38
40
  /** What the modules' tools did, for a test to read. */
@@ -170,8 +172,8 @@ export async function setUpModules(
170
172
  services.preset(AGENTS, {
171
173
  team: { channelOf: options.agentChannelOf },
172
174
  } as unknown as AgentServer);
173
- if (options.discord !== false)
174
- // SAFETY: the tools under test use the connection's owner channel and the threads' open only.
175
+ if (options.discord !== false) {
176
+ // SAFETY: the tools under test use the connection's owner channel only.
175
177
  services.preset(DISCORD, {
176
178
  connection: {
177
179
  ownerChannel: async () => {
@@ -180,15 +182,17 @@ export async function setUpModules(
180
182
  },
181
183
  ownerOperations: () => ({}),
182
184
  },
183
- threads: {
184
- open: async (origin: ChannelKey | undefined) => {
185
- record.threadOrigins.push(origin);
186
- return undefined;
187
- },
188
- },
189
185
  guard: {},
190
186
  commands: { add: () => undefined },
191
187
  } as unknown as DiscordServices);
188
+ // SAFETY: the modules use the threads' open only, which records where each opens.
189
+ services.preset(THREADS, {
190
+ open: async (origin: ChannelKey | undefined) => {
191
+ record.threadOrigins.push(origin);
192
+ return undefined;
193
+ },
194
+ } as unknown as DispatchThreads);
195
+ }
192
196
  const owns =
193
197
  options.owns ?? ((channel: ChannelKey) => channel.startsWith("discord:"));
194
198
  // Only Discord's channels have a surface; any other conversation has none to report in.
@@ -0,0 +1,58 @@
1
+ import type { SurfaceThreads } from "../contract/surface.ts";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { InterimMessage } from "../domain/interim.ts";
4
+
5
+ /**
6
+ * A surface's `threads` that records what happens, for a test of the core over a surface other
7
+ * than Discord. A thread's id is `thread.<n>`, as the web's are `thread.<uuid>`, and its mention
8
+ * is its name; parents in `hostless` cannot host threads.
9
+ */
10
+ export class FakeSurfaceThreads implements SurfaceThreads {
11
+ /** Start lines by parent key, as last edited. */
12
+ readonly lines = new Map<ChannelKey, string>();
13
+ readonly opened: { parent: ChannelKey; name: string; id: string }[] = [];
14
+ readonly posts: { thread: string; text: string }[] = [];
15
+ readonly closed: string[] = [];
16
+ /** Edits of messages `send` posted, in order; each also changes its entry in `posts`. */
17
+ readonly edits: { thread: string; text: string }[] = [];
18
+ readonly hostless = new Set<ChannelKey>();
19
+ failOpen = false;
20
+ #next = 1;
21
+
22
+ async open(
23
+ parent: ChannelKey,
24
+ name: string,
25
+ line: (mention?: string) => string,
26
+ ): Promise<{ id: string; mention: string } | undefined> {
27
+ if (this.hostless.has(parent)) return undefined;
28
+ if (this.failOpen) throw new Error("the surface refused");
29
+ const id = `thread.${this.#next++}`;
30
+ this.opened.push({ parent, name, id });
31
+ this.lines.set(parent, line(name));
32
+ return { id, mention: name };
33
+ }
34
+
35
+ async post(thread: string, text: string): Promise<void> {
36
+ this.posts.push({ thread, text });
37
+ }
38
+
39
+ async send(thread: string, text: string): Promise<InterimMessage> {
40
+ const post = { thread, text };
41
+ this.posts.push(post);
42
+ return {
43
+ edit: async (change) => {
44
+ post.text = change;
45
+ this.edits.push({ thread, text: change });
46
+ },
47
+ };
48
+ }
49
+
50
+ async close(thread: string): Promise<void> {
51
+ this.closed.push(thread);
52
+ }
53
+
54
+ /** The texts posted in one thread, in order. */
55
+ textsIn(thread: string): string[] {
56
+ return this.posts.filter((p) => p.thread === thread).map((p) => p.text);
57
+ }
58
+ }
@@ -1,12 +1,15 @@
1
1
  import { mkdtempSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import type { ChatSurface } from "../contract/surface.ts";
5
+ import type { InterimMessage } from "../domain/interim.ts";
6
+ import { silentLogger } from "../log.ts";
7
+ import { surfacePort } from "../routing/surface-port.ts";
4
8
  import {
5
9
  DispatchThreads,
6
10
  type ThreadHost,
7
- } from "../discord/dispatch-threads.ts";
8
- import type { InterimMessage } from "../domain/interim.ts";
9
- import { silentLogger } from "../log.ts";
11
+ } from "../threads/dispatch-threads.ts";
12
+ import { threadsOfHost } from "../threads/host-threads.ts";
10
13
 
11
14
  /** A ThreadHost that records what happens; parents in `hostless` cannot host threads. */
12
15
  export class FakeThreadHost implements ThreadHost {
@@ -59,14 +62,23 @@ export class FakeThreadHost implements ThreadHost {
59
62
  }
60
63
  }
61
64
 
62
- /** Dispatch threads over a fake host, with a ledger in a fresh temporary directory. */
65
+ /**
66
+ * Dispatch threads over a fake host, as the `discord` surface hosts them, with a ledger in a
67
+ * fresh temporary directory.
68
+ */
63
69
  export function fakeThreads(host = new FakeThreadHost()) {
64
70
  const ledgerPath = join(
65
71
  mkdtempSync(join(tmpdir(), "roundtable-threads-")),
66
72
  "dispatch-threads.json",
67
73
  );
74
+ const discord: ChatSurface = {
75
+ surface: "discord",
76
+ start: async () => undefined,
77
+ sendReply: async () => undefined,
78
+ threads: threadsOfHost(host),
79
+ };
68
80
  const threads = new DispatchThreads({
69
- host,
81
+ surfaces: surfacePort(() => [discord]),
70
82
  ledgerPath,
71
83
  logger: silentLogger(),
72
84
  });
@@ -0,0 +1,299 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import {
10
+ channelKey,
11
+ parseChannelKey,
12
+ type SurfacePort,
13
+ type SurfaceThreads,
14
+ } from "../contract/surface.ts";
15
+ import type { ChannelKey } from "../domain/conversation.ts";
16
+ import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
17
+ import { messages } from "../i18n/index.ts";
18
+ import type { Logger } from "../log.ts";
19
+ import { threadsOfHost } from "./host-threads.ts";
20
+
21
+ /** The only surface a `ThreadHost` serves. */
22
+ const DISCORD_PREFIX = "discord:";
23
+ /** Discord's limit on a thread name. */
24
+ const THREAD_NAME_LIMIT = 100;
25
+
26
+ /**
27
+ * The chat side of dispatch threads in Discord's terms: ids are Discord channel ids.
28
+ *
29
+ * @deprecated Until 1.0. A surface hosts dispatch threads with `ChatSurface.threads`
30
+ * (`SurfaceThreads`), for any chat network; the Discord surface implements it. Pass `surfaces`
31
+ * to `DispatchThreads` instead of a host.
32
+ */
33
+ export interface ThreadHost {
34
+ /**
35
+ * Posts the start line in the parent and opens a public thread from it; `line` gets the
36
+ * thread's mention once it exists. Undefined when the parent cannot host threads, such as a
37
+ * DM or a thread; throws when Discord refuses.
38
+ */
39
+ open(
40
+ parentId: string,
41
+ name: string,
42
+ line: (thread?: string) => string,
43
+ ): Promise<string | undefined>;
44
+ post(threadId: string, text: string): Promise<void>;
45
+ /**
46
+ * Posts one message of at most Discord's 2000 characters and resolves to it, so it can be
47
+ * edited in place; without it a thread has no `interim` posts.
48
+ */
49
+ send?(threadId: string, text: string): Promise<InterimMessage>;
50
+ /** Archives the thread, and locks it when allowed. */
51
+ close(threadId: string): Promise<void>;
52
+ }
53
+
54
+ /** One background dispatch's thread: its progress while it runs, then its report. */
55
+ export interface DispatchThread {
56
+ /** The thread's id on its surface, such as the Discord thread's channel id. */
57
+ readonly id: string;
58
+ /** The thread as a conversation channel, `<surface>:<id>`, where its owner cards are posted. */
59
+ readonly channel: ChannelKey;
60
+ /** How the thread is named in text, such as `<#id>` on Discord: for the report turn and the start line. */
61
+ readonly mention: string;
62
+ /** Posts in the thread; a failure is logged. */
63
+ post(text: string): Promise<void>;
64
+ /**
65
+ * Where the dispatch posts its progress as it goes, under the same name as its report, such
66
+ * as a coding worker's interim text; absent when the surface cannot edit a message it posted.
67
+ * Unlike `post`, a failure rejects.
68
+ */
69
+ readonly interim?: InterimPosts;
70
+ /** Posts the final report, if any, then archives the thread. Runs once; never rejects. */
71
+ close(report?: string): Promise<void>;
72
+ }
73
+
74
+ export interface DispatchThreadsOptions {
75
+ /**
76
+ * The chat surfaces. A dispatch started in a channel opens its thread through the `threads` of
77
+ * the surface that serves the channel's prefix; a channel whose surface has none gets no thread.
78
+ * Give this or `host`, not both.
79
+ */
80
+ surfaces?: Pick<SurfacePort, "of">;
81
+ /**
82
+ * Discord's thread host, for the `discord:` channels only.
83
+ *
84
+ * @deprecated Until 1.0; use `surfaces`, which serves every surface that hosts threads.
85
+ */
86
+ host?: ThreadHost;
87
+ /** Threads still open, so a start after a crash can close them. */
88
+ ledgerPath: string;
89
+ /** Channels whose claims keep reports in place rather than opening threads. */
90
+ excluded?: (channel: ChannelKey) => boolean;
91
+ logger: Logger;
92
+ }
93
+
94
+ interface LedgerEntry {
95
+ title: string;
96
+ parent: ChannelKey;
97
+ startedAt: string;
98
+ }
99
+
100
+ /** The thread's name: the title on one line, within Discord's limit. */
101
+ export function threadName(title: string): string {
102
+ const line = title.replace(/\s+/g, " ").trim() || "dispatch";
103
+ return line.length > THREAD_NAME_LIMIT
104
+ ? `${line.slice(0, THREAD_NAME_LIMIT - 1)}…`
105
+ : line;
106
+ }
107
+
108
+ /**
109
+ * Background dispatches (coding tasks, delegated tasks, messages between agents) each get a
110
+ * thread in the channel that started them, leaving one start line there. The thread lives on the
111
+ * surface of that channel, picked by the prefix of its key. A channel that cannot host one, or a
112
+ * failed creation, gets no thread, and the dispatch posts as before.
113
+ */
114
+ export class DispatchThreads {
115
+ readonly #options: DispatchThreadsOptions;
116
+ readonly #threadsOf: (channel: ChannelKey) => SurfaceThreads | undefined;
117
+ /** The threads still open, by their channel key, `<surface>:<id>`. */
118
+ readonly #open = new Map<ChannelKey, LedgerEntry>();
119
+ /** The keys the ledger held at construction: the threads of an earlier run, which `sweep` closes. */
120
+ readonly #left = new Set<ChannelKey>();
121
+
122
+ constructor(options: DispatchThreadsOptions) {
123
+ this.#options = options;
124
+ const { surfaces, host, ledgerPath } = options;
125
+ if (surfaces && host)
126
+ throw new Error(
127
+ "DispatchThreadsOptions takes surfaces or the deprecated host, not both",
128
+ );
129
+ if (surfaces) {
130
+ this.#threadsOf = (channel) => surfaces.of(channel)?.threads;
131
+ } else if (host) {
132
+ const discord = threadsOfHost(host);
133
+ this.#threadsOf = (channel) =>
134
+ channel.startsWith(DISCORD_PREFIX) ? discord : undefined;
135
+ } else {
136
+ throw new Error(
137
+ "DispatchThreadsOptions needs surfaces, the chat surfaces that host the threads",
138
+ );
139
+ }
140
+ if (!existsSync(ledgerPath)) return;
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(readFileSync(ledgerPath, "utf8"));
144
+ } catch (error) {
145
+ throw new Error(`${ledgerPath} is not valid JSON`, { cause: error });
146
+ }
147
+ if (typeof parsed !== "object" || parsed === null)
148
+ throw new Error(`${ledgerPath} must hold a JSON object`);
149
+ for (const [key, entry] of Object.entries(parsed)) {
150
+ const channel = this.#keyed(key, entry);
151
+ if (!channel) continue;
152
+ this.#open.set(channel, entry as LedgerEntry);
153
+ this.#left.add(channel);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * The channel key of a ledger entry, or undefined (with a warning) for one that cannot be
159
+ * read. Before 0.23 the ledger was keyed by the Discord thread's bare id; the surface then
160
+ * follows from the entry's parent, and an entry with no usable parent is skipped.
161
+ */
162
+ #keyed(key: string, entry: unknown): ChannelKey | undefined {
163
+ const { logger, ledgerPath } = this.#options;
164
+ const parent = (entry as { parent?: unknown } | null)?.parent;
165
+ if (typeof parent !== "string" || !parent.includes(":")) {
166
+ logger.warn(
167
+ { ledgerPath, thread: key },
168
+ "dispatch thread skipped: its ledger entry has no parent channel",
169
+ );
170
+ return undefined;
171
+ }
172
+ const at = key.indexOf(":");
173
+ if (at > 0) return channelKey(key.slice(0, at), key.slice(at + 1));
174
+ return channelKey(parseChannelKey(parent as ChannelKey).surface, key);
175
+ }
176
+
177
+ /** Opens the dispatch's thread under `origin`, posting `intro` first; never rejects. */
178
+ async open(
179
+ origin: ChannelKey | undefined,
180
+ title: string,
181
+ intro?: string,
182
+ ): Promise<DispatchThread | undefined> {
183
+ const { excluded, logger } = this.#options;
184
+ const threads = origin ? this.#threadsOf(origin) : undefined;
185
+ if (!origin || !threads || excluded?.(origin)) return undefined;
186
+ const name = threadName(title);
187
+ let opened: { id: string; mention: string } | undefined;
188
+ try {
189
+ opened = await threads.open(origin, name, (mention) =>
190
+ messages().threadStarted(name, mention),
191
+ );
192
+ } catch (error) {
193
+ logger.warn({ origin, err: error }, "dispatch thread not opened");
194
+ return undefined;
195
+ }
196
+ if (!opened?.id) return undefined;
197
+ const channel = channelKey(parseChannelKey(origin).surface, opened.id);
198
+ this.#open.set(channel, {
199
+ title: name,
200
+ parent: origin,
201
+ startedAt: new Date().toISOString(),
202
+ });
203
+ this.#write();
204
+ logger.info({ origin, thread: channel }, "dispatch thread opened");
205
+ const thread = this.#thread(channel, threads, opened.mention);
206
+ if (intro) await thread.post(intro);
207
+ return thread;
208
+ }
209
+
210
+ /**
211
+ * Closes the threads an earlier run left open, those the ledger held at construction; their
212
+ * dispatches never report. A thread opened since is live and stays. One whose surface is
213
+ * absent stays in the ledger for the next start, and one failure does not stop the rest.
214
+ */
215
+ async sweep(): Promise<void> {
216
+ const { logger } = this.#options;
217
+ for (const channel of [...this.#left]) {
218
+ this.#left.delete(channel);
219
+ const entry = this.#open.get(channel);
220
+ if (!entry) continue;
221
+ try {
222
+ const threads = this.#threadsOf(entry.parent);
223
+ if (!threads) {
224
+ logger.warn(
225
+ { thread: channel, parent: entry.parent },
226
+ "dispatch thread kept: its surface hosts no threads at this start",
227
+ );
228
+ continue;
229
+ }
230
+ // The thread is closed, never mentioned, so its mention stays empty.
231
+ await this.#thread(channel, threads).close(messages().restartNotice);
232
+ } catch (error) {
233
+ logger.warn(
234
+ { thread: channel, err: error },
235
+ "dispatch thread not swept",
236
+ );
237
+ }
238
+ }
239
+ }
240
+
241
+ #thread(
242
+ channel: ChannelKey,
243
+ threads: SurfaceThreads,
244
+ mention = "",
245
+ ): DispatchThread {
246
+ const { logger } = this.#options;
247
+ const { id } = parseChannelKey(channel);
248
+ let closed = false;
249
+ const send = threads.send?.bind(threads);
250
+ const post = async (text: string) => {
251
+ try {
252
+ await threads.post(id, text);
253
+ } catch (error) {
254
+ logger.warn(
255
+ { thread: channel, err: error },
256
+ "dispatch thread post failed",
257
+ );
258
+ }
259
+ };
260
+ return {
261
+ id,
262
+ channel,
263
+ mention,
264
+ post,
265
+ ...(send ? { interim: { post: (text: string) => send(id, text) } } : {}),
266
+ close: async (report) => {
267
+ if (closed) return;
268
+ closed = true;
269
+ if (report) await post(report);
270
+ try {
271
+ await threads.close(id);
272
+ } catch (error) {
273
+ logger.warn(
274
+ { thread: channel, err: error },
275
+ "dispatch thread not archived",
276
+ );
277
+ }
278
+ this.#open.delete(channel);
279
+ this.#write();
280
+ },
281
+ };
282
+ }
283
+
284
+ #write(): void {
285
+ const path = this.#options.ledgerPath;
286
+ try {
287
+ mkdirSync(dirname(path), { recursive: true });
288
+ const temporary = `${path}.tmp`;
289
+ writeFileSync(
290
+ temporary,
291
+ `${JSON.stringify(Object.fromEntries(this.#open), null, 2)}\n`,
292
+ { mode: 0o600 },
293
+ );
294
+ renameSync(temporary, path);
295
+ } catch (error) {
296
+ this.#options.logger.warn({ err: error }, "dispatch threads not saved");
297
+ }
298
+ }
299
+ }
@@ -0,0 +1,19 @@
1
+ import { parseChannelKey, type SurfaceThreads } from "../contract/surface.ts";
2
+ import type { ThreadHost } from "./dispatch-threads.ts";
3
+
4
+ /**
5
+ * A Discord `ThreadHost` as the `SurfaceThreads` of the `discord` surface: a thread's id is its
6
+ * Discord channel id, mentioned as `<#id>`, and a parent is the Discord channel behind its key.
7
+ */
8
+ export function threadsOfHost(host: ThreadHost): SurfaceThreads {
9
+ const send = host.send?.bind(host);
10
+ return {
11
+ open: async (parent, name, line) => {
12
+ const id = await host.open(parseChannelKey(parent).id, name, line);
13
+ return id ? { id, mention: `<#${id}>` } : undefined;
14
+ },
15
+ post: (thread, text) => host.post(thread, text),
16
+ ...(send ? { send } : {}),
17
+ close: (thread) => host.close(thread),
18
+ };
19
+ }