pi-roundtable 0.7.16 → 0.7.17

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.17] - 2026-10-04
9
+
10
+ ### Added
11
+
12
+ - A turn posts the text it writes before its final answer as it goes, so a proposal written before `ask_user` shows above its card instead of never reaching the channel. Primary text (400 characters or more, or with a Markdown heading, list, table or code fence) of a tool-calling assistant message is posted as ordinary messages when the message ends; shorter narration and the tools called (`-# bash ×3 · read`, names only) share one small-text progress message per run of tool calls, edited at most every 1.5 s, bounded to 2000 characters by dropping its oldest lines. Before any card the pending text is posted first. The final reply, its thinking line, and steered runs are unchanged; a failed interim post is logged and never fails the turn. See [interim text](docs/plugins.md#interim-text-what-a-turn-writes-before-its-final-answer).
13
+ - Config `interimText: "on" | "off"` (default `"on"`) and `interimPrimaryChars` (default 400), also on `PiAgentRuntimeOptions` and the agent server's options.
14
+ - `TurnRequest.interim`, `ChatSurface.interim(channel)` and `SurfacePort.interim(channel)` (Discord's surface implements it), optional `AgentChannels.interim(channelId, as)` (the agents' webhooks), and `PromptSlot.bind`'s optional `beforeCard`.
15
+ - Types: `InterimMessage`, `InterimPosts`, `InterimTextMode`.
16
+
17
+ ### Changed
18
+
19
+ - `ask_user`'s description asks for a question that reads on its own, with the context it needs.
20
+
8
21
  ## [0.7.16] - 2026-10-04
9
22
 
10
23
  ### Added
package/docs/plugins.md CHANGED
@@ -1056,7 +1056,7 @@ An `AgentRuntime` has these methods:
1056
1056
  | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
1057
1057
  | `dispose?()` | Runs when the host stops the agent server's runtime service |
1058
1058
 
1059
- A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, and flags (`steerable`, `interactive`, `confirmed`).
1059
+ A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
1060
1060
  An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
1061
1061
  Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
1062
1062
 
@@ -1882,6 +1882,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
1882
1882
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
1883
1883
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
1884
1884
  | `prompts(channel, speaker?)` | The owner's way to approve a held action or answer `ask_user` inside a running turn, as `OwnerPrompts`: `confirm` and `ask` | the action is held until the owner's next message |
1885
+ | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
1885
1886
 
1886
1887
  The host starts each surface as `surface:<prefix>` at the contributing plugin's place in the order, before that plugin's own services.
1887
1888
  It stops surfaces in reverse order, like other services.
@@ -1893,6 +1894,22 @@ If `prompts` is unavailable, it returns `undefined` and held actions wait for th
1893
1894
  `of(channel)` returns the surface, or `undefined`.
1894
1895
  The agent server asks the owner for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
1895
1896
 
1897
+ #### Interim text: what a turn writes before its final answer
1898
+
1899
+ A model often writes text in an assistant message that then calls tools, such as a proposal before it asks `ask_user` "go with this version?".
1900
+ On a surface that gives `interim`, and in the agent server's channels through the agents' webhooks, the host posts that text as the turn goes, sorted in two:
1901
+
1902
+ - **Primary** text is posted as ordinary messages as soon as its assistant message ends: text of 400 characters or more, or written with a Markdown heading, list, table or code fence.
1903
+ - **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message.
1904
+
1905
+ Before any card, an `ask_user` question or an approval, the host posts the pending text and brings the progress message up to date, so what the model wrote before the card shows above it.
1906
+ The final reply is posted at the end as before, with its thinking line; an intermediate message is never the final one, so nothing is posted twice, and a steered run keeps its final text.
1907
+ A failed interim post or edit is logged and never fails the turn.
1908
+ Turns with nowhere to post, such as transient tasks, coding workers, and turns of a claim that passes its own `reply` to `context.turns.run`, post only their final reply.
1909
+ A runtime that fills [the `runtime` slot](#the-runtime-slot-replace-pi) receives the place to post as `TurnRequest.interim` and may use it or not.
1910
+
1911
+ The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
1912
+
1896
1913
  The Discord plugin collects [slash commands](#slash-commands-commandsadd); the host doesn't compose them or pass them to surfaces on other networks.
1897
1914
 
1898
1915
  This in-memory chat surface records everything the host asks of it.
@@ -2525,6 +2542,9 @@ Import from the entries listed below; source area files are internal.
2525
2542
  | `HttpRoute` | `pi-roundtable` | type |
2526
2543
  | `ImageDrawer` | `pi-roundtable` | type |
2527
2544
  | `InboundMessage` | `pi-roundtable` | type |
2545
+ | `InterimMessage` | `pi-roundtable` | type |
2546
+ | `InterimPosts` | `pi-roundtable` | type |
2547
+ | `InterimTextMode` | `pi-roundtable` | type |
2528
2548
  | `Judge` | `pi-roundtable` | type |
2529
2549
  | `JudgeError` | `pi-roundtable` | value |
2530
2550
  | `JudgeModel` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.16",
3
+ "version": "0.7.17",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
2
+ import type { InterimPosts } from "../domain/interim.ts";
2
3
  import type { ThinkingSetting } from "../models.ts";
3
4
 
4
5
  export type { ContextUse };
@@ -47,6 +48,14 @@ export interface ChannelMessage {
47
48
  /** The agent server's channels as the agent team needs them. */
48
49
  export interface AgentChannels {
49
50
  post(channelId: string, post: AgentPost): Promise<void>;
51
+ /**
52
+ * Where a running turn posts the text it writes before its final answer, under the same name
53
+ * and avatar as `post`; absent = only the final reply is posted.
54
+ */
55
+ interim?(
56
+ channelId: string,
57
+ as: Omit<AgentPost, "thinking" | "chunks" | "files">,
58
+ ): InterimPosts;
50
59
  /** Creates a text channel under the category, made when missing; returns its id. */
51
60
  createChannel(
52
61
  name: string,
@@ -44,7 +44,7 @@ export interface TeamTurnsOptions {
44
44
  /** Who the agents work for, as their prompts and group history name them. */
45
45
  owner: OwnerIdentity;
46
46
  store: PgAgentStore;
47
- channels: Pick<AgentChannels, "post">;
47
+ channels: Pick<AgentChannels, "post" | "interim">;
48
48
  studio: Pick<AvatarStudio, "url">;
49
49
  /** Set once the runtime exists, which itself needs the team's tools. */
50
50
  runtime: () => AgentTurnRunner;
@@ -6,6 +6,7 @@ import type {
6
6
  TurnResult,
7
7
  } from "../domain/conversation.ts";
8
8
  import { AgentError } from "../domain/errors.ts";
9
+ import type { InterimPosts } from "../domain/interim.ts";
9
10
  import type { AgentTurnScope } from "../domain/ports.ts";
10
11
  import { messages } from "../i18n/index.ts";
11
12
  import { splitReply } from "../presentation/reply-splitter.ts";
@@ -185,6 +186,7 @@ export class TeamTurns {
185
186
  ...(scope.group ? { group: scope.group } : {}),
186
187
  };
187
188
  this.#options.events?.turnStarted(turn);
189
+ const interim = this.#interim(postTo, agent);
188
190
  let result: TurnResult;
189
191
  try {
190
192
  result = await settleTurn(
@@ -200,6 +202,7 @@ export class TeamTurns {
200
202
  ...(extra.interactive ? { interactive: true } : {}),
201
203
  agent: scope,
202
204
  speaker: chain.speaker,
205
+ ...(interim ? { interim } : {}),
203
206
  }),
204
207
  ),
205
208
  "agent turn",
@@ -250,6 +253,27 @@ export class TeamTurns {
250
253
  return this.#options.store.agent(agent.name) ?? agent;
251
254
  }
252
255
 
256
+ /**
257
+ * The turn's interim posts under the agent's name, read as each goes out so a name changed
258
+ * during the turn shows; none in a channel no agent or group owns any more.
259
+ */
260
+ #interim(channel: ChannelKey, agent: Agent): InterimPosts | undefined {
261
+ const { store, channels, studio } = this.#options;
262
+ if (!channels.interim) return undefined;
263
+ const interim = channels.interim.bind(channels);
264
+ return {
265
+ post: async (text) => {
266
+ if (!channelOwner(store, channel))
267
+ throw new AgentError(`${channel} is archived`);
268
+ const as = this.#current(agent);
269
+ return interim(channelIdOf(channel), {
270
+ name: as.displayName,
271
+ avatarUrl: studio.url(as.avatarHash),
272
+ }).post(text);
273
+ },
274
+ };
275
+ }
276
+
253
277
  /**
254
278
  * Posts under the agent's name. A channel no agent or group owns any more, archived while a
255
279
  * turn ran, gets nothing, so its webhook is not made again.
@@ -14,6 +14,7 @@ import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
14
14
  import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
15
15
  import type { ChannelKey } from "../domain/conversation.ts";
16
16
  import { ConfigError } from "../domain/errors.ts";
17
+ import type { InterimTextMode } from "../domain/interim.ts";
17
18
  import type { OwnerIdentity } from "../identity.ts";
18
19
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
19
20
  import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
@@ -72,6 +73,10 @@ export interface AgentServerOptions {
72
73
  avatarReference: string;
73
74
  /** Reports the process's own errors to an agent; without one nothing is reported. */
74
75
  errorReporter?: ErrorReporter;
76
+ /** Whether turns post the text they write before their final answer as they go; default "on". */
77
+ interimText?: InterimTextMode;
78
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
79
+ interimPrimaryChars?: number;
75
80
  }
76
81
 
77
82
  /** The name of the agent server's plugin, as `serviceStarted` events name it. */
@@ -278,6 +283,12 @@ export function agentServerPlugin(
278
283
  agents: agentSessions,
279
284
  prompts,
280
285
  logger,
286
+ ...(options.interimText
287
+ ? { interimText: options.interimText }
288
+ : {}),
289
+ ...(options.interimPrimaryChars
290
+ ? { interimPrimaryChars: options.interimPrimaryChars }
291
+ : {}),
281
292
  });
282
293
  runtime = running;
283
294
  context.services.provide(AGENTS, {
@@ -2,6 +2,7 @@ import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import type { AgentSeed } from "../agents/agent-rules.ts";
4
4
  import { ConfigError } from "../domain/errors.ts";
5
+ import type { InterimTextMode } from "../domain/interim.ts";
5
6
  import { isLocale, type Locale } from "../i18n/index.ts";
6
7
  import type { OwnerIdentity } from "../identity.ts";
7
8
  import {
@@ -11,6 +12,7 @@ import {
11
12
  type ThinkingLevel,
12
13
  } from "../models.ts";
13
14
  import type { RoundtablePlugin } from "../plugin.ts";
15
+ import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
14
16
  import type { Tier, TierMembers } from "../speakers.ts";
15
17
  import {
16
18
  bool,
@@ -123,6 +125,14 @@ export interface RoundtableConfig {
123
125
  memory?: boolean;
124
126
  /** The agent that investigates the process's own errors, by name. */
125
127
  ops?: { agent: string };
128
+ /**
129
+ * Whether a turn posts the text it writes before its final answer as it goes: long or
130
+ * structured text as ordinary messages, short narration and the tools called in one small
131
+ * progress message. Default "on"; "off" posts only the final reply.
132
+ */
133
+ interimText?: InterimTextMode;
134
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
135
+ interimPrimaryChars?: number;
126
136
  plugins?: RoundtablePlugin[];
127
137
  }
128
138
 
@@ -204,6 +214,8 @@ const schema = shape({
204
214
  ),
205
215
  memory: optional(bool),
206
216
  ops: optional(shape({ agent: text })),
217
+ interimText: optional(oneOf<InterimTextMode>("on", "off")),
218
+ interimPrimaryChars: optional(integer(1, 100_000)),
207
219
  plugins: optional(
208
220
  list(
209
221
  guarded("a plugin, an object with a name and a setup function", isPlugin),
@@ -256,6 +268,8 @@ export interface ResolvedConfig {
256
268
  skills: false | { builtinDir?: string; reposDir?: string };
257
269
  memory: boolean;
258
270
  ops?: { agent: string };
271
+ interimText: InterimTextMode;
272
+ interimPrimaryChars: number;
259
273
  plugins: RoundtablePlugin[];
260
274
  }
261
275
 
@@ -335,6 +349,8 @@ export function resolveConfig(input: unknown): ResolvedConfig {
335
349
  skills: config.skills ?? {},
336
350
  memory: config.memory ?? true,
337
351
  ...(config.ops ? { ops: config.ops } : {}),
352
+ interimText: config.interimText ?? "on",
353
+ interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
338
354
  plugins: config.plugins ?? [],
339
355
  };
340
356
  }
@@ -1,4 +1,5 @@
1
1
  import type { OutboundReply } from "../domain/conversation.ts";
2
+ import type { InterimPosts } from "../domain/interim.ts";
2
3
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
3
4
  import { PluginError } from "../errors.ts";
4
5
  import type { ChannelKey } from "../sessions.ts";
@@ -68,6 +69,11 @@ export interface ChatSurface {
68
69
  * absent, = the action is held until the owner's next message.
69
70
  */
70
71
  prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
72
+ /**
73
+ * Where a running turn posts the text it writes before its final answer: ordinary messages it
74
+ * may edit in place. Absent, or undefined, = only the final reply is posted.
75
+ */
76
+ interim?(channel: ChannelKey): InterimPosts | undefined;
71
77
  }
72
78
 
73
79
  /**
@@ -87,4 +93,6 @@ export interface SurfacePort {
87
93
  unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
88
94
  /** The owner's prompts in the channel; undefined when its surface has none. */
89
95
  prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
96
+ /** The channel's interim posts; undefined when its surface has none. */
97
+ interim(channel: ChannelKey): InterimPosts | undefined;
90
98
  }
@@ -217,6 +217,8 @@ export async function defineRoundtable(
217
217
  avatarListener: "public",
218
218
  avatarUrl: config.http.publicUrl,
219
219
  avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
220
+ interimText: config.interimText,
221
+ interimPrimaryChars: config.interimPrimaryChars,
220
222
  ...(errorReporter ? { errorReporter } : {}),
221
223
  }),
222
224
  seedsPlugin(config.agents),
@@ -23,6 +23,7 @@ import type {
23
23
  DashboardBoard,
24
24
  } from "../agents/agent-ports.ts";
25
25
  import { AgentError } from "../domain/errors.ts";
26
+ import type { InterimPosts } from "../domain/interim.ts";
26
27
  import { messages } from "../i18n/index.ts";
27
28
  import type { Logger } from "../log.ts";
28
29
 
@@ -69,6 +70,38 @@ export class DiscordAgentChannels implements AgentChannels {
69
70
  }
70
71
  }
71
72
 
73
+ interim(
74
+ channelId: string,
75
+ as: Omit<AgentPost, "thinking" | "chunks" | "files">,
76
+ ): InterimPosts {
77
+ const threadId = as.threadId ? { threadId: as.threadId } : {};
78
+ return {
79
+ post: async (text) => {
80
+ const webhook = await this.#webhook(channelId);
81
+ try {
82
+ const message = await webhook.send({
83
+ username: as.name,
84
+ avatarURL: as.avatarUrl,
85
+ allowedMentions: { parse: ["users"] },
86
+ ...threadId,
87
+ content: text,
88
+ });
89
+ return {
90
+ edit: async (change) => {
91
+ await webhook.editMessage(message, {
92
+ content: change,
93
+ ...threadId,
94
+ });
95
+ },
96
+ };
97
+ } catch (error) {
98
+ this.#webhooks.delete(channelId);
99
+ throw error;
100
+ }
101
+ },
102
+ };
103
+ }
104
+
72
105
  async createChannel(
73
106
  name: string,
74
107
  topic: string,
@@ -17,6 +17,7 @@ import type {
17
17
  InboundMessage,
18
18
  OutboundReply,
19
19
  } from "../domain/conversation.ts";
20
+ import type { InterimPosts } from "../domain/interim.ts";
20
21
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
21
22
  import type { OwnerNotifier } from "../domain/ports.ts";
22
23
  import { messages } from "../i18n/index.ts";
@@ -159,6 +160,25 @@ export class DiscordSurface
159
160
  return this.#options.prompts(channel, speaker);
160
161
  }
161
162
 
163
+ /** The channel's interim posts: ordinary messages, edited in place for the progress line. */
164
+ interim(channel: ChannelKey): InterimPosts | undefined {
165
+ if (!channel.startsWith(PREFIX)) return undefined;
166
+ return {
167
+ post: async (text) => {
168
+ const target = await this.#sendable(channel);
169
+ const message = await target.send({
170
+ content: text,
171
+ allowedMentions: { parse: ["users"] },
172
+ });
173
+ return {
174
+ edit: async (change) => {
175
+ await message.edit({ content: change });
176
+ },
177
+ };
178
+ },
179
+ };
180
+ }
181
+
162
182
  async start(onMessage: (message: InboundMessage) => void): Promise<void> {
163
183
  const { logger, token } = this.#options;
164
184
  this.#client.on(Events.MessageCreate, (message) => {
@@ -0,0 +1,16 @@
1
+ /** A message an interim post made, which the turn edits in place as it goes. */
2
+ export interface InterimMessage {
3
+ edit(text: string): Promise<void>;
4
+ }
5
+
6
+ /**
7
+ * Where a running turn posts the text it writes before its final answer: the channel the
8
+ * final reply goes to, under the same name. Each call posts one ordinary message of at most
9
+ * Discord's 2000 characters.
10
+ */
11
+ export interface InterimPosts {
12
+ post(text: string): Promise<InterimMessage>;
13
+ }
14
+
15
+ /** Whether a turn posts its intermediate text as it goes; "on" by default. */
16
+ export type InterimTextMode = "on" | "off";
@@ -2,6 +2,7 @@ import type { AgentTurnScope, TurnSelection } from "../sessions.ts";
2
2
  import type { Speaker } from "../speakers.ts";
3
3
  import type { TurnAttachments } from "./attachment.ts";
4
4
  import type { ChannelKey } from "./conversation.ts";
5
+ import type { InterimPosts } from "./interim.ts";
5
6
 
6
7
  export type { AgentTurnScope };
7
8
 
@@ -32,6 +33,11 @@ export interface TurnRequest {
32
33
  * schedule's turn is not.
33
34
  */
34
35
  interactive?: boolean;
36
+ /**
37
+ * Where the turn posts the text it writes before its final answer, as it goes; the caller
38
+ * hands the channel its reply goes to. Absent = only the final reply is posted.
39
+ */
40
+ interim?: InterimPosts;
35
41
  }
36
42
 
37
43
  /** The judge's question and answer shapes. */
@@ -82,6 +82,8 @@ export function conversationTurns(
82
82
  const hideStop = surfaces.showStop(channel);
83
83
  const turn = { kind, channel, speaker };
84
84
  events.turnStarted(turn);
85
+ // A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
86
+ const interim = input.reply ? undefined : surfaces.interim(channel);
85
87
  let result: TurnResult;
86
88
  try {
87
89
  result = await settleTurn(
@@ -102,6 +104,7 @@ export function conversationTurns(
102
104
  ...(input.confirmed ? { confirmed: true } : {}),
103
105
  ...(input.steerable ? { steerable: true } : {}),
104
106
  ...(input.interactive ? { interactive: true } : {}),
107
+ ...(interim ? { interim } : {}),
105
108
  }),
106
109
  ),
107
110
  "conversation turn",
@@ -41,5 +41,6 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
41
41
  unreact: async (channel, messageId, emoji) =>
42
42
  of(channel)?.unreact?.(channel, messageId, emoji),
43
43
  prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
44
+ interim: (channel) => of(channel)?.interim?.(channel),
44
45
  };
45
46
  }
@@ -37,7 +37,7 @@ export function askUserExtension(
37
37
  pi.registerTool({
38
38
  name: ASK_USER_TOOL,
39
39
  label: `Ask ${o.name}`,
40
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
40
+ description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
41
41
  parameters: Type.Object(
42
42
  {
43
43
  question: Type.String({ minLength: 1, maxLength: 1500 }),
@@ -0,0 +1,207 @@
1
+ import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
2
+ import type { TurnRequest } from "../domain/ports.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import {
5
+ DISCORD_MESSAGE_LIMIT,
6
+ splitReply,
7
+ } from "../presentation/reply-splitter.ts";
8
+ import { textOf } from "../shared/session-messages.ts";
9
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
10
+
11
+ /** An intermediate text this long or longer is posted as an ordinary message. */
12
+ export const PRIMARY_CHARS = 400;
13
+ /** The progress message is edited at most this often; its last state always lands. */
14
+ export const PROGRESS_EDIT_MS = 1_500;
15
+
16
+ const STRUCTURE = /^\s{0,3}(#{1,6}\s|```|~~~|[-*+]\s|\d+[.)]\s|\|.*\|)/m;
17
+
18
+ /**
19
+ * Whether an intermediate text is content the owner must read, such as a proposal or findings:
20
+ * long, or written with a Markdown heading, list, table, or code fence. Shorter narration
21
+ * between tool calls goes to the progress message instead.
22
+ */
23
+ export function isPrimaryText(
24
+ text: string,
25
+ primaryChars = PRIMARY_CHARS,
26
+ ): boolean {
27
+ return text.length >= primaryChars || STRUCTURE.test(text);
28
+ }
29
+
30
+ /**
31
+ * A run's progress message: its narration lines, then the tools it called, all in Discord's
32
+ * small text and kept inside one message by dropping the oldest lines behind a "…" marker.
33
+ */
34
+ export function progressText(
35
+ lines: readonly string[],
36
+ tools: ReadonlyMap<string, number>,
37
+ ): string {
38
+ const small = (line: string) => `-# ${line}`;
39
+ const toolLine =
40
+ tools.size > 0
41
+ ? small(
42
+ [...tools]
43
+ .map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
44
+ .join(" · "),
45
+ ).slice(0, DISCORD_MESSAGE_LIMIT)
46
+ : undefined;
47
+ const body = lines
48
+ .flatMap((text) => text.split("\n"))
49
+ .map((line) => line.trim())
50
+ .filter(Boolean)
51
+ .map(small);
52
+ const tail = toolLine ? [toolLine] : [];
53
+ const fits = (kept: string[]) =>
54
+ [...kept, ...tail].join("\n").length <= DISCORD_MESSAGE_LIMIT;
55
+ if (fits(body)) return [...body, ...tail].join("\n");
56
+ const marker = small("…");
57
+ let kept = body;
58
+ while (kept.length > 0 && !fits([marker, ...kept])) kept = kept.slice(1);
59
+ if (kept.length === 0 && body.length > 0) {
60
+ // One line alone is too long: keep its end.
61
+ const room =
62
+ DISCORD_MESSAGE_LIMIT - [marker, ...tail].join("\n").length - 4;
63
+ const last = body.at(-1) ?? "";
64
+ kept = room > 0 ? [small(`…${last.slice(-room)}`)] : [];
65
+ return [...kept, ...tail].join("\n");
66
+ }
67
+ return [marker, ...kept, ...tail].join("\n");
68
+ }
69
+
70
+ interface Run {
71
+ lines: string[];
72
+ tools: Map<string, number>;
73
+ message?: InterimMessage;
74
+ shown?: string;
75
+ lastAt: number;
76
+ timer?: ReturnType<typeof setTimeout>;
77
+ }
78
+
79
+ export interface InterimPosterOptions {
80
+ logger: Logger;
81
+ channel: string;
82
+ primaryChars?: number;
83
+ editMs?: number;
84
+ }
85
+
86
+ /**
87
+ * Posts a turn's intermediate text as it goes. A tool-calling assistant message whose text is
88
+ * primary is posted as ordinary messages; shorter text joins the run's progress message with
89
+ * the tools called, edited in place. A primary post or a card starts a new run. Posts go out
90
+ * in order, and a failed one is logged, never thrown.
91
+ */
92
+ export class InterimPoster {
93
+ readonly #posts: InterimPosts;
94
+ readonly #options: InterimPosterOptions;
95
+ #queue: Promise<void> = Promise.resolve();
96
+ #run: Run = newRun();
97
+
98
+ constructor(posts: InterimPosts, options: InterimPosterOptions) {
99
+ this.#posts = posts;
100
+ this.#options = options;
101
+ }
102
+
103
+ /** A finished message of the turn; only a tool-calling assistant message is posted. */
104
+ messageEnd(message: {
105
+ role: string;
106
+ content?: unknown;
107
+ stopReason?: string;
108
+ }): void {
109
+ if (message.role !== "assistant" || message.stopReason !== "toolUse")
110
+ return;
111
+ const text = textOf(message.content).trim();
112
+ if (!text) return;
113
+ if (isPrimaryText(text, this.#options.primaryChars)) {
114
+ this.#endRun();
115
+ for (const chunk of splitReply(text))
116
+ this.#enqueue(async () => {
117
+ await this.#posts.post(chunk);
118
+ }, "interim text not posted");
119
+ return;
120
+ }
121
+ this.#run.lines.push(text);
122
+ this.#changed();
123
+ }
124
+
125
+ /** A tool the turn started, counted on the run's tool line. */
126
+ toolStart(name: string): void {
127
+ const { tools } = this.#run;
128
+ tools.set(name, (tools.get(name) ?? 0) + 1);
129
+ this.#changed();
130
+ }
131
+
132
+ /**
133
+ * Brings every post up to date and waits for them, then starts a new run: before a card, so
134
+ * the text written before it shows above it, and when the turn ends, before its final reply.
135
+ */
136
+ async flush(): Promise<void> {
137
+ this.#endRun();
138
+ await this.#queue;
139
+ }
140
+
141
+ #endRun(): void {
142
+ const run = this.#run;
143
+ this.#run = newRun();
144
+ clearTimeout(run.timer);
145
+ run.timer = undefined;
146
+ this.#render(run);
147
+ }
148
+
149
+ /** The run changed: shown now, or once the edit interval has passed. */
150
+ #changed(): void {
151
+ const run = this.#run;
152
+ if (run.timer) return;
153
+ const wait =
154
+ run.lastAt + (this.#options.editMs ?? PROGRESS_EDIT_MS) - now();
155
+ if (wait <= 0) {
156
+ this.#render(run);
157
+ return;
158
+ }
159
+ run.timer = setTimeout(() => {
160
+ run.timer = undefined;
161
+ this.#render(run);
162
+ }, wait);
163
+ }
164
+
165
+ #render(run: Run): void {
166
+ if (run.lines.length === 0 && run.tools.size === 0) return;
167
+ run.lastAt = now();
168
+ const text = progressText(run.lines, run.tools);
169
+ this.#enqueue(async () => {
170
+ if (text === run.shown) return;
171
+ if (run.message) await run.message.edit(text);
172
+ else run.message = await this.#posts.post(text);
173
+ run.shown = text;
174
+ }, "progress message not posted");
175
+ }
176
+
177
+ #enqueue(step: () => Promise<void>, failure: string): void {
178
+ const { logger, channel } = this.#options;
179
+ this.#queue = this.#queue.then(step).catch((error: unknown) => {
180
+ logger.warn({ channel, err: error }, failure);
181
+ });
182
+ }
183
+ }
184
+
185
+ /** The turn's poster, when its caller gave a place for interim posts and the host left them on. */
186
+ export function interimPoster(
187
+ request: Pick<TurnRequest, "interim" | "channel">,
188
+ options: Pick<
189
+ PiAgentRuntimeOptions,
190
+ "interimText" | "interimPrimaryChars" | "logger"
191
+ >,
192
+ ): InterimPoster | undefined {
193
+ if (!request.interim || options.interimText === "off") return undefined;
194
+ return new InterimPoster(request.interim, {
195
+ logger: options.logger,
196
+ channel: request.channel,
197
+ ...(options.interimPrimaryChars
198
+ ? { primaryChars: options.interimPrimaryChars }
199
+ : {}),
200
+ });
201
+ }
202
+
203
+ function newRun(): Run {
204
+ return { lines: [], tools: new Map(), lastAt: 0 };
205
+ }
206
+
207
+ const now = () => Date.now();
@@ -36,6 +36,7 @@ import {
36
36
  confirmedTurnText,
37
37
  } from "./extensions/confirmation-gate.ts";
38
38
  import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
39
+ import { interimPoster } from "./interim-text.ts";
39
40
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
40
41
  import {
41
42
  type PiAgentRuntimeOptions,
@@ -181,9 +182,17 @@ export class PiAgentRuntime implements AgentRuntime {
181
182
  // Collected as they end: a compaction during the turn shortens session.messages.
182
183
  const turnMessages: TurnMessage[] = [];
183
184
  const toolCalls: string[] = [];
185
+ // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
186
+ const interim = interimPoster(request, this.#options);
184
187
  const unsubscribe = session.subscribe((event) => {
185
- if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
186
- if (event.type === "message_end") turnMessages.push(event.message);
188
+ if (event.type === "tool_execution_start") {
189
+ toolCalls.push(event.toolName);
190
+ interim?.toolStart(event.toolName);
191
+ }
192
+ if (event.type === "message_end") {
193
+ turnMessages.push(event.message);
194
+ interim?.messageEnd(event.message);
195
+ }
187
196
  });
188
197
  const slot = this.#sessions.slot(key);
189
198
  slot.bind(
@@ -191,6 +200,7 @@ export class PiAgentRuntime implements AgentRuntime {
191
200
  ? this.#options.prompts?.(request.channel, request.speaker)
192
201
  : undefined,
193
202
  request.agent?.name ?? assistantName(),
203
+ interim ? () => interim.flush() : undefined,
194
204
  );
195
205
  // Time spent waiting on the owner's cards does not count towards the timeout.
196
206
  const cancelTimeout = workTimeout(turnTimeoutMs, slot, () => {
@@ -237,6 +247,7 @@ export class PiAgentRuntime implements AgentRuntime {
237
247
  cancelTimeout();
238
248
  slot.unbind();
239
249
  unsubscribe();
250
+ await interim?.flush();
240
251
  gate.endTurn();
241
252
  const usage = session.getContextUsage();
242
253
  if (usage)
@@ -10,14 +10,23 @@ import { assistantName } from "../i18n/index.ts";
10
10
  */
11
11
  export class PromptSlot {
12
12
  #prompts: OwnerPrompts | undefined;
13
+ #beforeCard: (() => Promise<void>) | undefined;
13
14
  #asker = assistantName();
14
15
  #open = 0;
15
16
  #openSince = 0;
16
17
  #waited = 0;
17
18
 
18
- /** Binds the turn's prompts; `asker` names who asks, the assistant or an agent. */
19
- bind(prompts: OwnerPrompts | undefined, asker: string): void {
19
+ /**
20
+ * Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
21
+ * runs before each card opens, such as posting the text the turn wrote before it.
22
+ */
23
+ bind(
24
+ prompts: OwnerPrompts | undefined,
25
+ asker: string,
26
+ beforeCard?: () => Promise<void>,
27
+ ): void {
20
28
  this.#prompts = prompts;
29
+ this.#beforeCard = beforeCard;
21
30
  this.#asker = asker;
22
31
  this.#open = 0;
23
32
  this.#waited = 0;
@@ -25,6 +34,7 @@ export class PromptSlot {
25
34
 
26
35
  unbind(): void {
27
36
  this.#prompts = undefined;
37
+ this.#beforeCard = undefined;
28
38
  }
29
39
 
30
40
  get asker(): string {
@@ -49,6 +59,7 @@ export class PromptSlot {
49
59
  }
50
60
 
51
61
  async #waiting<T>(ask: () => Promise<T>): Promise<T> {
62
+ await this.#beforeCard?.();
52
63
  if (this.#open++ === 0) this.#openSince = Date.now();
53
64
  try {
54
65
  return await ask();
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
8
8
  import type { ChannelKey } from "../domain/conversation.ts";
9
+ import type { InterimTextMode } from "../domain/interim.ts";
9
10
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
10
11
  import type { OwnerIdentity } from "../identity.ts";
11
12
  import type { Logger } from "../log.ts";
@@ -66,6 +67,13 @@ export interface PiAgentRuntimeOptions {
66
67
  toolTiers?: ToolTiers;
67
68
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
68
69
  turnTimeoutMs?: number;
70
+ /**
71
+ * Whether a turn given a place for interim posts shows the text it writes before its final
72
+ * answer as it goes; default "on". "off" posts only the final reply.
73
+ */
74
+ interimText?: InterimTextMode;
75
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
76
+ interimPrimaryChars?: number;
69
77
  /** How long a new session waits for its MCP tools to register. */
70
78
  mcpConnectTimeoutMs?: number;
71
79
  }
package/src/index.ts CHANGED
@@ -88,6 +88,11 @@ export {
88
88
  MemoryError,
89
89
  ScheduleError,
90
90
  } from "./core/domain/errors.ts";
91
+ export type {
92
+ InterimMessage,
93
+ InterimPosts,
94
+ InterimTextMode,
95
+ } from "./core/domain/interim.ts";
91
96
  export type {
92
97
  Approval,
93
98
  AskOption,