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,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
type AttachmentPort,
|
|
7
7
|
AttachmentRefusal,
|
|
8
8
|
type AttachmentUpload,
|
|
9
|
+
type TurnAttachmentOptions,
|
|
9
10
|
} from "../contract/attachments.ts";
|
|
10
11
|
import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
|
|
11
12
|
import type {
|
|
@@ -14,12 +15,18 @@ import type {
|
|
|
14
15
|
} from "../domain/attachment.ts";
|
|
15
16
|
import type { Logger } from "../log.ts";
|
|
16
17
|
import type { ChannelKey } from "../sessions.ts";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
channelSegment,
|
|
20
|
+
listDir,
|
|
21
|
+
ownerAttachmentDir,
|
|
22
|
+
removeIfEmpty,
|
|
23
|
+
STAGING,
|
|
24
|
+
USAGE,
|
|
25
|
+
} from "./attachment-dir.ts";
|
|
18
26
|
import { MAX_ATTACHMENT_BYTES, safeFileName } from "./attachment-fetcher.ts";
|
|
27
|
+
import { isAttachmentName } from "./attachment-name.ts";
|
|
19
28
|
import { modelImagesOf } from "./model-images.ts";
|
|
20
29
|
|
|
21
|
-
/** Where saved files wait, beside the conversations' attachment directories. */
|
|
22
|
-
const STAGING = "attachments-pending";
|
|
23
30
|
/** The record of a saved file, in a directory next to it that no file name can be. */
|
|
24
31
|
export const RECORDS = ".records";
|
|
25
32
|
|
|
@@ -46,23 +53,28 @@ function principalSegment(principalId: string): string {
|
|
|
46
53
|
return createHash("sha256").update(principalId).digest("hex").slice(0, 32);
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
/**
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
/** The bytes of a tally file; none when it is missing or unreadable as a number. */
|
|
57
|
+
async function readTally(path: string): Promise<number> {
|
|
58
|
+
try {
|
|
59
|
+
const value = Number(await readFile(path, "utf8"));
|
|
60
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
/** Writes a tally whole or not at all, creating its directory if a conversation's deletion removed it meanwhile. */
|
|
68
|
+
async function writeTally(path: string, bytes: number): Promise<void> {
|
|
69
|
+
const temporary = `${path}.tmp`;
|
|
60
70
|
try {
|
|
61
|
-
|
|
71
|
+
await writeFile(temporary, String(bytes));
|
|
62
72
|
} catch (error) {
|
|
63
|
-
if ((error as NodeJS.ErrnoException).code
|
|
64
|
-
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
74
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
75
|
+
await writeFile(temporary, String(bytes));
|
|
65
76
|
}
|
|
77
|
+
await rename(temporary, path);
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
/**
|
|
@@ -70,14 +82,34 @@ async function listDir(dir: string): Promise<string[]> {
|
|
|
70
82
|
* its conversation until a turn uses it, then moved into the conversation's attachment directory,
|
|
71
83
|
* the one `read_attachment` reads. The conversation's record decides who may stage: a private
|
|
72
84
|
* conversation takes only its principal's files.
|
|
85
|
+
*
|
|
86
|
+
* Calls that change one principal's files run one after another, so two messages of one person
|
|
87
|
+
* never see each other half done; the tally of the bytes a principal used per conversation lives
|
|
88
|
+
* beside the staging directories.
|
|
73
89
|
*/
|
|
74
90
|
export class AttachmentStore implements AttachmentPort {
|
|
75
91
|
readonly #options: AttachmentStoreOptions;
|
|
92
|
+
/** The last call queued for each principal, which the next one waits for. */
|
|
93
|
+
readonly #tails = new Map<string, Promise<void>>();
|
|
76
94
|
|
|
77
95
|
constructor(options: AttachmentStoreOptions) {
|
|
78
96
|
this.#options = options;
|
|
79
97
|
}
|
|
80
98
|
|
|
99
|
+
/** Runs `run` after the principal's earlier calls, whether they succeeded or not. */
|
|
100
|
+
#exclusive<T>(principal: string, run: () => Promise<T>): Promise<T> {
|
|
101
|
+
const result = (this.#tails.get(principal) ?? Promise.resolve()).then(run);
|
|
102
|
+
const tail = result.then(
|
|
103
|
+
() => undefined,
|
|
104
|
+
() => undefined,
|
|
105
|
+
);
|
|
106
|
+
this.#tails.set(principal, tail);
|
|
107
|
+
void tail.then(() => {
|
|
108
|
+
if (this.#tails.get(principal) === tail) this.#tails.delete(principal);
|
|
109
|
+
});
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
#stagingDir(channel: ChannelKey, principalId: string): string {
|
|
82
114
|
return join(
|
|
83
115
|
this.#options.dataDir,
|
|
@@ -87,6 +119,10 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
87
119
|
);
|
|
88
120
|
}
|
|
89
121
|
|
|
122
|
+
#tallyDir(principalId: string): string {
|
|
123
|
+
return join(this.#options.dataDir, USAGE, principalSegment(principalId));
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/** Refuses a principal other than the one a private conversation belongs to. */
|
|
91
127
|
async #admit(channel: ChannelKey, principalId: string): Promise<void> {
|
|
92
128
|
const record = await this.#options.registry()?.get(channel);
|
|
@@ -110,15 +146,25 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
110
146
|
`a file may be at most 25 MiB; "${upload.name}" is ${size} bytes`,
|
|
111
147
|
);
|
|
112
148
|
const dir = this.#stagingDir(channel, principalId);
|
|
113
|
-
mkdirSync(join(dir, RECORDS), { recursive: true, mode: 0o700 });
|
|
114
149
|
const file = `${crypto.randomUUID()}-${safeFileName(upload.name)}`;
|
|
115
150
|
const path = join(dir, file);
|
|
116
|
-
|
|
151
|
+
const recordPath = join(dir, RECORDS, `${file}.json`);
|
|
117
152
|
const record: StagedRecord = {
|
|
118
153
|
name: upload.name,
|
|
119
154
|
contentType: upload.contentType,
|
|
120
155
|
};
|
|
121
|
-
await
|
|
156
|
+
await this.#exclusive(principalSegment(principalId), async () => {
|
|
157
|
+
mkdirSync(join(dir, RECORDS), { recursive: true, mode: 0o700 });
|
|
158
|
+
try {
|
|
159
|
+
await Bun.write(path, upload.data);
|
|
160
|
+
await Bun.write(recordPath, JSON.stringify(record));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A file without its record could never be used or removed, yet would count as waiting.
|
|
163
|
+
await rm(path, { force: true });
|
|
164
|
+
await rm(recordPath, { force: true });
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
122
168
|
return {
|
|
123
169
|
name: upload.name,
|
|
124
170
|
file,
|
|
@@ -133,8 +179,26 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
133
179
|
channel: ChannelKey,
|
|
134
180
|
principalId: string,
|
|
135
181
|
files: readonly string[],
|
|
182
|
+
options: TurnAttachmentOptions = {},
|
|
136
183
|
): Promise<TurnAttachments> {
|
|
137
184
|
await this.#admit(channel, principalId);
|
|
185
|
+
const found = await this.#exclusive(principalSegment(principalId), () =>
|
|
186
|
+
this.#claim(channel, principalId, files, options),
|
|
187
|
+
);
|
|
188
|
+
const { images, failures } = await modelImagesOf(
|
|
189
|
+
found,
|
|
190
|
+
this.#options.logger,
|
|
191
|
+
);
|
|
192
|
+
return { files: found, images, failures };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Checks every file, then moves them all into the conversation, or none when any step fails. */
|
|
196
|
+
async #claim(
|
|
197
|
+
channel: ChannelKey,
|
|
198
|
+
principalId: string,
|
|
199
|
+
files: readonly string[],
|
|
200
|
+
options: TurnAttachmentOptions,
|
|
201
|
+
): Promise<StoredAttachment[]> {
|
|
138
202
|
const staging = this.#stagingDir(channel, principalId);
|
|
139
203
|
const target = ownerAttachmentDir(this.#options.dataDir, channel);
|
|
140
204
|
const found: StoredAttachment[] = [];
|
|
@@ -145,39 +209,70 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
145
209
|
"unknown_file",
|
|
146
210
|
`no saved file "${file}" is waiting for this person in ${channel}`,
|
|
147
211
|
);
|
|
148
|
-
const from = join(staging, file);
|
|
149
212
|
found.push({
|
|
150
213
|
name: record.name,
|
|
151
214
|
file,
|
|
152
215
|
path: join(target, file),
|
|
153
216
|
contentType: record.contentType,
|
|
154
|
-
size: (await stat(
|
|
217
|
+
size: (await stat(join(staging, file))).size,
|
|
155
218
|
fromReference: false,
|
|
156
219
|
});
|
|
157
220
|
}
|
|
221
|
+
const added = found.reduce((sum, stored) => sum + stored.size, 0);
|
|
222
|
+
const tally = join(this.#tallyDir(principalId), channelSegment(channel));
|
|
223
|
+
const before = await readTally(tally);
|
|
224
|
+
if (options.usedBytesLimit !== undefined) {
|
|
225
|
+
const used = await this.#usedBytes(principalId);
|
|
226
|
+
if (used + added > options.usedBytesLimit)
|
|
227
|
+
throw new AttachmentRefusal(
|
|
228
|
+
"quota_exceeded",
|
|
229
|
+
`the files already used in turns hold ${used} bytes, and ${added} more would pass the limit of ${options.usedBytesLimit} bytes`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
158
232
|
mkdirSync(join(target, RECORDS), { recursive: true, mode: 0o700 });
|
|
159
|
-
|
|
160
|
-
|
|
233
|
+
mkdirSync(this.#tallyDir(principalId), { recursive: true, mode: 0o700 });
|
|
234
|
+
const moved: StoredAttachment[] = [];
|
|
235
|
+
try {
|
|
236
|
+
for (const stored of found) {
|
|
161
237
|
await rename(join(staging, stored.file), stored.path);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`the saved file "${stored.file}" was used already`,
|
|
168
|
-
);
|
|
169
|
-
throw error;
|
|
238
|
+
moved.push(stored);
|
|
239
|
+
await rename(
|
|
240
|
+
join(staging, RECORDS, `${stored.file}.json`),
|
|
241
|
+
join(target, RECORDS, `${stored.file}.json`),
|
|
242
|
+
);
|
|
170
243
|
}
|
|
244
|
+
await writeTally(tally, before + added);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
await this.#putBack(moved, staging, target, tally, before);
|
|
247
|
+
// Something else took the file between the check and the move.
|
|
248
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
249
|
+
throw new AttachmentRefusal(
|
|
250
|
+
"unknown_file",
|
|
251
|
+
"a saved file was used or discarded while the message was taken",
|
|
252
|
+
);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
return found;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Undoes a partial move: the files return to staging and the tally to what it was. */
|
|
259
|
+
async #putBack(
|
|
260
|
+
moved: readonly StoredAttachment[],
|
|
261
|
+
staging: string,
|
|
262
|
+
target: string,
|
|
263
|
+
tally: string,
|
|
264
|
+
before: number,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
for (const stored of moved) {
|
|
267
|
+
await rename(stored.path, join(staging, stored.file)).catch(
|
|
268
|
+
() => undefined,
|
|
269
|
+
);
|
|
171
270
|
await rename(
|
|
172
|
-
join(staging, RECORDS, `${stored.file}.json`),
|
|
173
271
|
join(target, RECORDS, `${stored.file}.json`),
|
|
174
|
-
|
|
272
|
+
join(staging, RECORDS, `${stored.file}.json`),
|
|
273
|
+
).catch(() => undefined);
|
|
175
274
|
}
|
|
176
|
-
|
|
177
|
-
found,
|
|
178
|
-
this.#options.logger,
|
|
179
|
-
);
|
|
180
|
-
return { files: found, images, failures };
|
|
275
|
+
await writeTally(tally, before).catch(() => undefined);
|
|
181
276
|
}
|
|
182
277
|
|
|
183
278
|
async remove(
|
|
@@ -187,27 +282,37 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
187
282
|
): Promise<boolean> {
|
|
188
283
|
await this.#admit(channel, principalId);
|
|
189
284
|
const staging = this.#stagingDir(channel, principalId);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
285
|
+
return this.#exclusive(principalSegment(principalId), async () => {
|
|
286
|
+
if (!(await this.#recordOf(staging, file))) return false;
|
|
287
|
+
await rm(join(staging, file), { force: true });
|
|
288
|
+
await rm(join(staging, RECORDS, `${file}.json`), { force: true });
|
|
289
|
+
return true;
|
|
290
|
+
});
|
|
194
291
|
}
|
|
195
292
|
|
|
196
293
|
async discardPending(olderThan: Date): Promise<number> {
|
|
197
294
|
const root = join(this.#options.dataDir, STAGING);
|
|
198
295
|
let discarded = 0;
|
|
199
296
|
for (const principal of await listDir(root)) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
for (const
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
297
|
+
discarded += await this.#exclusive(principal, async () => {
|
|
298
|
+
let count = 0;
|
|
299
|
+
for (const channel of await listDir(join(root, principal))) {
|
|
300
|
+
const dir = join(root, principal, channel);
|
|
301
|
+
for (const entry of await listDir(dir)) {
|
|
302
|
+
if (entry === RECORDS) continue;
|
|
303
|
+
const info = await stat(join(dir, entry)).catch(() => undefined);
|
|
304
|
+
if (!info || info.mtimeMs >= olderThan.getTime()) continue;
|
|
305
|
+
await rm(join(dir, entry), { force: true });
|
|
306
|
+
await rm(join(dir, RECORDS, `${entry}.json`), { force: true });
|
|
307
|
+
count += 1;
|
|
308
|
+
}
|
|
309
|
+
// A conversation with nothing waiting keeps no directory.
|
|
310
|
+
await removeIfEmpty(join(dir, RECORDS));
|
|
311
|
+
await removeIfEmpty(dir);
|
|
209
312
|
}
|
|
210
|
-
|
|
313
|
+
await removeIfEmpty(join(root, principal));
|
|
314
|
+
return count;
|
|
315
|
+
});
|
|
211
316
|
}
|
|
212
317
|
return discarded;
|
|
213
318
|
}
|
|
@@ -230,12 +335,22 @@ export class AttachmentStore implements AttachmentPort {
|
|
|
230
335
|
return total;
|
|
231
336
|
}
|
|
232
337
|
|
|
338
|
+
/** The bytes the principal's turns used, across their conversations. */
|
|
339
|
+
async #usedBytes(principalId: string): Promise<number> {
|
|
340
|
+
const dir = this.#tallyDir(principalId);
|
|
341
|
+
let total = 0;
|
|
342
|
+
for (const channel of await listDir(dir))
|
|
343
|
+
if (!channel.endsWith(".tmp"))
|
|
344
|
+
total += await readTally(join(dir, channel));
|
|
345
|
+
return total;
|
|
346
|
+
}
|
|
347
|
+
|
|
233
348
|
/** The record of a staged file, or undefined when it is not staged here. */
|
|
234
349
|
async #recordOf(
|
|
235
350
|
staging: string,
|
|
236
351
|
file: string,
|
|
237
352
|
): Promise<StagedRecord | undefined> {
|
|
238
|
-
if (!
|
|
353
|
+
if (!isAttachmentName(file)) return undefined;
|
|
239
354
|
const stored = Bun.file(join(staging, RECORDS, `${file}.json`));
|
|
240
355
|
if (
|
|
241
356
|
!(await stored.exists()) ||
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
|
+
import { plainContentType, plainText } from "./attachment-name.ts";
|
|
3
|
+
|
|
4
|
+
/** A name a prompt can quote: no control or bidi characters, and no quote to close it early. */
|
|
5
|
+
const quotable = (name: string): string => plainText(name).replaceAll('"', "'");
|
|
2
6
|
|
|
3
7
|
function formatSize(bytes: number): string {
|
|
4
8
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -19,13 +23,13 @@ export function withAttachmentsBlock(
|
|
|
19
23
|
for (const file of attachments.files) {
|
|
20
24
|
const origin = file.fromReference ? ", from the replied-to message" : "";
|
|
21
25
|
lines.push(
|
|
22
|
-
`- ${file.file} (original name "${file.name}", ${file.contentType}, ${formatSize(file.size)}${origin})`,
|
|
26
|
+
`- ${plainText(file.file)} (original name "${quotable(file.name)}", ${plainContentType(file.contentType)}, ${formatSize(file.size)}${origin})`,
|
|
23
27
|
);
|
|
24
28
|
}
|
|
25
29
|
for (const failure of attachments.failures) {
|
|
26
30
|
const origin = failure.fromReference ? " from the replied-to message" : "";
|
|
27
31
|
lines.push(
|
|
28
|
-
`- "${failure.name}"${origin} could not be received: ${failure.reason}`,
|
|
32
|
+
`- "${quotable(failure.name)}"${origin} could not be received: ${failure.reason}`,
|
|
29
33
|
);
|
|
30
34
|
}
|
|
31
35
|
if (lines.length === 0) return text;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isAttachmentName, plainText } from "./attachment-name.ts";
|
|
2
3
|
import { RECORDS } from "./attachment-store.ts";
|
|
3
4
|
|
|
4
5
|
/** A file someone attached to the conversation, as a tool reads it. */
|
|
@@ -31,12 +32,13 @@ export async function openAttachment(
|
|
|
31
32
|
dir: string,
|
|
32
33
|
file: string,
|
|
33
34
|
): Promise<ToolAttachment> {
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const shown = plainText(file);
|
|
36
|
+
if (!isAttachmentName(file))
|
|
37
|
+
throw new AttachmentLookupError(`"${shown}" is not an attachment name`);
|
|
36
38
|
const handle = Bun.file(join(dir, file));
|
|
37
39
|
if (!(await handle.exists()))
|
|
38
40
|
throw new AttachmentLookupError(
|
|
39
|
-
`no attachment named "${
|
|
41
|
+
`no attachment named "${shown}" in this channel`,
|
|
40
42
|
);
|
|
41
43
|
const record = Bun.file(join(dir, RECORDS, `${file}.json`));
|
|
42
44
|
const recorded: Recorded = (await record.exists()) ? await record.json() : {};
|
|
@@ -48,6 +50,13 @@ export async function openAttachment(
|
|
|
48
50
|
? recorded.contentType
|
|
49
51
|
: handle.type,
|
|
50
52
|
size: handle.size,
|
|
51
|
-
|
|
53
|
+
// A file system error names the path on the host; the model sees only the attachment's name.
|
|
54
|
+
bytes: async () => {
|
|
55
|
+
try {
|
|
56
|
+
return new Uint8Array(await handle.arrayBuffer());
|
|
57
|
+
} catch {
|
|
58
|
+
throw new AttachmentLookupError(`"${shown}" could not be read`);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
52
61
|
};
|
|
53
62
|
}
|
|
@@ -30,8 +30,12 @@ export function discordAdminPlugin(
|
|
|
30
30
|
const { connection } = services.get(DISCORD);
|
|
31
31
|
return {
|
|
32
32
|
sessionTools: [
|
|
33
|
-
fixed("discord-admin", () =>
|
|
34
|
-
discordAdminExtension(
|
|
33
|
+
fixed("discord-admin", (session) =>
|
|
34
|
+
discordAdminExtension(
|
|
35
|
+
connection.ownerOperations(),
|
|
36
|
+
owner,
|
|
37
|
+
session.workspace,
|
|
38
|
+
),
|
|
35
39
|
),
|
|
36
40
|
],
|
|
37
41
|
// Administering the server stays with the owner until an operator lowers a tool.
|
|
@@ -67,6 +67,11 @@ export interface DiscordOptions {
|
|
|
67
67
|
channelContext?: ChannelContextOptions | false;
|
|
68
68
|
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
|
|
69
69
|
freshMarker?: string | false;
|
|
70
|
+
/**
|
|
71
|
+
* How long a turn waits on its owner card before it goes on without the answer, leaving the
|
|
72
|
+
* card open; a later answer starts a new turn. Default `CARD_GRACE_MS`, two minutes.
|
|
73
|
+
*/
|
|
74
|
+
cardGraceMs?: number;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/**
|
|
@@ -96,6 +101,10 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
96
101
|
...(identity ? { identity } : {}),
|
|
97
102
|
channel: (channelId) => connected.cardChannel(channelId),
|
|
98
103
|
logger,
|
|
104
|
+
...(options.cardGraceMs === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { graceMs: options.cardGraceMs }),
|
|
107
|
+
resume: (turn) => connected.resume(turn),
|
|
99
108
|
});
|
|
100
109
|
const connected = new DiscordSurface({
|
|
101
110
|
token: options.token,
|
|
@@ -124,6 +133,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
124
133
|
open: (parentId, name, line) =>
|
|
125
134
|
connected.threadHost().open(parentId, name, line),
|
|
126
135
|
post: (threadId, text) => connected.threadHost().post(threadId, text),
|
|
136
|
+
send: (threadId, text) => connected.threadHost().send(threadId, text),
|
|
127
137
|
close: (threadId) => connected.threadHost().close(threadId),
|
|
128
138
|
},
|
|
129
139
|
ledgerPath: join(options.dataDir, "dispatch-threads.json"),
|
|
@@ -50,6 +50,52 @@ export interface DiscordConfig {
|
|
|
50
50
|
* never in a direct message. Default `─── new conversation ───`; `false` posts none.
|
|
51
51
|
*/
|
|
52
52
|
freshMarker?: string | false;
|
|
53
|
+
/**
|
|
54
|
+
* Seconds a turn waits on an owner card (an approval or an `ask_user` question) before it goes
|
|
55
|
+
* on without the answer; the card stays open, and answering it later starts a new turn with
|
|
56
|
+
* the answer. Default 120.
|
|
57
|
+
*/
|
|
58
|
+
cardGraceSeconds?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The Discord settings with every default filled in. */
|
|
62
|
+
export interface ResolvedDiscord {
|
|
63
|
+
token: string;
|
|
64
|
+
guild: string;
|
|
65
|
+
entryChannel: string;
|
|
66
|
+
rootCommand: string;
|
|
67
|
+
admin: boolean;
|
|
68
|
+
refusalHint?: string;
|
|
69
|
+
agentMemory?: "everyone" | "owners";
|
|
70
|
+
/** As configured: `{}` when left out, so the defaults apply, or `false`. */
|
|
71
|
+
channelContext: NonNullable<DiscordConfig["channelContext"]>;
|
|
72
|
+
freshMarker?: string | false;
|
|
73
|
+
cardGraceSeconds: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The configured Discord settings, defaults filled in; `rootCommand` is the host's slug. */
|
|
77
|
+
export function resolvedDiscord(
|
|
78
|
+
discord: DiscordConfig,
|
|
79
|
+
rootCommand: string,
|
|
80
|
+
): ResolvedDiscord {
|
|
81
|
+
return {
|
|
82
|
+
token: discord.token,
|
|
83
|
+
guild: discord.guild,
|
|
84
|
+
entryChannel: discord.entryChannel,
|
|
85
|
+
rootCommand,
|
|
86
|
+
admin: discord.admin ?? true,
|
|
87
|
+
...(discord.refusalHint === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { refusalHint: discord.refusalHint }),
|
|
90
|
+
...(discord.agentMemory === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { agentMemory: discord.agentMemory }),
|
|
93
|
+
channelContext: discord.channelContext ?? {},
|
|
94
|
+
...(discord.freshMarker === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { freshMarker: discord.freshMarker }),
|
|
97
|
+
cardGraceSeconds: discord.cardGraceSeconds ?? 120,
|
|
98
|
+
};
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
/** What `discord.channelContext` takes besides `false`. */
|
|
@@ -72,6 +118,7 @@ export const discordShape = shape({
|
|
|
72
118
|
agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
|
|
73
119
|
channelContext: optional(orOff(channelContextShape)),
|
|
74
120
|
freshMarker: optional(orOff(text)),
|
|
121
|
+
cardGraceSeconds: optional(integer(1, 86_400)),
|
|
75
122
|
});
|
|
76
123
|
|
|
77
124
|
/** The adapters `adapters` takes, by the name their factory gives them. */
|
|
@@ -19,7 +19,13 @@ import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
|
|
|
19
19
|
import type { ChannelKey } from "../sessions.ts";
|
|
20
20
|
import type { Tier, TierMembers } from "../speakers.ts";
|
|
21
21
|
import { type AccessConfig, accessOf, accessShape } from "./access.ts";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
type DiscordConfig,
|
|
24
|
+
discordShape,
|
|
25
|
+
liftAdapters,
|
|
26
|
+
type ResolvedDiscord,
|
|
27
|
+
resolvedDiscord,
|
|
28
|
+
} from "./adapters.ts";
|
|
23
29
|
import {
|
|
24
30
|
bool,
|
|
25
31
|
guarded,
|
|
@@ -297,18 +303,7 @@ export interface ResolvedConfig {
|
|
|
297
303
|
/** What the configuration wrote in a deprecated form, which the host logs once. */
|
|
298
304
|
deprecations: string[];
|
|
299
305
|
/** Undefined on a host without Discord. */
|
|
300
|
-
discord?:
|
|
301
|
-
token: string;
|
|
302
|
-
guild: string;
|
|
303
|
-
entryChannel: string;
|
|
304
|
-
rootCommand: string;
|
|
305
|
-
admin: boolean;
|
|
306
|
-
refusalHint?: string;
|
|
307
|
-
agentMemory?: "everyone" | "owners";
|
|
308
|
-
/** As configured: `{}` when left out, so the defaults apply, or `false`. */
|
|
309
|
-
channelContext: NonNullable<DiscordConfig["channelContext"]>;
|
|
310
|
-
freshMarker?: string | false;
|
|
311
|
-
};
|
|
306
|
+
discord?: ResolvedDiscord;
|
|
312
307
|
databaseUrl: string;
|
|
313
308
|
dataDir: string;
|
|
314
309
|
agentDir: string;
|
|
@@ -423,27 +418,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
423
418
|
name: primaryOwner.name,
|
|
424
419
|
pronouns: primaryOwner.pronouns,
|
|
425
420
|
},
|
|
426
|
-
...(discord
|
|
427
|
-
? {
|
|
428
|
-
discord: {
|
|
429
|
-
token: discord.token,
|
|
430
|
-
guild: discord.guild,
|
|
431
|
-
entryChannel: discord.entryChannel,
|
|
432
|
-
rootCommand: slug,
|
|
433
|
-
admin: discord.admin ?? true,
|
|
434
|
-
...(discord.refusalHint === undefined
|
|
435
|
-
? {}
|
|
436
|
-
: { refusalHint: discord.refusalHint }),
|
|
437
|
-
...(discord.agentMemory === undefined
|
|
438
|
-
? {}
|
|
439
|
-
: { agentMemory: discord.agentMemory }),
|
|
440
|
-
channelContext: discord.channelContext ?? {},
|
|
441
|
-
...(discord.freshMarker === undefined
|
|
442
|
-
? {}
|
|
443
|
-
: { freshMarker: discord.freshMarker }),
|
|
444
|
-
},
|
|
445
|
-
}
|
|
446
|
-
: {}),
|
|
421
|
+
...(discord ? { discord: resolvedDiscord(discord, slug) } : {}),
|
|
447
422
|
databaseUrl: config.database.url,
|
|
448
423
|
dataDir: config.dataDir,
|
|
449
424
|
agentDir: config.agentDir ?? `${config.dataDir}/pi`,
|
|
@@ -19,7 +19,9 @@ export type AttachmentRefusalCode =
|
|
|
19
19
|
/** No saved file of that name is waiting for this person in this conversation. */
|
|
20
20
|
| "unknown_file"
|
|
21
21
|
/** The file is over the core's limit of 25 MiB. */
|
|
22
|
-
| "too_large"
|
|
22
|
+
| "too_large"
|
|
23
|
+
/** Using the files would take the principal past the `usedBytesLimit` the plugin passed. */
|
|
24
|
+
| "quota_exceeded";
|
|
23
25
|
|
|
24
26
|
/** An attachment call the store refused; `code` says why, and the plugin decides what its client sees. */
|
|
25
27
|
export class AttachmentRefusal extends Error {
|
|
@@ -32,6 +34,16 @@ export class AttachmentRefusal extends Error {
|
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/** What a plugin asks of `turnAttachments` besides the files. */
|
|
38
|
+
export interface TurnAttachmentOptions {
|
|
39
|
+
/**
|
|
40
|
+
* The most bytes the principal may have used in turns across all their conversations, these
|
|
41
|
+
* files included. A call that would pass it is refused with `quota_exceeded` and moves nothing.
|
|
42
|
+
* Without it the core sets no limit; deleting a conversation gives its bytes back.
|
|
43
|
+
*/
|
|
44
|
+
usedBytesLimit?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* Files a plugin takes from a person outside a turn, such as an upload over HTTP, kept for the
|
|
37
49
|
* conversation until a turn uses them. A saved file waits for its person: only the principal that
|
|
@@ -40,6 +52,13 @@ export class AttachmentRefusal extends Error {
|
|
|
40
52
|
* conversation's attachment directory, where the turn's `read_attachment` and
|
|
41
53
|
* `ToolTurn.attachment()` find it. Calls reject with `AttachmentRefusal` for a refused call, and
|
|
42
54
|
* throw a PluginError when the host has no `dataDir`.
|
|
55
|
+
*
|
|
56
|
+
* Who may use a channel is the plugin's to check, not the port's. The port refuses another
|
|
57
|
+
* principal only in a conversation the host's registry records as private to someone else; for a
|
|
58
|
+
* channel the registry does not know, or when the host has no registry, it admits every principal.
|
|
59
|
+
* A plugin whose channel key comes from a person must first check that the channel is that
|
|
60
|
+
* person's, as the web chat does before every call; the plugin that owns a channel checks it,
|
|
61
|
+
* because only that plugin knows what the channel is.
|
|
43
62
|
*/
|
|
44
63
|
export interface AttachmentPort {
|
|
45
64
|
/** Keeps a file for the principal in the conversation until a turn uses it. */
|
|
@@ -51,12 +70,14 @@ export interface AttachmentPort {
|
|
|
51
70
|
/**
|
|
52
71
|
* The turn's attachments for files `save` returned: each moves into the conversation, up to four
|
|
53
72
|
* images are prepared for the model, and every file is named in the turn's `## Attachments`
|
|
54
|
-
* block. Refuses the whole set when one file is unknown or already used
|
|
73
|
+
* block. Refuses the whole set when one file is unknown or already used, or when
|
|
74
|
+
* `options.usedBytesLimit` would be passed; none moves then, however many calls run at once.
|
|
55
75
|
*/
|
|
56
76
|
turnAttachments(
|
|
57
77
|
channel: ChannelKey,
|
|
58
78
|
principalId: string,
|
|
59
79
|
files: readonly string[],
|
|
80
|
+
options?: TurnAttachmentOptions,
|
|
60
81
|
): Promise<TurnAttachments>;
|
|
61
82
|
/** Discards a saved file no turn used; false when there is none. */
|
|
62
83
|
remove(
|
|
@@ -172,6 +172,7 @@ function discordAssembly(
|
|
|
172
172
|
...(discord.freshMarker === undefined
|
|
173
173
|
? {}
|
|
174
174
|
: { freshMarker: discord.freshMarker }),
|
|
175
|
+
cardGraceMs: discord.cardGraceSeconds * 1_000,
|
|
175
176
|
}),
|
|
176
177
|
admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
|
|
177
178
|
skills: config.skills
|
|
@@ -274,6 +275,7 @@ export async function defineRoundtable(
|
|
|
274
275
|
assistant: name,
|
|
275
276
|
rootCommand: config.slug,
|
|
276
277
|
agentDir: config.agentDir,
|
|
278
|
+
scratchDir: config.scratchDir,
|
|
277
279
|
},
|
|
278
280
|
database: { url: config.databaseUrl },
|
|
279
281
|
dataDir: config.dataDir,
|
package/src/core/define.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface ToolTurn {
|
|
|
29
29
|
/** The agent whose turn it is. */
|
|
30
30
|
agent: AgentTurnScope | undefined;
|
|
31
31
|
signal: AbortSignal | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The session's shared workspace and scratch dir, where a tool may save a file for the model to
|
|
34
|
+
* pass on by path; undefined for a session without a workspace.
|
|
35
|
+
*/
|
|
36
|
+
workspace?: { workspace: string; scratchDir?: string };
|
|
32
37
|
/** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
|
|
33
38
|
attachFile(file: ReplyFile): void;
|
|
34
39
|
/**
|
|
@@ -124,6 +129,9 @@ export function defineTool<Schema extends TObject>(
|
|
|
124
129
|
channel: context.turnChannel,
|
|
125
130
|
agent: context.agent,
|
|
126
131
|
signal,
|
|
132
|
+
...(context.workspace
|
|
133
|
+
? { workspace: context.workspace }
|
|
134
|
+
: {}),
|
|
127
135
|
attachFile: attachReplyFile,
|
|
128
136
|
attachment: async (file) => {
|
|
129
137
|
if (context.attachmentDir === undefined)
|