pi-roundtable 0.9.3 → 0.9.5
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 +26 -0
- package/README.md +4 -0
- package/README.zh-TW.md +4 -0
- package/docs/plugins.md +31 -5
- package/package.json +1 -1
- package/src/core/agents/agent-prompt.ts +1 -1
- package/src/core/builtin/discord-admin.ts +6 -2
- package/src/core/builtin/discord.ts +10 -0
- package/src/core/config/adapters.ts +47 -0
- package/src/core/config/config.ts +9 -34
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/define.ts +8 -0
- package/src/core/discord/channel-files.ts +107 -0
- package/src/core/discord/channel-operations.ts +100 -33
- package/src/core/discord/discord-surface.ts +21 -4
- package/src/core/discord/dispatch-thread-host.ts +11 -0
- package/src/core/discord/dispatch-threads.ts +14 -0
- package/src/core/discord/inbound-message.ts +34 -0
- package/src/core/discord/owner-card-rows.ts +98 -0
- package/src/core/discord/owner-cards-fixture.ts +3 -0
- package/src/core/discord/owner-cards.ts +137 -105
- package/src/core/host.ts +9 -1
- package/src/core/i18n/cards.ts +6 -8
- package/src/core/i18n/conversation.ts +15 -0
- package/src/core/interactions/prompts.ts +36 -7
- package/src/core/modules/discord-admin/discord-admin.ts +16 -2
- package/src/core/plugin.ts +5 -0
- package/src/core/runtime/conversation-sessions.ts +2 -0
- package/src/core/runtime/extensions/ask-user.ts +21 -4
- package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
- package/src/core/runtime/prompt-slot.ts +6 -4
- package/src/core/runtime/runtime-types.ts +5 -0
- package/src/core/runtime/session-factory.ts +17 -0
- package/src/core/sessions.ts +6 -0
- package/src/core/shared/attach-file-tool.ts +72 -0
- package/src/core/shared/workspace-files.ts +76 -0
- package/src/core/testing/thread-host.ts +14 -0
- package/src/index.ts +2 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/worker.ts +2 -0
- 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 {
|
|
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 {
|
|
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
|
|
36
|
-
/**
|
|
37
|
-
export const
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
89
|
-
|
|
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.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
/**
|
|
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,
|
|
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 =
|
|
229
|
-
() =>
|
|
230
|
-
|
|
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) =>
|
|
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 {
|
|
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> {
|
package/src/core/i18n/cards.ts
CHANGED
|
@@ -6,10 +6,9 @@ export function cardsEn(ctx: CatalogContext) {
|
|
|
6
6
|
cardRunLabel: "Run",
|
|
7
7
|
cardCancelLabel: "Cancel",
|
|
8
8
|
cardApprovalFooter:
|
|
9
|
-
"
|
|
10
|
-
cardQuestionFooter: "
|
|
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
|
|
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: "
|
|
42
|
-
cardQuestionFooter: "
|
|
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:
|
|
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
|
|
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 =
|
|
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.
|
|
68
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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) =>
|
|
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
|
);
|
package/src/core/plugin.ts
CHANGED
|
@@ -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. */
|
|
@@ -107,6 +107,8 @@ export class ConversationSessions {
|
|
|
107
107
|
}
|
|
108
108
|
: {},
|
|
109
109
|
this.#tiers,
|
|
110
|
+
// Owner sessions have no shell workspace but still send files from their own directory.
|
|
111
|
+
agents ? agents.workDir : this.#factory.cwd(undefined),
|
|
110
112
|
);
|
|
111
113
|
this.#gates.set(key, gate);
|
|
112
114
|
this.#restored.delete(key);
|