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.
Files changed (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +36 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/attachments/attachment-dir.ts +51 -0
  8. package/src/core/attachments/attachment-fetcher.ts +30 -6
  9. package/src/core/attachments/attachment-name.ts +34 -0
  10. package/src/core/attachments/attachment-store.ts +169 -54
  11. package/src/core/attachments/prompt-block.ts +6 -2
  12. package/src/core/attachments/tool-attachment.ts +14 -5
  13. package/src/core/builtin/discord-admin.ts +6 -2
  14. package/src/core/builtin/discord.ts +10 -0
  15. package/src/core/config/adapters.ts +47 -0
  16. package/src/core/config/config.ts +9 -34
  17. package/src/core/contract/attachments.ts +23 -2
  18. package/src/core/define-roundtable.ts +2 -0
  19. package/src/core/define.ts +8 -0
  20. package/src/core/discord/channel-files.ts +107 -0
  21. package/src/core/discord/channel-operations.ts +100 -33
  22. package/src/core/discord/discord-surface.ts +21 -4
  23. package/src/core/discord/dispatch-thread-host.ts +11 -0
  24. package/src/core/discord/dispatch-threads.ts +14 -0
  25. package/src/core/discord/inbound-message.ts +34 -0
  26. package/src/core/discord/owner-card-rows.ts +98 -0
  27. package/src/core/discord/owner-cards-fixture.ts +3 -0
  28. package/src/core/discord/owner-cards.ts +137 -105
  29. package/src/core/host.ts +9 -1
  30. package/src/core/i18n/cards.ts +6 -8
  31. package/src/core/i18n/conversation.ts +15 -0
  32. package/src/core/interactions/prompts.ts +36 -7
  33. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  34. package/src/core/plugin.ts +5 -0
  35. package/src/core/runtime/conversation-sessions.ts +2 -0
  36. package/src/core/runtime/extensions/ask-user.ts +21 -4
  37. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  38. package/src/core/runtime/pi-agent-runtime.ts +5 -1
  39. package/src/core/runtime/prompt-slot.ts +6 -4
  40. package/src/core/runtime/runtime-types.ts +5 -0
  41. package/src/core/runtime/session-factory.ts +17 -0
  42. package/src/core/sessions.ts +6 -0
  43. package/src/core/shared/attach-file-tool.ts +72 -0
  44. package/src/core/shared/attachment-reader.ts +11 -4
  45. package/src/core/shared/workspace-files.ts +76 -0
  46. package/src/core/testing/thread-host.ts +14 -0
  47. package/src/index.ts +3 -0
  48. package/src/kit/index.ts +2 -0
  49. package/src/kit/worker.ts +2 -0
  50. 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 { readdir, rename, rm, stat } from "node:fs/promises";
4
- import { basename, join } from "node:path";
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 { channelSegment, ownerAttachmentDir } from "./attachment-dir.ts";
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
- /** Whether `file` could be a name this store gave out: one path segment that is not hidden. */
50
- function isFileName(file: string): boolean {
51
- return (
52
- file !== "" &&
53
- basename(file) === file &&
54
- !file.startsWith(".") &&
55
- !file.includes("\0")
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
- async function listDir(dir: string): Promise<string[]> {
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
- return await readdir(dir);
71
+ await writeFile(temporary, String(bytes));
62
72
  } catch (error) {
63
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
64
- throw error;
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
- await Bun.write(path, upload.data);
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 Bun.write(join(dir, RECORDS, `${file}.json`), JSON.stringify(record));
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(from)).size,
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
- for (const stored of found) {
160
- try {
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
- } catch (error) {
163
- // Another turn used the file between the check and the move.
164
- if ((error as NodeJS.ErrnoException).code === "ENOENT")
165
- throw new AttachmentRefusal(
166
- "unknown_file",
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
- const { images, failures } = await modelImagesOf(
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
- if (!(await this.#recordOf(staging, file))) return false;
191
- await rm(join(staging, file), { force: true });
192
- await rm(join(staging, RECORDS, `${file}.json`), { force: true });
193
- return true;
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
- for (const channel of await listDir(join(root, principal))) {
201
- const dir = join(root, principal, channel);
202
- for (const entry of await listDir(dir)) {
203
- if (entry === RECORDS) continue;
204
- const info = await stat(join(dir, entry)).catch(() => undefined);
205
- if (!info || info.mtimeMs >= olderThan.getTime()) continue;
206
- await rm(join(dir, entry), { force: true });
207
- await rm(join(dir, RECORDS, `${entry}.json`), { force: true });
208
- discarded += 1;
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 (!isFileName(file)) return undefined;
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 { basename, join } from "node:path";
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
- if (file === "" || basename(file) !== file || file.startsWith("."))
35
- throw new AttachmentLookupError(`"${file}" is not an attachment name`);
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 "${file}" in this channel`,
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
- bytes: async () => new Uint8Array(await handle.arrayBuffer()),
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(connection.ownerOperations(), owner),
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 { type DiscordConfig, discordShape, liftAdapters } from "./adapters.ts";
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; none moves then.
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,
@@ -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)