pi-roundtable 0.9.2 → 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 +45 -0
- package/README.md +4 -0
- package/README.zh-TW.md +4 -0
- package/docs/plugins.md +36 -7
- package/package.json +1 -1
- package/src/core/agents/agent-prompt.ts +1 -1
- package/src/core/attachments/attachment-dir.ts +51 -0
- package/src/core/attachments/attachment-fetcher.ts +30 -6
- package/src/core/attachments/attachment-name.ts +34 -0
- package/src/core/attachments/attachment-store.ts +169 -54
- package/src/core/attachments/prompt-block.ts +6 -2
- package/src/core/attachments/tool-attachment.ts +14 -5
- 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/contract/attachments.ts +23 -2
- 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/pi-agent-runtime.ts +5 -1
- 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/attachment-reader.ts +11 -4
- package/src/core/shared/workspace-files.ts +76 -0
- package/src/core/testing/thread-host.ts +14 -0
- package/src/index.ts +3 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/worker.ts +2 -0
- package/src/testing.ts +7 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { messages } from "../../i18n/index.ts";
|
|
4
|
-
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
4
|
+
import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
|
|
5
5
|
import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
|
|
6
6
|
import { toolText } from "../../shared/tool-result.ts";
|
|
7
7
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
@@ -10,12 +10,14 @@ export const ASK_USER_TOOL = "ask_user";
|
|
|
10
10
|
|
|
11
11
|
/** The tool's result: what the owner chose and wrote. */
|
|
12
12
|
export function answerText(
|
|
13
|
-
answer: OwnerAnswer | undefined,
|
|
13
|
+
answer: OwnerAnswer | "pending" | undefined,
|
|
14
14
|
owner: OwnerIdentity,
|
|
15
15
|
): string {
|
|
16
16
|
const o = ownerWords(owner);
|
|
17
|
+
if (answer === "pending")
|
|
18
|
+
return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
|
|
17
19
|
if (!answer)
|
|
18
|
-
return `No answer: ${o.name} did not answer
|
|
20
|
+
return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
|
|
19
21
|
const lines = [
|
|
20
22
|
...(answer.choices.length > 0
|
|
21
23
|
? [`${o.name} chose: ${answer.choices.join("; ")}`]
|
|
@@ -25,6 +27,15 @@ export function answerText(
|
|
|
25
27
|
return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** The turn a late answer starts: the question it answers and what was chosen and written. */
|
|
31
|
+
export function lateAnswerText(
|
|
32
|
+
question: string,
|
|
33
|
+
answer: OwnerAnswer,
|
|
34
|
+
by: string,
|
|
35
|
+
): string {
|
|
36
|
+
return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
/** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
|
|
29
40
|
export function askUserExtension(
|
|
30
41
|
slot: PromptSlot,
|
|
@@ -37,7 +48,7 @@ export function askUserExtension(
|
|
|
37
48
|
pi.registerTool({
|
|
38
49
|
name: ASK_USER_TOOL,
|
|
39
50
|
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. 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.
|
|
51
|
+
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. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
|
|
41
52
|
parameters: Type.Object(
|
|
42
53
|
{
|
|
43
54
|
question: Type.String({ minLength: 1, maxLength: 1500 }),
|
|
@@ -88,6 +99,12 @@ export function askUserExtension(
|
|
|
88
99
|
messages().askTitle(slot.asker),
|
|
89
100
|
question,
|
|
90
101
|
signal,
|
|
102
|
+
{
|
|
103
|
+
late: (late) =>
|
|
104
|
+
late.kind === "question"
|
|
105
|
+
? lateAnswerText(p.question, late.answer, late.by)
|
|
106
|
+
: undefined,
|
|
107
|
+
},
|
|
91
108
|
),
|
|
92
109
|
addressee?.() ?? owner,
|
|
93
110
|
),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type {
|
|
3
5
|
HeldCall,
|
|
@@ -6,7 +8,7 @@ import type {
|
|
|
6
8
|
import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
|
|
7
9
|
import { messages } from "../../i18n/index.ts";
|
|
8
10
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
9
|
-
import type { Prompts } from "../../interactions/prompts.ts";
|
|
11
|
+
import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
|
|
10
12
|
import type { Speaker } from "../../speakers.ts";
|
|
11
13
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
12
14
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
@@ -70,16 +72,22 @@ export class ConfirmationGate {
|
|
|
70
72
|
#speaker: Pick<Speaker, "id" | "principalId"> | undefined;
|
|
71
73
|
#pending: PendingConfirmation | undefined;
|
|
72
74
|
#approved: HeldCall[] = [];
|
|
75
|
+
/** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
|
|
76
|
+
#lateApproved: HeldCall[] = [];
|
|
73
77
|
readonly #holds: HoldCheck;
|
|
74
78
|
readonly #owner: OwnerIdentity;
|
|
75
79
|
/** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
|
|
76
80
|
#addressee: OwnerIdentity;
|
|
77
81
|
readonly #context: HoldContext;
|
|
78
82
|
readonly #tiers: ToolTiers | undefined;
|
|
83
|
+
/** Where the session's relative file paths resolve, for the sizes a card shows. */
|
|
84
|
+
readonly #fileRoot: string | undefined;
|
|
79
85
|
|
|
80
86
|
/**
|
|
81
87
|
* `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
|
|
82
88
|
* and its writes outside the workspace and the context's scratch dir are held too.
|
|
89
|
+
* `fileRoot` is the session's working directory, where a card resolves relative file paths;
|
|
90
|
+
* it defaults to the workspace.
|
|
83
91
|
*/
|
|
84
92
|
constructor(
|
|
85
93
|
holds: HoldCheck,
|
|
@@ -87,8 +95,10 @@ export class ConfirmationGate {
|
|
|
87
95
|
pending?: PendingConfirmation,
|
|
88
96
|
context: HoldContext = {},
|
|
89
97
|
tiers?: ToolTiers,
|
|
98
|
+
fileRoot?: string,
|
|
90
99
|
) {
|
|
91
100
|
this.#tiers = tiers;
|
|
101
|
+
this.#fileRoot = fileRoot ?? context.workspace;
|
|
92
102
|
this.#holds = holds;
|
|
93
103
|
this.#owner = owner;
|
|
94
104
|
this.#addressee = owner;
|
|
@@ -155,11 +165,18 @@ export class ConfirmationGate {
|
|
|
155
165
|
if (!ask) return { reason: this.#record(call) };
|
|
156
166
|
const answer = await ask.prompts.confirm(
|
|
157
167
|
messages().confirmTitle(ask.asker),
|
|
158
|
-
approvalCard(call),
|
|
168
|
+
approvalCard(call, this.#fileRoot),
|
|
159
169
|
ask.signal,
|
|
160
170
|
higherTier(this.#tiers?.minTier(call.tool), call.minTier),
|
|
171
|
+
{ late: (late) => this.#late(call, late) },
|
|
161
172
|
);
|
|
162
173
|
if (answer === "approved") return { approvedOnCard: true };
|
|
174
|
+
if (answer === "pending") {
|
|
175
|
+
const o = ownerWords(this.#addressee);
|
|
176
|
+
return {
|
|
177
|
+
reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
163
180
|
if (answer === "declined") {
|
|
164
181
|
const o = ownerWords(this.#addressee);
|
|
165
182
|
return {
|
|
@@ -170,6 +187,18 @@ export class ConfirmationGate {
|
|
|
170
187
|
return { reason: this.#record(call) };
|
|
171
188
|
}
|
|
172
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A late answer on a call's card: an approval lets the identical call run once, in whichever
|
|
192
|
+
* turn makes it next; returns the text of the turn that tells the model.
|
|
193
|
+
*/
|
|
194
|
+
#late(call: HeldCall, late: LateAnswer): string | undefined {
|
|
195
|
+
if (late.kind !== "approval") return undefined;
|
|
196
|
+
if (!late.approved)
|
|
197
|
+
return `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`;
|
|
198
|
+
this.#lateApproved.push(call);
|
|
199
|
+
return `(${late.by} approved on its card the action you were waiting on: it would ${call.action}. Make this call now with exactly this input, and it runs once; then report its result.)\n- ${call.tool} ${call.input}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
173
202
|
/** The call when it needs the owner's confirmation and no approval releases it. */
|
|
174
203
|
#needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
|
|
175
204
|
const context = this.#context;
|
|
@@ -182,12 +211,14 @@ export class ConfirmationGate {
|
|
|
182
211
|
action,
|
|
183
212
|
...(minTier ? { minTier } : {}),
|
|
184
213
|
};
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
214
|
+
for (const released of [this.#approved, this.#lateApproved]) {
|
|
215
|
+
const approved = released.findIndex(
|
|
216
|
+
(c) => c.tool === call.tool && c.input === call.input,
|
|
217
|
+
);
|
|
218
|
+
if (approved !== -1) {
|
|
219
|
+
released.splice(approved, 1);
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
191
222
|
}
|
|
192
223
|
return call;
|
|
193
224
|
}
|
|
@@ -229,13 +260,44 @@ export function confirmedTurnText(
|
|
|
229
260
|
return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
|
|
230
261
|
}
|
|
231
262
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
263
|
+
/**
|
|
264
|
+
* What an approval card says: the held action's words, the exact call, and each file it sends by
|
|
265
|
+
* path with the file's size now; relative paths resolve against `workspace`.
|
|
266
|
+
*/
|
|
267
|
+
export function approvalCard(call: HeldCall, workspace?: string): string {
|
|
234
268
|
const input =
|
|
235
269
|
call.input.length > CARD_INPUT_CHARS
|
|
236
270
|
? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
|
|
237
271
|
: call.input;
|
|
238
|
-
|
|
272
|
+
const files = pathFiles(call.input).map(
|
|
273
|
+
(path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
|
|
274
|
+
);
|
|
275
|
+
return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The paths of a call's `files` entries that name one. */
|
|
279
|
+
function pathFiles(input: string): string[] {
|
|
280
|
+
let parsed: unknown;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(input);
|
|
283
|
+
} catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
const files = isPlainObject(parsed) ? parsed.files : undefined;
|
|
287
|
+
if (!Array.isArray(files)) return [];
|
|
288
|
+
return files.flatMap((file: unknown) =>
|
|
289
|
+
isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A file's size in bytes, or undefined when it cannot be read. */
|
|
294
|
+
function fileSize(path: string, workspace: string | undefined) {
|
|
295
|
+
try {
|
|
296
|
+
const info = statSync(resolve(workspace ?? "/", path));
|
|
297
|
+
return info.isFile() ? info.size : undefined;
|
|
298
|
+
} catch {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
239
301
|
}
|
|
240
302
|
|
|
241
303
|
/** Without a slot, or with an empty one, every call that needs confirmation is held. */
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
type ModelRuntime,
|
|
4
4
|
SessionManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
discardConversationFiles,
|
|
8
|
+
ownerAttachmentDir,
|
|
9
|
+
} from "../attachments/attachment-dir.ts";
|
|
7
10
|
import { withAttachmentsBlock } from "../attachments/prompt-block.ts";
|
|
8
11
|
import type { AgentRuntime } from "../contract/runtime.ts";
|
|
9
12
|
import { NO_ATTACHMENTS, type TurnAttachments } from "../domain/attachment.ts";
|
|
@@ -447,6 +450,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
447
450
|
async deleteConversation(channel: ChannelKey): Promise<void> {
|
|
448
451
|
await this.#sessions.forget(channel);
|
|
449
452
|
rmSync(this.#factory.sessionDir(channel), { recursive: true, force: true });
|
|
453
|
+
await discardConversationFiles(this.#options.dataDir, channel);
|
|
450
454
|
this.#options.logger.info({ channel }, "conversation deleted");
|
|
451
455
|
}
|
|
452
456
|
|
|
@@ -46,10 +46,12 @@ export class PromptSlot {
|
|
|
46
46
|
const prompts = this.#prompts;
|
|
47
47
|
if (!prompts) return undefined;
|
|
48
48
|
return {
|
|
49
|
-
confirm: (title, message, signal, minTier) =>
|
|
50
|
-
this.#waiting(() =>
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
confirm: (title, message, signal, minTier, wait) =>
|
|
50
|
+
this.#waiting(() =>
|
|
51
|
+
prompts.confirm(title, message, signal, minTier, wait),
|
|
52
|
+
),
|
|
53
|
+
ask: (title, question: OwnerQuestion, signal, wait) =>
|
|
54
|
+
this.#waiting(() => prompts.ask(title, question, signal, wait)),
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -111,6 +111,8 @@ export const revisionsKey = (plan: SessionPlan) =>
|
|
|
111
111
|
/** The core's own extensions of one session, which the plan's contributions surround. */
|
|
112
112
|
export interface CoreExtensions {
|
|
113
113
|
readAttachment: ExtensionFactory;
|
|
114
|
+
/** `attach_file`, for a session with a workspace. */
|
|
115
|
+
attachFile?: ExtensionFactory;
|
|
114
116
|
confirmationGate: ExtensionFactory;
|
|
115
117
|
askUser: ExtensionFactory;
|
|
116
118
|
selfCompactGuard: ExtensionFactory;
|
|
@@ -139,6 +141,9 @@ export function sessionExtensions(
|
|
|
139
141
|
return [
|
|
140
142
|
...contributed(plan.tools),
|
|
141
143
|
{ name: "read-attachment", factory: core.readAttachment },
|
|
144
|
+
...(core.attachFile
|
|
145
|
+
? [{ name: "attach-file", factory: core.attachFile }]
|
|
146
|
+
: []),
|
|
142
147
|
{ name: "confirmation-gate", factory: core.confirmationGate },
|
|
143
148
|
{ name: "ask-user", factory: core.askUser },
|
|
144
149
|
{ name: "self-compact-guard", factory: core.selfCompactGuard },
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
type TransientTask,
|
|
24
24
|
} from "../sessions.ts";
|
|
25
25
|
import { activeToolsExtension } from "../shared/active-tools.ts";
|
|
26
|
+
import {
|
|
27
|
+
ATTACH_FILE_TOOL,
|
|
28
|
+
attachFileExtension,
|
|
29
|
+
} from "../shared/attach-file-tool.ts";
|
|
26
30
|
import { packageDir } from "../shared/package-dir.ts";
|
|
27
31
|
import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
|
|
28
32
|
import { type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
@@ -130,6 +134,8 @@ export class SessionFactory {
|
|
|
130
134
|
...currentToolNames(selection.tools, this.#options.logger),
|
|
131
135
|
COMPACT_TOOL,
|
|
132
136
|
ASK_USER_TOOL,
|
|
137
|
+
// Registered only by a session with a workspace.
|
|
138
|
+
ATTACH_FILE_TOOL,
|
|
133
139
|
...groups
|
|
134
140
|
.filter((group) => selection.groups.includes(group.name))
|
|
135
141
|
.flatMap((group) => group.tools),
|
|
@@ -332,6 +338,14 @@ export class SessionFactory {
|
|
|
332
338
|
},
|
|
333
339
|
conversation,
|
|
334
340
|
addressee,
|
|
341
|
+
...(agents
|
|
342
|
+
? {
|
|
343
|
+
workspace: {
|
|
344
|
+
workspace: cwd,
|
|
345
|
+
...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
335
349
|
attachmentDir,
|
|
336
350
|
get memory() {
|
|
337
351
|
return state.memory;
|
|
@@ -353,6 +367,9 @@ export class SessionFactory {
|
|
|
353
367
|
additionalExtensionPaths: this.link().extensionPaths,
|
|
354
368
|
extensionFactories: sessionExtensions(this.plan, context, {
|
|
355
369
|
readAttachment: readAttachmentExtension(attachmentDir),
|
|
370
|
+
...(context.workspace
|
|
371
|
+
? { attachFile: attachFileExtension(context.workspace) }
|
|
372
|
+
: {}),
|
|
356
373
|
confirmationGate: confirmationGateExtension(gate, slot),
|
|
357
374
|
askUser: askUserExtension(slot, addressee, () =>
|
|
358
375
|
turnAddressee(
|
package/src/core/sessions.ts
CHANGED
|
@@ -83,6 +83,11 @@ export interface SessionContext {
|
|
|
83
83
|
* it reads `"none"` for that turn alone.
|
|
84
84
|
*/
|
|
85
85
|
memory: "speaker" | "none";
|
|
86
|
+
/**
|
|
87
|
+
* The shared workspace of a session with a shell, and its scratch dir: the roots its shell writes
|
|
88
|
+
* in without a hold, and where its tools may read a file by path. Absent for a session without one.
|
|
89
|
+
*/
|
|
90
|
+
workspace?: { workspace: string; scratchDir?: string };
|
|
86
91
|
/** Where the session's conversation keeps the files people attached; absent when the session keeps none. */
|
|
87
92
|
attachmentDir?: string;
|
|
88
93
|
/** The person the session's running turn is for; undefined between turns. */
|
|
@@ -124,6 +129,7 @@ export interface SessionTool {
|
|
|
124
129
|
/** Extensions the core places itself; no contribution may take their names. */
|
|
125
130
|
const CORE_EXTENSIONS = [
|
|
126
131
|
"read-attachment",
|
|
132
|
+
"attach-file",
|
|
127
133
|
"confirmation-gate",
|
|
128
134
|
"ask-user",
|
|
129
135
|
"self-compact-guard",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import {
|
|
4
|
+
attachReplyFile,
|
|
5
|
+
REPLY_FILE_LIMITS,
|
|
6
|
+
ReplyFileError,
|
|
7
|
+
} from "../reply-files.ts";
|
|
8
|
+
import { toolError, toolText } from "./tool-result.ts";
|
|
9
|
+
import {
|
|
10
|
+
findWorkspaceFile,
|
|
11
|
+
readWorkspaceFile,
|
|
12
|
+
WorkspaceFileError,
|
|
13
|
+
type WorkspaceRoots,
|
|
14
|
+
} from "./workspace-files.ts";
|
|
15
|
+
|
|
16
|
+
export const ATTACH_FILE_TOOL = "attach_file";
|
|
17
|
+
|
|
18
|
+
/** Registers `attach_file`, which posts a file from the session's disk with the turn's reply. */
|
|
19
|
+
export function attachFileExtension(roots: WorkspaceRoots): ExtensionFactory {
|
|
20
|
+
return (pi) => {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: ATTACH_FILE_TOOL,
|
|
23
|
+
label: "Attach file",
|
|
24
|
+
description:
|
|
25
|
+
"Show a file you have on disk, such as a picture you made, by attaching it to your reply in this conversation. Give its path inside the workspace or the scratch dir; never read an image or base64 a file to show it.",
|
|
26
|
+
parameters: Type.Object(
|
|
27
|
+
{
|
|
28
|
+
path: Type.String({
|
|
29
|
+
minLength: 1,
|
|
30
|
+
maxLength: 4096,
|
|
31
|
+
description:
|
|
32
|
+
"The file's path inside the workspace or the scratch dir.",
|
|
33
|
+
}),
|
|
34
|
+
filename: Type.Optional(
|
|
35
|
+
Type.String({
|
|
36
|
+
minLength: 1,
|
|
37
|
+
maxLength: 255,
|
|
38
|
+
description:
|
|
39
|
+
"The name it is posted under, without a path; defaults to the path's base name.",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
{ additionalProperties: false },
|
|
44
|
+
),
|
|
45
|
+
execute: async (_toolCallId, params) => {
|
|
46
|
+
try {
|
|
47
|
+
const file = await findWorkspaceFile(params.path, roots);
|
|
48
|
+
if (file.size > REPLY_FILE_LIMITS.maxFileBytes)
|
|
49
|
+
throw new ReplyFileError(
|
|
50
|
+
`${params.path} is ${file.size} bytes; a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
|
|
51
|
+
);
|
|
52
|
+
const data = await readWorkspaceFile(
|
|
53
|
+
file,
|
|
54
|
+
REPLY_FILE_LIMITS.maxFileBytes,
|
|
55
|
+
);
|
|
56
|
+
const name = params.filename ?? file.name;
|
|
57
|
+
attachReplyFile({ name, data });
|
|
58
|
+
return toolText(
|
|
59
|
+
`${name} (${data.byteLength} bytes) will appear with your reply.`,
|
|
60
|
+
);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (
|
|
63
|
+
error instanceof WorkspaceFileError ||
|
|
64
|
+
error instanceof ReplyFileError
|
|
65
|
+
)
|
|
66
|
+
return toolError(`Not attached: ${error.message}`);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
2
|
import { extractText, getDocumentProxy } from "unpdf";
|
|
3
|
+
import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
|
|
3
4
|
|
|
4
5
|
/** Characters returned per read; the model can ask for a later offset. */
|
|
5
6
|
export const READ_LIMIT = 60_000;
|
|
@@ -66,14 +67,20 @@ export async function readAttachment(
|
|
|
66
67
|
file: string,
|
|
67
68
|
offset = 0,
|
|
68
69
|
): Promise<ReadResult> {
|
|
69
|
-
if (
|
|
70
|
-
throw new Error(`"${file}" is not an attachment name`);
|
|
70
|
+
if (!isAttachmentName(file)) {
|
|
71
|
+
throw new Error(`"${plainText(file)}" is not an attachment name`);
|
|
71
72
|
}
|
|
72
73
|
const handle = Bun.file(join(dir, file));
|
|
73
74
|
if (!(await handle.exists())) {
|
|
74
75
|
throw new Error(`no attachment named "${file}" in this channel`);
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
+
let bytes: Uint8Array;
|
|
78
|
+
try {
|
|
79
|
+
bytes = new Uint8Array(await handle.arrayBuffer());
|
|
80
|
+
} catch {
|
|
81
|
+
// A file system error names the path on the host; the model sees only the attachment's name.
|
|
82
|
+
throw new Error(`"${plainText(file)}" could not be read`);
|
|
83
|
+
}
|
|
77
84
|
let text: string;
|
|
78
85
|
if (isPdf(file, bytes)) {
|
|
79
86
|
const pdf = await getDocumentProxy(bytes);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Where a session may read files by path: its shared workspace and its scratch dir. */
|
|
5
|
+
export interface WorkspaceRoots {
|
|
6
|
+
workspace: string;
|
|
7
|
+
scratchDir?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A file refused by path; its message names the path and is safe to show the model. */
|
|
11
|
+
export class WorkspaceFileError extends Error {
|
|
12
|
+
override name = "WorkspaceFileError";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A local file found inside the roots, not read yet. */
|
|
16
|
+
export interface WorkspaceFile {
|
|
17
|
+
/** The path as the caller gave it. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** Where it resolves, symlinks followed. */
|
|
20
|
+
realPath: string;
|
|
21
|
+
/** Its base name, the default name it is sent under. */
|
|
22
|
+
name: string;
|
|
23
|
+
size: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether `child` is `parent` or inside it. */
|
|
27
|
+
function within(parent: string, child: string): boolean {
|
|
28
|
+
const rel = relative(parent, child);
|
|
29
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function realRoot(root: string): Promise<string | undefined> {
|
|
33
|
+
return realpath(root).catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Finds a regular file by path, relative paths against the workspace, and refuses one that does
|
|
38
|
+
* not resolve inside the workspace or the scratch dir, so a symlink counts by its target.
|
|
39
|
+
*/
|
|
40
|
+
export async function findWorkspaceFile(
|
|
41
|
+
path: string,
|
|
42
|
+
roots: WorkspaceRoots,
|
|
43
|
+
): Promise<WorkspaceFile> {
|
|
44
|
+
const given = resolve(roots.workspace, path);
|
|
45
|
+
let real: string;
|
|
46
|
+
try {
|
|
47
|
+
real = await realpath(given);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new WorkspaceFileError(`${path} does not exist.`);
|
|
50
|
+
}
|
|
51
|
+
const allowed = await Promise.all(
|
|
52
|
+
[roots.workspace, roots.scratchDir]
|
|
53
|
+
.filter((root): root is string => root !== undefined)
|
|
54
|
+
.map(realRoot),
|
|
55
|
+
);
|
|
56
|
+
if (!allowed.some((root) => root !== undefined && within(root, real)))
|
|
57
|
+
throw new WorkspaceFileError(
|
|
58
|
+
`${path} is outside the workspace${roots.scratchDir ? " and the scratch dir" : ""}; only files inside ${roots.scratchDir ? "them" : "it"} can be sent.`,
|
|
59
|
+
);
|
|
60
|
+
const info = await stat(real);
|
|
61
|
+
if (!info.isFile()) throw new WorkspaceFileError(`${path} is not a file.`);
|
|
62
|
+
return { path, realPath: real, name: basename(given), size: info.size };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reads a file `findWorkspaceFile` found; a file that grew past `maxBytes` since is refused. */
|
|
66
|
+
export async function readWorkspaceFile(
|
|
67
|
+
file: WorkspaceFile,
|
|
68
|
+
maxBytes: number,
|
|
69
|
+
): Promise<Uint8Array> {
|
|
70
|
+
const data = new Uint8Array(await readFile(file.realPath));
|
|
71
|
+
if (data.byteLength > maxBytes)
|
|
72
|
+
throw new WorkspaceFileError(
|
|
73
|
+
`${file.path} is ${data.byteLength} bytes, over the ${maxBytes} allowed.`,
|
|
74
|
+
);
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DispatchThreads,
|
|
6
6
|
type ThreadHost,
|
|
7
7
|
} from "../discord/dispatch-threads.ts";
|
|
8
|
+
import type { InterimMessage } from "../domain/interim.ts";
|
|
8
9
|
import { silentLogger } from "../log.ts";
|
|
9
10
|
|
|
10
11
|
/** A ThreadHost that records what happens; parents in `hostless` cannot host threads. */
|
|
@@ -14,6 +15,8 @@ export class FakeThreadHost implements ThreadHost {
|
|
|
14
15
|
readonly opened: { parentId: string; name: string; id: string }[] = [];
|
|
15
16
|
readonly posts: { threadId: string; text: string }[] = [];
|
|
16
17
|
readonly closed: string[] = [];
|
|
18
|
+
/** Edits of messages `send` posted, in order; each also changes its entry in `posts`. */
|
|
19
|
+
readonly edits: { threadId: string; text: string }[] = [];
|
|
17
20
|
readonly hostless = new Set<string>();
|
|
18
21
|
failOpen = false;
|
|
19
22
|
#next = 900;
|
|
@@ -35,6 +38,17 @@ export class FakeThreadHost implements ThreadHost {
|
|
|
35
38
|
this.posts.push({ threadId, text });
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
async send(threadId: string, text: string): Promise<InterimMessage> {
|
|
42
|
+
const post = { threadId, text };
|
|
43
|
+
this.posts.push(post);
|
|
44
|
+
return {
|
|
45
|
+
edit: async (change) => {
|
|
46
|
+
post.text = change;
|
|
47
|
+
this.edits.push({ threadId, text: change });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
async close(threadId: string): Promise<void> {
|
|
39
53
|
this.closed.push(threadId);
|
|
40
54
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type {
|
|
|
38
38
|
AttachmentPort,
|
|
39
39
|
AttachmentRefusalCode,
|
|
40
40
|
AttachmentUpload,
|
|
41
|
+
TurnAttachmentOptions,
|
|
41
42
|
} from "./core/contract/attachments.ts";
|
|
42
43
|
export { AttachmentRefusal } from "./core/contract/attachments.ts";
|
|
43
44
|
export type {
|
|
@@ -161,11 +162,13 @@ export { SYSTEM_PRINCIPAL } from "./core/identity/principal-store.ts";
|
|
|
161
162
|
export type {
|
|
162
163
|
Approval,
|
|
163
164
|
AskOption,
|
|
165
|
+
LateAnswer,
|
|
164
166
|
OwnerAnswer,
|
|
165
167
|
OwnerPrompts,
|
|
166
168
|
OwnerQuestion,
|
|
167
169
|
PromptScope,
|
|
168
170
|
Prompts,
|
|
171
|
+
PromptWait,
|
|
169
172
|
} from "./core/interactions/prompts.ts";
|
|
170
173
|
export { promptScope } from "./core/interactions/prompts.ts";
|
|
171
174
|
export type { JudgeModel } from "./core/judging/model-judge.ts";
|
package/src/kit/index.ts
CHANGED
|
@@ -158,6 +158,7 @@ export {
|
|
|
158
158
|
toolText,
|
|
159
159
|
} from "./tools.ts";
|
|
160
160
|
export type {
|
|
161
|
+
InterimPosterOptions,
|
|
161
162
|
McpEndpoint,
|
|
162
163
|
PromptSlot,
|
|
163
164
|
VirtualServer,
|
|
@@ -166,6 +167,7 @@ export {
|
|
|
166
167
|
approvalCard,
|
|
167
168
|
archiveSessions,
|
|
168
169
|
canonicalJson,
|
|
170
|
+
InterimPoster,
|
|
169
171
|
mcpAdapterExtension,
|
|
170
172
|
mcpExtension,
|
|
171
173
|
promptSlot,
|
package/src/kit/worker.ts
CHANGED
|
@@ -5,6 +5,8 @@ export {
|
|
|
5
5
|
approvalCard,
|
|
6
6
|
canonicalJson,
|
|
7
7
|
} from "../core/runtime/extensions/confirmation-gate.ts";
|
|
8
|
+
export type { InterimPosterOptions } from "../core/runtime/interim-text.ts";
|
|
9
|
+
export { InterimPoster } from "../core/runtime/interim-text.ts";
|
|
8
10
|
export type { VirtualServer } from "../core/runtime/mcp.ts";
|
|
9
11
|
export { mcpExtension } from "../core/runtime/mcp.ts";
|
|
10
12
|
export type { PromptSlot } from "../core/runtime/prompt-slot.ts";
|
package/src/testing.ts
CHANGED
|
@@ -267,7 +267,12 @@ export interface TestPluginResult {
|
|
|
267
267
|
runTool(
|
|
268
268
|
name: string,
|
|
269
269
|
args: Record<string, unknown>,
|
|
270
|
-
|
|
270
|
+
/** `workspace` gives the session a workspace and scratch dir, as an agent's session has. */
|
|
271
|
+
options?: {
|
|
272
|
+
speaker?: Speaker;
|
|
273
|
+
channel?: ChannelKey;
|
|
274
|
+
workspace?: SessionContext["workspace"];
|
|
275
|
+
},
|
|
271
276
|
): Promise<string>;
|
|
272
277
|
stop(): Promise<void>;
|
|
273
278
|
}
|
|
@@ -632,6 +637,7 @@ export async function testPlugin(
|
|
|
632
637
|
conversation: { visibility: "shared" },
|
|
633
638
|
addressee: THE_SPEAKER,
|
|
634
639
|
memory: "speaker",
|
|
640
|
+
...(runOptions?.workspace ? { workspace: runOptions.workspace } : {}),
|
|
635
641
|
attachmentDir: ownerAttachmentDir(attachmentDir, channel),
|
|
636
642
|
speaker: () => runOptions?.speaker,
|
|
637
643
|
runTask: async () => {
|