pi-roundtable 0.9.3 → 0.9.6

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 (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +32 -6
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/builtin/discord-admin.ts +6 -2
  8. package/src/core/builtin/discord.ts +10 -0
  9. package/src/core/config/adapters.ts +47 -0
  10. package/src/core/config/config.ts +9 -34
  11. package/src/core/define-roundtable.ts +2 -0
  12. package/src/core/define.ts +8 -0
  13. package/src/core/discord/channel-files.ts +107 -0
  14. package/src/core/discord/channel-operations.ts +100 -33
  15. package/src/core/discord/discord-surface.ts +21 -4
  16. package/src/core/discord/dispatch-thread-host.ts +11 -0
  17. package/src/core/discord/dispatch-threads.ts +14 -0
  18. package/src/core/discord/inbound-message.ts +34 -0
  19. package/src/core/discord/owner-card-rows.ts +98 -0
  20. package/src/core/discord/owner-cards-fixture.ts +3 -0
  21. package/src/core/discord/owner-cards.ts +137 -105
  22. package/src/core/host.ts +9 -1
  23. package/src/core/i18n/cards.ts +6 -8
  24. package/src/core/i18n/conversation.ts +15 -0
  25. package/src/core/interactions/prompts.ts +36 -7
  26. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  27. package/src/core/plugin.ts +5 -0
  28. package/src/core/runtime/compaction-tiers.ts +61 -8
  29. package/src/core/runtime/conversation-sessions.ts +2 -0
  30. package/src/core/runtime/extensions/ask-user.ts +21 -4
  31. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  32. package/src/core/runtime/prompt-slot.ts +6 -4
  33. package/src/core/runtime/runtime-types.ts +5 -0
  34. package/src/core/runtime/session-factory.ts +22 -2
  35. package/src/core/sessions.ts +6 -0
  36. package/src/core/shared/attach-file-tool.ts +72 -0
  37. package/src/core/shared/workspace-files.ts +76 -0
  38. package/src/core/testing/thread-host.ts +14 -0
  39. package/src/index.ts +2 -0
  40. package/src/kit/index.ts +2 -0
  41. package/src/kit/worker.ts +2 -0
  42. package/src/testing.ts +7 -1
@@ -4,13 +4,8 @@ import {
4
4
  ButtonBuilder,
5
5
  ButtonStyle,
6
6
  type Interaction,
7
- LabelBuilder,
8
7
  MessageFlags,
9
- ModalBuilder,
10
- StringSelectMenuBuilder,
11
8
  TextDisplayBuilder,
12
- TextInputBuilder,
13
- TextInputStyle,
14
9
  } from "discord.js";
15
10
  import { parseChannelKey } from "../contract/surface.ts";
16
11
  import type { ChannelKey } from "../domain/conversation.ts";
@@ -21,6 +16,7 @@ import type {
21
16
  OwnerQuestion,
22
17
  PromptScope,
23
18
  Prompts,
19
+ PromptWait,
24
20
  } from "../interactions/prompts.ts";
25
21
  import type { Logger } from "../log.ts";
26
22
  import {
@@ -28,15 +24,26 @@ import {
28
24
  type CardAudienceOptions,
29
25
  CardAudiences,
30
26
  } from "./card-audience.ts";
31
- import { DiscordOwners, discordUser } from "./discord-owners.ts";
27
+ import {
28
+ DiscordOwners,
29
+ type DiscordUser,
30
+ discordUser,
31
+ } from "./discord-owners.ts";
32
32
  import type { InteractionModule } from "./interaction-module.ts";
33
- import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
33
+ import {
34
+ answeredLine,
35
+ answerModal,
36
+ askRows,
37
+ CARD_PREFIX,
38
+ OTHER,
39
+ type Rows,
40
+ TEXT_FIELD,
41
+ } from "./owner-card-rows.ts";
42
+ import { ownerPanel, type PanelContent } from "./owner-panel.ts";
34
43
 
35
- export const CARD_PREFIX = "roundtable:card:";
36
- /** An unanswered card expires after this long. */
37
- export const CARD_TIMEOUT_MS = 30 * 60_000;
38
- const OTHER = "other";
39
- const TEXT_FIELD = "text";
44
+ export { CARD_PREFIX };
45
+ /** How long a turn that can be resumed waits on its card before it goes on without the answer. */
46
+ export const CARD_GRACE_MS = 2 * 60_000;
40
47
 
41
48
  /** A card's message: the panel, led in a thread by a mention of those it is for. */
42
49
  export type CardPayload = Omit<
@@ -73,11 +80,38 @@ export interface OwnerCardsOptions {
73
80
  /** The Discord channel by id; throws when it cannot take messages. */
74
81
  channel(channelId: string): Promise<CardChannel>;
75
82
  logger: Logger;
76
- /** CARD_TIMEOUT_MS by default. */
77
- timeoutMs?: number;
83
+ /**
84
+ * How long a turn that gives a `PromptWait` waits on its card; `CARD_GRACE_MS` (two minutes)
85
+ * by default. The card stays open after it, for as long as the process runs.
86
+ */
87
+ graceMs?: number;
88
+ /**
89
+ * Starts a turn in a card's channel with a late answer's text, as a message from whoever
90
+ * answered. Without it no turn stops waiting on its cards.
91
+ */
92
+ resume?(turn: LateTurn): void;
78
93
  }
79
94
 
80
- type Rows = NonNullable<Parameters<typeof ownerPanel>[0]["rows"]>;
95
+ /** A turn a card answered after its turn stopped waiting starts, as a message in its channel. */
96
+ export interface LateTurn {
97
+ channelId: string;
98
+ /** The card's message, which the turn's message stands for. */
99
+ messageId: string;
100
+ /** Who answered. */
101
+ user: DiscordUser;
102
+ /** The server the channel belongs to; absent in a direct message. */
103
+ guildId?: string;
104
+ isDirect: boolean;
105
+ text: string;
106
+ }
107
+
108
+ /** Where and by whom a card was answered. */
109
+ interface Answerer {
110
+ user: DiscordUser;
111
+ messageId?: string;
112
+ guildId?: string;
113
+ isDirect: boolean;
114
+ }
81
115
 
82
116
  /** One open card: how it looks, and how it ends. */
83
117
  interface OpenCard {
@@ -85,8 +119,11 @@ interface OpenCard {
85
119
  sections: string[];
86
120
  /** The card's controls, disabled once it has ended. */
87
121
  rows(disabled: boolean): Rows;
88
- /** Ends the card with its answer; the answering interaction shows the outcome. */
89
- end(value: unknown): void;
122
+ /**
123
+ * Ends the card with its answer; the answering interaction shows the outcome. A card whose
124
+ * turn stopped waiting starts a turn with the answer instead.
125
+ */
126
+ end(value: unknown, by: Answerer): void;
90
127
  question?: OwnerQuestion;
91
128
  /** Options chosen together with "Other…", kept while its form is open. */
92
129
  picked?: string[];
@@ -100,9 +137,11 @@ interface OpenCard {
100
137
  * The cards in the assistant's channels: approvals of held actions and ask_user questions,
101
138
  * answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
102
139
  * `CardAudiences`): the speaker whose turn asks, when the card allows (a question always; an
103
- * approval at its tier), and, in a shared conversation, the owners. An unanswered card expires
104
- * after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a restart
105
- * abandons them; pressing one then says it no longer works.
140
+ * approval at its tier), and, in a shared conversation, the owners. A turn that can be resumed
141
+ * (it gives a `PromptWait`) waits on its card for the grace period, then goes on without the
142
+ * answer while the card stays open; answered later, the card starts a turn in its channel with
143
+ * the answer. Other cards wait for their answer. A stopped turn cancels a card it still waits on.
144
+ * Open cards live in memory, so a restart abandons them; pressing one then says to ask again.
106
145
  */
107
146
  export class OwnerCards implements InteractionModule {
108
147
  readonly #options: OwnerCardsOptions;
@@ -125,12 +164,25 @@ export class OwnerCards implements InteractionModule {
125
164
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
126
165
  const channelId = parseChannelKey(channel).id;
127
166
  return {
128
- confirm: async (title, message, signal, minTier = "owner") => {
167
+ confirm: async (title, message, signal, minTier = "owner", wait) => {
129
168
  const audience = await this.#audiences.approval(scope, minTier);
130
169
  // A private conversation's call above its person's tier is no one's to approve.
131
170
  if (!audience) return "expired";
132
171
  return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
133
172
  audience,
173
+ ...(wait
174
+ ? {
175
+ wait: {
176
+ pending: "pending",
177
+ late: (value, by) =>
178
+ wait.late({
179
+ kind: "approval",
180
+ approved: value === "approved",
181
+ by,
182
+ }),
183
+ },
184
+ }
185
+ : {}),
134
186
  title,
135
187
  sections: [message],
136
188
  rows: (id, disabled) => [
@@ -152,16 +204,29 @@ export class OwnerCards implements InteractionModule {
152
204
  footer: messages().cardApprovalFooter,
153
205
  });
154
206
  },
155
- ask: async (title, question, signal) => {
207
+ ask: async (title, question, signal, wait) => {
156
208
  const audience = await this.#audiences.question(scope);
157
209
  if (!audience) return undefined;
158
- return this.#post<OwnerAnswer | undefined>(
210
+ return this.#post<OwnerAnswer | "pending" | undefined>(
159
211
  channelId,
160
212
  signal,
161
213
  undefined,
162
214
  undefined,
163
215
  {
164
216
  audience,
217
+ ...(wait
218
+ ? {
219
+ wait: {
220
+ pending: "pending",
221
+ late: (value, by) =>
222
+ wait.late({
223
+ kind: "question",
224
+ answer: value as OwnerAnswer,
225
+ by,
226
+ }),
227
+ },
228
+ }
229
+ : {}),
165
230
  title,
166
231
  sections: [question.question],
167
232
  question,
@@ -173,7 +238,10 @@ export class OwnerCards implements InteractionModule {
173
238
  };
174
239
  }
175
240
 
176
- /** Posts a card and resolves with its answer, `expired`, or `cancelled`. */
241
+ /**
242
+ * Posts a card and resolves with its answer, `expired` when it could not be posted, or
243
+ * `cancelled`; with `wait`, and a way to resume, `wait.pending` once the grace period passes.
244
+ */
177
245
  #post<T>(
178
246
  channelId: string,
179
247
  signal: AbortSignal | undefined,
@@ -186,10 +254,15 @@ export class OwnerCards implements InteractionModule {
186
254
  question?: OwnerQuestion;
187
255
  rows(id: string, disabled: boolean): Rows;
188
256
  footer: string;
257
+ wait?: {
258
+ pending: T;
259
+ late(value: unknown, by: string): ReturnType<PromptWait["late"]>;
260
+ };
189
261
  },
190
262
  ): Promise<T> {
191
- const { logger, timeoutMs = CARD_TIMEOUT_MS } = this.#options;
263
+ const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
192
264
  if (signal?.aborted) return Promise.resolve(cancelled);
265
+ const wait = resume ? card.wait : undefined;
193
266
  const id = randomUUID();
194
267
  const sections = [
195
268
  ...card.sections,
@@ -198,10 +271,13 @@ export class OwnerCards implements InteractionModule {
198
271
  return new Promise<T>((resolve) => {
199
272
  let message: CardMessage | undefined;
200
273
  let finish: string | undefined;
274
+ /** The turn went on without the answer; the card is the conversation's now. */
275
+ let late = false;
201
276
  const settle = (value: T, outcome: string | undefined) => {
202
277
  if (!this.#open.delete(id)) return;
203
278
  clearTimeout(timer);
204
279
  signal?.removeEventListener("abort", abort);
280
+ if (late) return;
205
281
  // A card ended by an answer is edited by the answering interaction.
206
282
  if (outcome !== undefined) {
207
283
  finish = outcome;
@@ -225,17 +301,39 @@ export class OwnerCards implements InteractionModule {
225
301
  card.audience.mentions,
226
302
  );
227
303
  const abort = () => settle(cancelled, messages().cardStopped);
228
- const timer = setTimeout(
229
- () => settle(expired, messages().cardExpired),
230
- timeoutMs,
231
- );
304
+ const timer = wait
305
+ ? setTimeout(() => {
306
+ if (!this.#open.has(id)) return;
307
+ late = true;
308
+ signal?.removeEventListener("abort", abort);
309
+ resolve(wait.pending);
310
+ }, graceMs)
311
+ : undefined;
232
312
  signal?.addEventListener("abort", abort, { once: true });
233
313
  const open: OpenCard = {
234
314
  title: card.title,
235
315
  sections,
236
316
  rows: (disabled) => card.rows(id, disabled),
237
317
  audience: card.audience,
238
- end: (value) => settle(value as T, undefined),
318
+ end: (value, by) => {
319
+ const answeredLate = late;
320
+ settle(value as T, undefined);
321
+ if (!answeredLate || !wait || !resume) return;
322
+ try {
323
+ const text = wait.late(value, by.user.name);
324
+ if (text === undefined) return;
325
+ resume({
326
+ channelId,
327
+ messageId: by.messageId ?? `card-${id}`,
328
+ user: by.user,
329
+ ...(by.guildId ? { guildId: by.guildId } : {}),
330
+ isDirect: by.isDirect,
331
+ text,
332
+ });
333
+ } catch (error) {
334
+ logger.warn({ channelId, err: error }, "late card answer lost");
335
+ }
336
+ },
239
337
  ...(card.question ? { question: card.question } : {}),
240
338
  };
241
339
  this.#open.set(id, open);
@@ -299,6 +397,12 @@ export class OwnerCards implements InteractionModule {
299
397
  return true;
300
398
  }
301
399
  const user = discordUser(interaction);
400
+ const by: Answerer = {
401
+ user,
402
+ ...(interaction.message ? { messageId: interaction.message.id } : {}),
403
+ ...(interaction.guildId ? { guildId: interaction.guildId } : {}),
404
+ isDirect: !interaction.inGuild(),
405
+ };
302
406
  if (!(await card.audience.allows(user))) {
303
407
  await interaction.reply({
304
408
  content: card.audience.refusal,
@@ -319,7 +423,7 @@ export class OwnerCards implements InteractionModule {
319
423
  );
320
424
  if (interaction.isButton() && (action === "yes" || action === "no")) {
321
425
  const approved = action === "yes";
322
- card.end(approved ? "approved" : "declined");
426
+ card.end(approved ? "approved" : "declined", by);
323
427
  await interaction.update(
324
428
  closed(approved ? messages().cardApproved : messages().cardDeclined),
325
429
  );
@@ -343,7 +447,7 @@ export class OwnerCards implements InteractionModule {
343
447
  return true;
344
448
  }
345
449
  const answer = { choices };
346
- card.end(answer);
450
+ card.end(answer, by);
347
451
  await interaction.update(closed(answeredLine(answer)));
348
452
  return true;
349
453
  }
@@ -352,7 +456,7 @@ export class OwnerCards implements InteractionModule {
352
456
  choices: card.picked ?? [],
353
457
  text: interaction.fields.getTextInputValue(TEXT_FIELD),
354
458
  };
355
- card.end(answer);
459
+ card.end(answer, by);
356
460
  if (interaction.isFromMessage())
357
461
  await interaction.update(closed(answeredLine(answer)));
358
462
  else await interaction.deferUpdate();
@@ -361,75 +465,3 @@ export class OwnerCards implements InteractionModule {
361
465
  return true;
362
466
  }
363
467
  }
364
-
365
- /** A question's controls: a menu of its options, or a button that opens the answer form. */
366
- function askRows(id: string, question: OwnerQuestion, disabled: boolean): Rows {
367
- if (question.options.length === 0)
368
- return [
369
- new ActionRowBuilder<ButtonBuilder>().addComponents(
370
- new ButtonBuilder()
371
- .setCustomId(`${CARD_PREFIX}${id}:write`)
372
- .setLabel(messages().cardAnswerLabel)
373
- .setStyle(ButtonStyle.Primary)
374
- .setDisabled(disabled),
375
- ),
376
- ];
377
- // Discord menus take 25 options; "Other…" takes the last place when the options fill it.
378
- const options = question.options
379
- .slice(0, question.allowOther ? 24 : 25)
380
- .map((option, i) => ({
381
- label: option.label.slice(0, 100),
382
- value: String(i),
383
- ...(option.description
384
- ? { description: option.description.slice(0, 100) }
385
- : {}),
386
- }));
387
- if (question.allowOther)
388
- options.push({
389
- label: messages().cardOtherLabel,
390
- value: OTHER,
391
- description: messages().cardOtherDescription,
392
- });
393
- return [
394
- new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
395
- new StringSelectMenuBuilder()
396
- .setCustomId(`${CARD_PREFIX}${id}:pick`)
397
- .setPlaceholder(
398
- question.multi
399
- ? messages().cardPickManyPlaceholder
400
- : messages().cardPickOnePlaceholder,
401
- )
402
- .setMinValues(1)
403
- .setMaxValues(question.multi ? options.length : 1)
404
- .addOptions(options)
405
- .setDisabled(disabled),
406
- ),
407
- ];
408
- }
409
-
410
- function answerModal(id: string): ModalBuilder {
411
- return new ModalBuilder()
412
- .setCustomId(`${CARD_PREFIX}${id}:text`)
413
- .setTitle(messages().cardModalTitle)
414
- .addLabelComponents(
415
- new LabelBuilder()
416
- .setLabel(messages().cardModalField)
417
- .setTextInputComponent(
418
- new TextInputBuilder()
419
- .setCustomId(TEXT_FIELD)
420
- .setStyle(TextInputStyle.Paragraph)
421
- .setRequired(true)
422
- .setMaxLength(1000),
423
- ),
424
- );
425
- }
426
-
427
- function answeredLine(answer: OwnerAnswer): string {
428
- const parts = [
429
- ...answer.choices.map(plain),
430
- ...(answer.text !== undefined
431
- ? [messages().cardQuote(plain(answer.text))]
432
- : []),
433
- ];
434
- return messages().cardAnswered(parts);
435
- }
package/src/core/host.ts CHANGED
@@ -55,6 +55,8 @@ export interface HostEnvironment {
55
55
  rootCommand?: string;
56
56
  /** Exported as `PI_CODING_AGENT_DIR` for Pi packages such as pi-web-access; unset leaves the process's own. */
57
57
  agentDir?: string;
58
+ /** The host's scratch dir, which plugins read as `env.scratchDir`; unset gives them none. */
59
+ scratchDir?: string;
58
60
  }
59
61
 
60
62
  export interface RoundtableOptions {
@@ -245,12 +247,18 @@ export class Roundtable {
245
247
  assistant = "Roundtable",
246
248
  rootCommand = "roundtable",
247
249
  agentDir,
250
+ scratchDir,
248
251
  } = this.#options.environment ?? {};
249
252
  setLocale(locale, { assistant, root: rootCommand });
250
253
  setTimeZone(timeZone);
251
254
  // Pi packages such as pi-web-access read their config from the Pi agent directory.
252
255
  if (agentDir !== undefined) process.env.PI_CODING_AGENT_DIR = agentDir;
253
- return { locale, timeZone, now: () => new Date() };
256
+ return {
257
+ locale,
258
+ timeZone,
259
+ now: () => new Date(),
260
+ ...(scratchDir !== undefined ? { scratchDir } : {}),
261
+ };
254
262
  }
255
263
 
256
264
  async #boot(): Promise<void> {
@@ -6,10 +6,9 @@ export function cardsEn(ctx: CatalogContext) {
6
6
  cardRunLabel: "Run",
7
7
  cardCancelLabel: "Cancel",
8
8
  cardApprovalFooter:
9
- "If nobody answers within 30 minutes, it falls back to waiting for your confirmation in text.",
10
- cardQuestionFooter: "Valid for 30 minutes.",
9
+ "Nothing runs until you approve; answer whenever you are ready.",
10
+ cardQuestionFooter: "Answer whenever you are ready.",
11
11
  cardStopped: "⏹️ This round was stopped.",
12
- cardExpired: "⌛ Timed out with no answer.",
13
12
  cardOwnerOnly: `Only ${ctx.assistant}'s owner can answer this card.`,
14
13
  cardApproversNote: (...userIds: readonly string[]) =>
15
14
  `-# Who can approve: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
@@ -18,7 +17,7 @@ export function cardsEn(ctx: CatalogContext) {
18
17
  cardAskerNote: (...userIds: readonly string[]) =>
19
18
  `-# Who can answer: ${userIds.map((id) => `<@${id}>`).join(" ")}`,
20
19
  cardAskerRefusal: "This question is for another speaker.",
21
- cardInactive: `This card is no longer active (it timed out, or ${ctx.assistant} restarted); reply in text if you need to.`,
20
+ cardInactive: `This card is no longer valid (${ctx.assistant} restarted since it was posted, or it was already answered); ask again, or reply in text.`,
22
21
  cardApproved: "✅ Approved to run.",
23
22
  cardDeclined: "❌ Cancelled.",
24
23
  cardAnswerLabel: "Answer…",
@@ -38,10 +37,9 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
38
37
  return {
39
38
  cardRunLabel: "執行",
40
39
  cardCancelLabel: "取消",
41
- cardApprovalFooter: "30 分鐘內沒回覆,就改成等你用文字確認。",
42
- cardQuestionFooter: "30 分鐘內有效。",
40
+ cardApprovalFooter: "你核准之前不會執行;準備好再回覆就好。",
41
+ cardQuestionFooter: "準備好再回答就好。",
43
42
  cardStopped: "⏹️ 這一輪已停止。",
44
- cardExpired: "⌛ 已逾時,沒有回答。",
45
43
  cardOwnerOnly: `只有 ${ctx.assistant} 的擁有者能回答這張卡片。`,
46
44
  cardApproversNote: (...userIds: readonly string[]) =>
47
45
  `-# 可以核准的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
@@ -49,7 +47,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
49
47
  cardAskerNote: (...userIds: readonly string[]) =>
50
48
  `-# 可以回答的人:${userIds.map((id) => `<@${id}>`).join(" ")}`,
51
49
  cardAskerRefusal: "這張問題是問另一位發話者的。",
52
- cardInactive: `這張卡片已經失效(逾時,或 ${ctx.assistant} 重新啟動過);需要的話直接打字回覆。`,
50
+ cardInactive: `這張卡片已經失效(${ctx.assistant} 在它發出後重新啟動過,或它已經回答過了);請再問一次,或直接打字回覆。`,
53
51
  cardApproved: "✅ 已核准執行。",
54
52
  cardDeclined: "❌ 已取消。",
55
53
  cardAnswerLabel: "回答…",
@@ -1,5 +1,12 @@
1
1
  import type { CatalogContext } from "./types.ts";
2
2
 
3
+ /** A size in B, KiB, or MiB. */
4
+ function formatBytes(bytes: number): string {
5
+ if (bytes < 1024) return `${bytes} B`;
6
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
7
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
8
+ }
9
+
3
10
  /** Notices, thread lines, and titles a conversation posts on its own. */
4
11
  export function conversationEn(ctx: CatalogContext) {
5
12
  return {
@@ -22,6 +29,10 @@ export function conversationEn(ctx: CatalogContext) {
22
29
  thinkingAuto: "auto",
23
30
  confirmTitle: (asker: string) => `${asker} wants to run this action`,
24
31
  askTitle: (asker: string) => `${asker} has a question for you`,
32
+ cardFile: (path: string, bytes: number | undefined) =>
33
+ bytes === undefined
34
+ ? `File \`${path}\` (not found now)`
35
+ : `File \`${path}\` (${formatBytes(bytes)})`,
25
36
  };
26
37
  }
27
38
 
@@ -47,5 +58,9 @@ export function conversationZhTW(
47
58
  thinkingAuto: "自動",
48
59
  confirmTitle: (asker: string) => `${asker} 要執行這個動作`,
49
60
  askTitle: (asker: string) => `${asker} 想問你`,
61
+ cardFile: (path: string, bytes: number | undefined) =>
62
+ bytes === undefined
63
+ ? `檔案 \`${path}\`(目前找不到)`
64
+ : `檔案 \`${path}\`(${formatBytes(bytes)})`,
50
65
  };
51
66
  }
@@ -3,10 +3,33 @@ import type { TurnRequest } from "../domain/ports.ts";
3
3
  import type { Speaker, Tier } from "../speakers.ts";
4
4
 
5
5
  /**
6
- * How a prompt ended: answered yes or no, unanswered until it expired, or cancelled when its turn
7
- * stopped.
6
+ * How a prompt ended: answered yes or no; `expired` when no one could answer it (no one's to
7
+ * approve, or the card could not be posted); `cancelled` when its turn stopped; or `pending` when
8
+ * the turn stopped waiting while the card stays open, its answer to come through `PromptWait`.
8
9
  */
9
- export type Approval = "approved" | "declined" | "expired" | "cancelled";
10
+ export type Approval =
11
+ | "approved"
12
+ | "declined"
13
+ | "expired"
14
+ | "cancelled"
15
+ | "pending";
16
+
17
+ /** An answer given on a prompt after its turn stopped waiting; `by` names who gave it. */
18
+ export type LateAnswer = { by: string } & (
19
+ | { kind: "approval"; approved: boolean }
20
+ | { kind: "question"; answer: OwnerAnswer }
21
+ );
22
+
23
+ /**
24
+ * How a prompt may outlive its turn. Given, a surface may stop the turn's wait after a grace
25
+ * period, resolving `pending`, and keep the prompt open; an answer that comes later goes to
26
+ * `late`, and the surface starts a turn in the prompt's conversation with the text `late`
27
+ * returns, as a message from whoever answered (none when it returns undefined). Without it the
28
+ * prompt waits for its answer or its turn's stop.
29
+ */
30
+ export interface PromptWait {
31
+ late(answer: LateAnswer): string | undefined;
32
+ }
10
33
 
11
34
  export interface AskOption {
12
35
  label: string;
@@ -64,27 +87,33 @@ export interface PromptScope {
64
87
 
65
88
  /**
66
89
  * Prompts inside a running turn, on its conversation's surface: an approval of a held action and an
67
- * `ask_user` question, answered by those its `PromptScope` names. Unanswered prompts expire; a
68
- * stopped turn cancels its open ones.
90
+ * `ask_user` question, answered by those its `PromptScope` names. A stopped turn cancels the ones
91
+ * it still waits on; with a `PromptWait`, a prompt may outlive the turn's wait (see there).
69
92
  */
70
93
  export interface Prompts {
71
94
  /**
72
95
  * Asks to approve an action; `signal` cancels the prompt when the turn stops. `minTier` is the
73
96
  * tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
74
97
  * prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
98
+ * `pending` comes only with a `wait`.
75
99
  */
76
100
  confirm(
77
101
  title: string,
78
102
  message: string,
79
103
  signal?: AbortSignal,
80
104
  minTier?: Tier,
105
+ wait?: PromptWait,
81
106
  ): Promise<Approval>;
82
- /** Asks a question; undefined when it expired or was cancelled. */
107
+ /**
108
+ * Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
109
+ * with a `wait`, when the turn stopped waiting and the question stays open.
110
+ */
83
111
  ask(
84
112
  title: string,
85
113
  question: OwnerQuestion,
86
114
  signal?: AbortSignal,
87
- ): Promise<OwnerAnswer | undefined>;
115
+ wait?: PromptWait,
116
+ ): Promise<OwnerAnswer | "pending" | undefined>;
88
117
  }
89
118
 
90
119
  /** @deprecated Since 0.9 the prompts are `Prompts`, answered by those their `PromptScope` names; this name goes away in 1.0. */
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { type TObject, Type } from "typebox";
3
+ import { resolveChannelFiles } from "../../discord/channel-files.ts";
3
4
  import {
4
5
  CHANNEL_TOOLS,
5
6
  ChannelToolError,
7
+ SESSION_CHANNEL_TOOLS,
6
8
  } from "../../discord/channel-operations.ts";
7
9
  import { assistantName } from "../../i18n/index.ts";
8
10
  import {
@@ -11,6 +13,7 @@ import {
11
13
  ownerWords,
12
14
  } from "../../identity.ts";
13
15
  import { textToolsExtension } from "../../runtime/text-tools.ts";
16
+ import type { WorkspaceRoots } from "../../shared/workspace-files.ts";
14
17
 
15
18
  const id = Type.String({ pattern: "^\\d{1,20}$" });
16
19
  const guild = { guildId: id };
@@ -259,13 +262,18 @@ export interface OwnerOperations {
259
262
  run(tool: string, args: Record<string, unknown>): Promise<unknown>;
260
263
  }
261
264
 
265
+ /**
266
+ * The owner's Discord tools for one session. With `roots`, a file to send may name a path inside
267
+ * the session's workspace or scratch dir; without, files go inline only.
268
+ */
262
269
  export function discordAdminExtension(
263
270
  discord: OwnerOperations,
264
271
  owner: OwnerIdentity,
272
+ roots?: WorkspaceRoots,
265
273
  ): ExtensionFactory {
266
274
  const o = ownerWords(owner);
267
275
  const specs = [
268
- ...Object.entries(CHANNEL_TOOLS).map(([name, spec]) => ({
276
+ ...Object.entries(SESSION_CHANNEL_TOOLS).map(([name, spec]) => ({
269
277
  name,
270
278
  label: name,
271
279
  description: spec.description,
@@ -281,7 +289,13 @@ export function discordAdminExtension(
281
289
  return textToolsExtension(
282
290
  specs.map((spec) => ({
283
291
  ...spec,
284
- run: async (input) => JSON.stringify(await discord.run(spec.name, input)),
292
+ run: async (input) =>
293
+ JSON.stringify(
294
+ await discord.run(
295
+ spec.name,
296
+ await resolveChannelFiles(spec.name, input, roots),
297
+ ),
298
+ ),
285
299
  })),
286
300
  ChannelToolError,
287
301
  );
@@ -271,6 +271,11 @@ export interface HostEnv {
271
271
  readonly timeZone: string;
272
272
  /** The current instant. */
273
273
  now(): Date;
274
+ /**
275
+ * The host's scratch dir, when it has one: the agents' TMPDIR, where writes and removals run
276
+ * without a hold. Plugins that run shells of their own (coding workers) give it to them too.
277
+ */
278
+ readonly scratchDir?: string;
274
279
  }
275
280
 
276
281
  /** What the host gives every plugin. */