pi-roundtable 0.9.1 → 0.9.2

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 CHANGED
@@ -5,6 +5,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.2] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - `context.attachments` (`AttachmentPort`, with `AttachmentUpload`, `AttachmentRefusal` and `AttachmentRefusalCode`): a plugin keeps a file a person uploaded outside a turn until a turn uses it. `save` stages the file for its principal in a conversation, `turnAttachments` turns the named files into the turn's `TurnAttachments` (they move into the conversation's attachment directory, so `read_attachment` and the `## Attachments` block see them, and up to four images reach the model), `remove` and `discardPending` drop files no turn used, and `pendingBytes` counts what a principal has waiting. A staged file is usable only by the principal that saved it, in the conversation it was saved for; a conversation recorded private refuses every other principal (`AttachmentRefusal` with code `forbidden`). It needs the host's `dataDir`, a new `RoundtableOptions` field that `defineRoundtable` sets, and throws a `PluginError` without it.
13
+ - `ToolTurn.attachment(file)` (`ToolAttachment`): a tool opens a file attached to the conversation, by the name in the turn's `## Attachments` block, and reads its bytes with `bytes()` or passes it on. A path or a hidden name is refused with a `ToolRefusal`. `SessionContext.attachmentDir` names the directory it reads.
14
+
8
15
  ## [0.9.1] - 2026-10-10
9
16
 
10
17
  ### Fixed
package/docs/plugins.md CHANGED
@@ -135,6 +135,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
135
135
  | `apiKey(provider)` | The credential the host's model login holds for a provider such as `openai-codex`, the same login the agents use. It resolves to `undefined` when the host has none for that provider and never throws for that. The value is a secret: keep it out of logs and error messages. Read it when you use it rather than keeping it, since a login may refresh its token |
136
136
  | `queue` | The one channel queue that every conversation and channel operation shares |
137
137
  | `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
138
+ | `attachments` | Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation until a turn uses them: [`attachments`](#contextattachments-files-from-outside-a-turn) |
138
139
  | `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
139
140
  | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
140
141
  | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
@@ -528,11 +529,35 @@ A plugin that adds raw session tools names their tiers with `toolTiers`; the cor
528
529
  Each heading below is a key `setup` may return.
529
530
  A key the contract does not have stops the start and names the closest one.
530
531
 
532
+ ### `context.attachments`: files from outside a turn
533
+
534
+ A surface that takes a file as it arrives, such as Discord, hands the core a link, and the core downloads it into the conversation's attachment directory.
535
+ A plugin whose clients upload a file themselves, over HTTP for instance, has the file before any turn exists.
536
+ `context.attachments` keeps such a file for the conversation until a turn uses it:
537
+
538
+ | Call | What it does |
539
+ |---|---|
540
+ | `save(channel, principalId, { name, contentType, data })` | Keeps the file for that principal in that conversation and returns a `StoredAttachment`; its `file` is the name to hand back to the client. Refuses a file over 25 MiB (`too_large`) |
541
+ | `turnAttachments(channel, principalId, files)` | Takes the `file` names `save` returned and returns the turn's `TurnAttachments`; pass it as `attachments` to [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own). Each file moves into the conversation's attachment directory, up to four images are prepared for the model, and the turn's prompt lists every file under `## Attachments`. Refuses the whole set when one name is unknown or already used (`unknown_file`); then none moves |
542
+ | `remove(channel, principalId, file)` | Discards a saved file no turn used; `false` when there is none |
543
+ | `discardPending(olderThan)` | Discards every saved file no turn used that was saved before the date, and returns how many; call it from a service's timer so abandoned uploads do not pile up |
544
+ | `pendingBytes(principalId)` | The bytes the principal has saved that no turn used yet, for a quota of your own |
545
+
546
+ A saved file waits for its owner: only the principal that saved it can use it, and only in the conversation it was saved for.
547
+ The conversation's record decides who may save into it: a private conversation refuses every principal but its own with an `AttachmentRefusal` whose `code` is `forbidden`, and a shared one lets each principal save and use their own files.
548
+ A channel with no record yet, such as a conversation opened but not written in, is not checked; the plugin that owns the channel checks it, as the web chat does before it saves.
549
+ The calls reject with `AttachmentRefusal`, and throw a `PluginError` when the host has no `dataDir` (a host built by `defineRoundtable` always has one).
550
+ The core limits only the size; the types a plugin accepts, the number of files, and the rate are its own to limit.
551
+
552
+ Once a turn has used a file, the model reads it with `read_attachment`, and a tool reads it with `turn.attachment(file)` (see [`tools`](#tools-what-agents-can-call)).
553
+
531
554
  ### `tools`: what agents can call
532
555
 
533
556
  `defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
534
- `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, and `attachFile`.
557
+ `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `attachFile`, and `attachment`.
535
558
  It returns the text the model reads.
559
+ `turn.attachment(file)` opens a file someone attached to the conversation, by the name the turn's `## Attachments` block lists, and returns `{ file, name, contentType, size, bytes() }`: a tool passes a user's file on without the model carrying its bytes.
560
+ Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
536
561
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
537
562
 
538
563
  <!-- example: examples/tools.ts -->
@@ -2709,12 +2734,14 @@ The package README documents every frame, error code, close code, and limit.
2709
2734
  The verifier is generic; what makes it safe is how it is pointed at your provider.
2710
2735
  The README's [provider settings][webchat-provider-settings] explain, with Microsoft Entra ID as the example, why to name people by a claim that stays the same across app registrations (`subjectClaim`), pin the tenant in `check`, accept access tokens only, refuse app-only tokens, merge issuers of one tenant only (`speakerIssuer`), and mind the guests `everyone` admits.
2711
2736
 
2712
- The web chat takes no attachments or system error reports (`takesSystemReports: false`), so `ops.conversation` cannot name it.
2737
+ The web chat takes files people upload to their conversation and then name in a message (see the README's [attachments][webchat-attachments]), built on [`context.attachments`](#contextattachments-files-from-outside-a-turn).
2738
+ It takes no system error reports (`takesSystemReports: false`), so `ops.conversation` cannot name it.
2713
2739
  It accepts `PERSONAL_TARGET` background turns only for the private conversation's principal; include `schedule_*`, `delegate_task`, and `notify` in a persona's selection only deliberately, adjusting their tiers if needed.
2714
2740
  Leave out `web_search` and `fetch_content` unless people may make the host fetch any address, internal ones included.
2715
2741
 
2716
2742
  [webchat-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md
2717
2743
  [webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
2744
+ [webchat-attachments]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#attachments
2718
2745
 
2719
2746
  ### What plugins do not extend
2720
2747
 
@@ -3219,7 +3246,11 @@ Import from the entries listed below; source area files are internal.
3219
3246
  | `Approval` | `pi-roundtable` | type |
3220
3247
  | `AskOption` | `pi-roundtable` | type |
3221
3248
  | `AttachmentFailure` | `pi-roundtable` | type |
3249
+ | `AttachmentPort` | `pi-roundtable` | type |
3222
3250
  | `AttachmentRef` | `pi-roundtable` | type |
3251
+ | `AttachmentRefusal` | `pi-roundtable` | value |
3252
+ | `AttachmentRefusalCode` | `pi-roundtable` | type |
3253
+ | `AttachmentUpload` | `pi-roundtable` | type |
3223
3254
  | `AvatarMode` | `pi-roundtable` | type |
3224
3255
  | `AvatarStudio` | `pi-roundtable` | type |
3225
3256
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
@@ -3388,6 +3419,7 @@ Import from the entries listed below; source area files are internal.
3388
3419
  | `ThinkingSetting` | `pi-roundtable` | type |
3389
3420
  | `Tier` | `pi-roundtable` | type |
3390
3421
  | `TierConfig` | `pi-roundtable` | type |
3422
+ | `ToolAttachment` | `pi-roundtable` | type |
3391
3423
  | `ToolContribution` | `pi-roundtable` | type |
3392
3424
  | `ToolRefusal` | `pi-roundtable` | value |
3393
3425
  | `ToolSelection` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,34 @@
1
+ import type { AttachmentPort } from "../contract/attachments.ts";
2
+ import { PluginError } from "../errors.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import {
5
+ AttachmentStore,
6
+ type AttachmentStoreOptions,
7
+ } from "./attachment-store.ts";
8
+
9
+ /**
10
+ * The attachment port a plugin gets as `context.attachments`: over the host's `dataDir`, or, when
11
+ * the host has none, a port whose every call throws a PluginError naming the option to set.
12
+ */
13
+ export function attachmentPort(options: {
14
+ dataDir: string | undefined;
15
+ registry: AttachmentStoreOptions["registry"];
16
+ logger: Logger;
17
+ }): AttachmentPort {
18
+ const { dataDir, registry, logger } = options;
19
+ if (dataDir === undefined) {
20
+ const refuse = (): never => {
21
+ throw new PluginError(
22
+ "no dataDir is configured, so attachments cannot be kept. Set dataDir in the configuration, or the dataDir option of the host.",
23
+ );
24
+ };
25
+ return {
26
+ save: refuse,
27
+ turnAttachments: refuse,
28
+ remove: refuse,
29
+ discardPending: refuse,
30
+ pendingBytes: refuse,
31
+ };
32
+ }
33
+ return new AttachmentStore({ dataDir, registry, logger });
34
+ }
@@ -0,0 +1,248 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync } from "node:fs";
3
+ import { readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
5
+ import {
6
+ type AttachmentPort,
7
+ AttachmentRefusal,
8
+ type AttachmentUpload,
9
+ } from "../contract/attachments.ts";
10
+ import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
11
+ import type {
12
+ StoredAttachment,
13
+ TurnAttachments,
14
+ } from "../domain/attachment.ts";
15
+ import type { Logger } from "../log.ts";
16
+ import type { ChannelKey } from "../sessions.ts";
17
+ import { channelSegment, ownerAttachmentDir } from "./attachment-dir.ts";
18
+ import { MAX_ATTACHMENT_BYTES, safeFileName } from "./attachment-fetcher.ts";
19
+ import { modelImagesOf } from "./model-images.ts";
20
+
21
+ /** Where saved files wait, beside the conversations' attachment directories. */
22
+ const STAGING = "attachments-pending";
23
+ /** The record of a saved file, in a directory next to it that no file name can be. */
24
+ export const RECORDS = ".records";
25
+
26
+ export interface AttachmentStoreOptions {
27
+ dataDir: string;
28
+ /** Read when used, once the host linked its services; undefined when no registry records conversations. */
29
+ registry(): Pick<ConversationRegistry, "get"> | undefined;
30
+ logger: Logger;
31
+ }
32
+
33
+ interface StagedRecord {
34
+ name: string;
35
+ contentType: string;
36
+ }
37
+
38
+ const isRecord = (value: unknown): value is StagedRecord =>
39
+ typeof value === "object" &&
40
+ value !== null &&
41
+ typeof (value as StagedRecord).name === "string" &&
42
+ typeof (value as StagedRecord).contentType === "string";
43
+
44
+ /** A principal id as one path segment; hashed, so two ids never share a directory. */
45
+ function principalSegment(principalId: string): string {
46
+ return createHash("sha256").update(principalId).digest("hex").slice(0, 32);
47
+ }
48
+
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
+ );
57
+ }
58
+
59
+ async function listDir(dir: string): Promise<string[]> {
60
+ try {
61
+ return await readdir(dir);
62
+ } catch (error) {
63
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The files plugins take from people outside a turn. A saved file is staged under its principal and
70
+ * its conversation until a turn uses it, then moved into the conversation's attachment directory,
71
+ * the one `read_attachment` reads. The conversation's record decides who may stage: a private
72
+ * conversation takes only its principal's files.
73
+ */
74
+ export class AttachmentStore implements AttachmentPort {
75
+ readonly #options: AttachmentStoreOptions;
76
+
77
+ constructor(options: AttachmentStoreOptions) {
78
+ this.#options = options;
79
+ }
80
+
81
+ #stagingDir(channel: ChannelKey, principalId: string): string {
82
+ return join(
83
+ this.#options.dataDir,
84
+ STAGING,
85
+ principalSegment(principalId),
86
+ channelSegment(channel),
87
+ );
88
+ }
89
+
90
+ /** Refuses a principal other than the one a private conversation belongs to. */
91
+ async #admit(channel: ChannelKey, principalId: string): Promise<void> {
92
+ const record = await this.#options.registry()?.get(channel);
93
+ if (record?.visibility === "private" && record.principalId !== principalId)
94
+ throw new AttachmentRefusal(
95
+ "forbidden",
96
+ `${channel} is private to someone else, so its files cannot be saved or used by ${principalId}`,
97
+ );
98
+ }
99
+
100
+ async save(
101
+ channel: ChannelKey,
102
+ principalId: string,
103
+ upload: AttachmentUpload,
104
+ ): Promise<StoredAttachment> {
105
+ await this.#admit(channel, principalId);
106
+ const size = upload.data.byteLength;
107
+ if (size > MAX_ATTACHMENT_BYTES)
108
+ throw new AttachmentRefusal(
109
+ "too_large",
110
+ `a file may be at most 25 MiB; "${upload.name}" is ${size} bytes`,
111
+ );
112
+ const dir = this.#stagingDir(channel, principalId);
113
+ mkdirSync(join(dir, RECORDS), { recursive: true, mode: 0o700 });
114
+ const file = `${crypto.randomUUID()}-${safeFileName(upload.name)}`;
115
+ const path = join(dir, file);
116
+ await Bun.write(path, upload.data);
117
+ const record: StagedRecord = {
118
+ name: upload.name,
119
+ contentType: upload.contentType,
120
+ };
121
+ await Bun.write(join(dir, RECORDS, `${file}.json`), JSON.stringify(record));
122
+ return {
123
+ name: upload.name,
124
+ file,
125
+ path,
126
+ contentType: upload.contentType,
127
+ size,
128
+ fromReference: false,
129
+ };
130
+ }
131
+
132
+ async turnAttachments(
133
+ channel: ChannelKey,
134
+ principalId: string,
135
+ files: readonly string[],
136
+ ): Promise<TurnAttachments> {
137
+ await this.#admit(channel, principalId);
138
+ const staging = this.#stagingDir(channel, principalId);
139
+ const target = ownerAttachmentDir(this.#options.dataDir, channel);
140
+ const found: StoredAttachment[] = [];
141
+ for (const file of new Set(files)) {
142
+ const record = await this.#recordOf(staging, file);
143
+ if (!record)
144
+ throw new AttachmentRefusal(
145
+ "unknown_file",
146
+ `no saved file "${file}" is waiting for this person in ${channel}`,
147
+ );
148
+ const from = join(staging, file);
149
+ found.push({
150
+ name: record.name,
151
+ file,
152
+ path: join(target, file),
153
+ contentType: record.contentType,
154
+ size: (await stat(from)).size,
155
+ fromReference: false,
156
+ });
157
+ }
158
+ mkdirSync(join(target, RECORDS), { recursive: true, mode: 0o700 });
159
+ for (const stored of found) {
160
+ try {
161
+ 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;
170
+ }
171
+ await rename(
172
+ join(staging, RECORDS, `${stored.file}.json`),
173
+ join(target, RECORDS, `${stored.file}.json`),
174
+ );
175
+ }
176
+ const { images, failures } = await modelImagesOf(
177
+ found,
178
+ this.#options.logger,
179
+ );
180
+ return { files: found, images, failures };
181
+ }
182
+
183
+ async remove(
184
+ channel: ChannelKey,
185
+ principalId: string,
186
+ file: string,
187
+ ): Promise<boolean> {
188
+ await this.#admit(channel, principalId);
189
+ 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;
194
+ }
195
+
196
+ async discardPending(olderThan: Date): Promise<number> {
197
+ const root = join(this.#options.dataDir, STAGING);
198
+ let discarded = 0;
199
+ 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;
209
+ }
210
+ }
211
+ }
212
+ return discarded;
213
+ }
214
+
215
+ async pendingBytes(principalId: string): Promise<number> {
216
+ const root = join(
217
+ this.#options.dataDir,
218
+ STAGING,
219
+ principalSegment(principalId),
220
+ );
221
+ let total = 0;
222
+ for (const channel of await listDir(root)) {
223
+ const dir = join(root, channel);
224
+ for (const entry of await listDir(dir)) {
225
+ if (entry === RECORDS) continue;
226
+ total +=
227
+ (await stat(join(dir, entry)).catch(() => undefined))?.size ?? 0;
228
+ }
229
+ }
230
+ return total;
231
+ }
232
+
233
+ /** The record of a staged file, or undefined when it is not staged here. */
234
+ async #recordOf(
235
+ staging: string,
236
+ file: string,
237
+ ): Promise<StagedRecord | undefined> {
238
+ if (!isFileName(file)) return undefined;
239
+ const stored = Bun.file(join(staging, RECORDS, `${file}.json`));
240
+ if (
241
+ !(await stored.exists()) ||
242
+ !(await Bun.file(join(staging, file)).exists())
243
+ )
244
+ return undefined;
245
+ const value: unknown = await stored.json();
246
+ return isRecord(value) ? value : undefined;
247
+ }
248
+ }
@@ -0,0 +1,39 @@
1
+ import type {
2
+ AttachmentFailure,
3
+ ModelImage,
4
+ StoredAttachment,
5
+ } from "../domain/attachment.ts";
6
+ import type { Logger } from "../log.ts";
7
+ import {
8
+ isModelImage,
9
+ MAX_IMAGES_PER_TURN,
10
+ prepareImage,
11
+ } from "./image-prep.ts";
12
+
13
+ /**
14
+ * The first four images among the files, prepared as the model accepts them. An image that cannot
15
+ * be decoded is reported as a failure and the turn goes on without it.
16
+ */
17
+ export async function modelImagesOf(
18
+ files: readonly StoredAttachment[],
19
+ logger: Logger,
20
+ ): Promise<{ images: ModelImage[]; failures: AttachmentFailure[] }> {
21
+ const images: ModelImage[] = [];
22
+ const failures: AttachmentFailure[] = [];
23
+ for (const file of files.filter(isModelImage).slice(0, MAX_IMAGES_PER_TURN)) {
24
+ try {
25
+ images.push(await prepareImage(file));
26
+ } catch (error) {
27
+ logger.warn(
28
+ { file: file.file, err: error },
29
+ "image could not be prepared",
30
+ );
31
+ failures.push({
32
+ name: file.name,
33
+ reason: "the image could not be decoded",
34
+ fromReference: file.fromReference,
35
+ });
36
+ }
37
+ }
38
+ return { images, failures };
39
+ }
@@ -0,0 +1,53 @@
1
+ import { basename, join } from "node:path";
2
+ import { RECORDS } from "./attachment-store.ts";
3
+
4
+ /** A file someone attached to the conversation, as a tool reads it. */
5
+ export interface ToolAttachment {
6
+ /** The file name inside the conversation's attachment store; what `## Attachments` lists. */
7
+ file: string;
8
+ /** The name the person gave it, when the core recorded one; else the file name. */
9
+ name: string;
10
+ contentType: string;
11
+ size: number;
12
+ /** The file's whole content. */
13
+ bytes(): Promise<Uint8Array>;
14
+ }
15
+
16
+ /** A name that is no attachment of the conversation, or a file that is not there. */
17
+ export class AttachmentLookupError extends Error {
18
+ override name = "AttachmentLookupError";
19
+ }
20
+
21
+ interface Recorded {
22
+ name?: unknown;
23
+ contentType?: unknown;
24
+ }
25
+
26
+ /**
27
+ * Opens one attachment of a conversation's store for a tool. `file` must be a name in `dir`, as
28
+ * `read_attachment` takes it: a path or a hidden name is refused.
29
+ */
30
+ export async function openAttachment(
31
+ dir: string,
32
+ file: string,
33
+ ): Promise<ToolAttachment> {
34
+ if (file === "" || basename(file) !== file || file.startsWith("."))
35
+ throw new AttachmentLookupError(`"${file}" is not an attachment name`);
36
+ const handle = Bun.file(join(dir, file));
37
+ if (!(await handle.exists()))
38
+ throw new AttachmentLookupError(
39
+ `no attachment named "${file}" in this channel`,
40
+ );
41
+ const record = Bun.file(join(dir, RECORDS, `${file}.json`));
42
+ const recorded: Recorded = (await record.exists()) ? await record.json() : {};
43
+ return {
44
+ file,
45
+ name: typeof recorded.name === "string" ? recorded.name : file,
46
+ contentType:
47
+ typeof recorded.contentType === "string"
48
+ ? recorded.contentType
49
+ : handle.type,
50
+ size: handle.size,
51
+ bytes: async () => new Uint8Array(await handle.arrayBuffer()),
52
+ };
53
+ }
@@ -2,11 +2,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
2
2
  import type { InboundMessage } from "../domain/conversation.ts";
3
3
  import type { Logger } from "../log.ts";
4
4
  import { fetchAttachments } from "./attachment-fetcher.ts";
5
- import {
6
- isModelImage,
7
- MAX_IMAGES_PER_TURN,
8
- prepareImage,
9
- } from "./image-prep.ts";
5
+ import { modelImagesOf } from "./model-images.ts";
10
6
 
11
7
  /** Saves the message's and its reference's files, and turns up to four images into model images. */
12
8
  export async function collectAttachments(
@@ -35,22 +31,9 @@ export async function collectAttachments(
35
31
  );
36
32
  const files = [...own.files, ...referenced.files];
37
33
  const failures = [...own.failures, ...referenced.failures];
38
- const images = [];
39
- for (const file of files.filter(isModelImage).slice(0, MAX_IMAGES_PER_TURN)) {
40
- try {
41
- images.push(await prepareImage(file));
42
- } catch (error) {
43
- logger.warn(
44
- { file: file.file, err: error },
45
- "image could not be prepared",
46
- );
47
- failures.push({
48
- name: file.name,
49
- reason: "the image could not be decoded",
50
- fromReference: file.fromReference,
51
- });
52
- }
53
- }
34
+ const prepared = await modelImagesOf(files, logger);
35
+ const images = prepared.images;
36
+ failures.push(...prepared.failures);
54
37
  if (files.length > 0 || failures.length > 0) {
55
38
  logger.info(
56
39
  {
@@ -0,0 +1,71 @@
1
+ import type {
2
+ StoredAttachment,
3
+ TurnAttachments,
4
+ } from "../domain/attachment.ts";
5
+ import type { ChannelKey } from "../sessions.ts";
6
+
7
+ /** A file a plugin hands the core to keep for a conversation. */
8
+ export interface AttachmentUpload {
9
+ /** The name the person gave it; shown to the model and kept as is. */
10
+ name: string;
11
+ contentType: string;
12
+ data: Uint8Array;
13
+ }
14
+
15
+ /** Why the attachment store refused a call. */
16
+ export type AttachmentRefusalCode =
17
+ /** The conversation is private to someone else. */
18
+ | "forbidden"
19
+ /** No saved file of that name is waiting for this person in this conversation. */
20
+ | "unknown_file"
21
+ /** The file is over the core's limit of 25 MiB. */
22
+ | "too_large";
23
+
24
+ /** An attachment call the store refused; `code` says why, and the plugin decides what its client sees. */
25
+ export class AttachmentRefusal extends Error {
26
+ override name = "AttachmentRefusal";
27
+ readonly code: AttachmentRefusalCode;
28
+
29
+ constructor(code: AttachmentRefusalCode, message: string) {
30
+ super(message);
31
+ this.code = code;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Files a plugin takes from a person outside a turn, such as an upload over HTTP, kept for the
37
+ * conversation until a turn uses them. A saved file waits for its person: only the principal that
38
+ * saved it may use it, in the conversation it was saved for. A private conversation refuses every
39
+ * other principal; a shared one lets each use their own. Using a file moves it into the
40
+ * conversation's attachment directory, where the turn's `read_attachment` and
41
+ * `ToolTurn.attachment()` find it. Calls reject with `AttachmentRefusal` for a refused call, and
42
+ * throw a PluginError when the host has no `dataDir`.
43
+ */
44
+ export interface AttachmentPort {
45
+ /** Keeps a file for the principal in the conversation until a turn uses it. */
46
+ save(
47
+ channel: ChannelKey,
48
+ principalId: string,
49
+ upload: AttachmentUpload,
50
+ ): Promise<StoredAttachment>;
51
+ /**
52
+ * The turn's attachments for files `save` returned: each moves into the conversation, up to four
53
+ * 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.
55
+ */
56
+ turnAttachments(
57
+ channel: ChannelKey,
58
+ principalId: string,
59
+ files: readonly string[],
60
+ ): Promise<TurnAttachments>;
61
+ /** Discards a saved file no turn used; false when there is none. */
62
+ remove(
63
+ channel: ChannelKey,
64
+ principalId: string,
65
+ file: string,
66
+ ): Promise<boolean>;
67
+ /** Discards every saved file no turn used that was saved before `olderThan`; returns how many. */
68
+ discardPending(olderThan: Date): Promise<number>;
69
+ /** The bytes the principal has saved that no turn used yet, across their conversations. */
70
+ pendingBytes(principalId: string): Promise<number>;
71
+ }
@@ -276,6 +276,7 @@ export async function defineRoundtable(
276
276
  agentDir: config.agentDir,
277
277
  },
278
278
  database: { url: config.databaseUrl },
279
+ dataDir: config.dataDir,
279
280
  toolTiers: toolTiers(config.toolTiers, logger),
280
281
  listeners: [
281
282
  ...(config.http ? [publicListener(config.http)] : []),
@@ -1,4 +1,9 @@
1
1
  import type { Static, TObject } from "typebox";
2
+ import {
3
+ AttachmentLookupError,
4
+ openAttachment,
5
+ type ToolAttachment,
6
+ } from "./attachments/tool-attachment.ts";
2
7
  import type { ReplyFile } from "./domain/conversation.ts";
3
8
  import { PluginError } from "./errors.ts";
4
9
  import type { HoldRule } from "./holds.ts";
@@ -26,6 +31,13 @@ export interface ToolTurn {
26
31
  signal: AbortSignal | undefined;
27
32
  /** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
28
33
  attachFile(file: ReplyFile): void;
34
+ /**
35
+ * Opens a file someone attached to this conversation, by the name `## Attachments` lists, to read
36
+ * its bytes or pass it on. Only this conversation's files open; a path, a hidden name, or a file
37
+ * that is not there throws a ToolRefusal the model reads. A session without an attachment store
38
+ * refuses every call.
39
+ */
40
+ attachment(file: string): Promise<ToolAttachment>;
29
41
  }
30
42
 
31
43
  /**
@@ -113,6 +125,19 @@ export function defineTool<Schema extends TObject>(
113
125
  agent: context.agent,
114
126
  signal,
115
127
  attachFile: attachReplyFile,
128
+ attachment: async (file) => {
129
+ if (context.attachmentDir === undefined)
130
+ throw new ToolRefusal(
131
+ "this session keeps no attachments",
132
+ );
133
+ try {
134
+ return await openAttachment(context.attachmentDir, file);
135
+ } catch (error) {
136
+ if (error instanceof AttachmentLookupError)
137
+ throw new ToolRefusal(error.message);
138
+ throw error;
139
+ }
140
+ },
116
141
  }),
117
142
  );
118
143
  } catch (error) {
package/src/core/host.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import { attachmentPort } from "./attachments/attachment-port.ts";
2
3
  import type { ConversationPort } from "./contract/channels.ts";
3
4
  import type { SurfacePort } from "./contract/surface.ts";
4
5
  import { openPool, runMigrations } from "./db/migrations.ts";
@@ -77,6 +78,8 @@ export interface RoundtableOptions {
77
78
  toolTiers?: ToolTierTable;
78
79
  /** The database the plugins' migrations and stores use; the host owns its one pool. */
79
80
  database?: { url: string };
81
+ /** The directory the host keeps files in; `context.attachments` keeps its files there. Without it that port refuses every call. */
82
+ dataDir?: string;
80
83
  /** Receives the work a shutdown drain gave up on, before any service stops. */
81
84
  aborted?: (left: string[]) => Promise<void>;
82
85
  /**
@@ -276,6 +279,11 @@ export class Roundtable {
276
279
  toolTiers: this.#tiers,
277
280
  events: this.#events.sink,
278
281
  conversations: this.#conversations(),
282
+ attachments: attachmentPort({
283
+ dataDir: this.#options.dataDir,
284
+ registry: () => this.#services?.find(CONVERSATIONS),
285
+ logger,
286
+ }),
279
287
  surfaces: this.#surfaces(),
280
288
  turns: this.#turns(),
281
289
  database: () => {
@@ -1,5 +1,6 @@
1
1
  import type { SQL } from "bun";
2
2
  import type { AgentSeed } from "./agents/agent-store.ts";
3
+ import type { AttachmentPort } from "./contract/attachments.ts";
3
4
  import type {
4
5
  BackgroundTarget,
5
6
  ChannelClaim,
@@ -287,6 +288,11 @@ export interface PluginContext {
287
288
  events: EventSink;
288
289
  /** The claimed channels' conversations; calls during setup throw NotLinkedError. */
289
290
  conversations: ConversationPort;
291
+ /**
292
+ * Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation
293
+ * until a turn uses them; a call throws PluginError when the host has no `dataDir`.
294
+ */
295
+ attachments: AttachmentPort;
290
296
  /** Every contributed chat surface, chosen by a channel's prefix; calls during setup throw NotLinkedError. */
291
297
  surfaces: SurfacePort;
292
298
  /** Every plugin's direct channels, which reach a person on their own; calls during setup throw NotLinkedError. */
@@ -332,6 +332,7 @@ export class SessionFactory {
332
332
  },
333
333
  conversation,
334
334
  addressee,
335
+ attachmentDir,
335
336
  get memory() {
336
337
  return state.memory;
337
338
  },
@@ -83,6 +83,8 @@ export interface SessionContext {
83
83
  * it reads `"none"` for that turn alone.
84
84
  */
85
85
  memory: "speaker" | "none";
86
+ /** Where the session's conversation keeps the files people attached; absent when the session keeps none. */
87
+ attachmentDir?: string;
86
88
  /** The person the session's running turn is for; undefined between turns. */
87
89
  speaker(): Speaker | undefined;
88
90
  /** Runs a task beside this session, under its confirmation gate. */
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export type {
16
16
  GroupStatus,
17
17
  TeamStatus,
18
18
  } from "./core/agents/team-status.ts";
19
+ export type { ToolAttachment } from "./core/attachments/tool-attachment.ts";
19
20
  export {
20
21
  AGENT_SERVER_PLUGIN,
21
22
  AGENT_TEAM_SERVICE,
@@ -33,6 +34,12 @@ export type {
33
34
  RoundtableConfig,
34
35
  TierConfig,
35
36
  } from "./core/config/config.ts";
37
+ export type {
38
+ AttachmentPort,
39
+ AttachmentRefusalCode,
40
+ AttachmentUpload,
41
+ } from "./core/contract/attachments.ts";
42
+ export { AttachmentRefusal } from "./core/contract/attachments.ts";
36
43
  export type {
37
44
  Admission,
38
45
  AttachmentRef,
package/src/testing.ts CHANGED
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import type { SQL } from "bun";
6
6
  import { agentClaim } from "./core/agents/agent-claim.ts";
7
+ import { ownerAttachmentDir } from "./core/attachments/attachment-dir.ts";
8
+ import { attachmentPort } from "./core/attachments/attachment-port.ts";
7
9
  import { DISCORD, type DiscordServices } from "./core/builtin/discord.ts";
8
10
  import type {
9
11
  ConversationPort,
@@ -486,6 +488,7 @@ export async function testPlugin(
486
488
  }),
487
489
  );
488
490
  services.checkRequires();
491
+ const attachmentDir = mkdtempSync(join(tmpdir(), "roundtable-test-plugin-"));
489
492
  const context: Omit<PluginContext, "services" | "directChannels"> = {
490
493
  logger,
491
494
  env,
@@ -494,6 +497,11 @@ export async function testPlugin(
494
497
  toolTiers: tiers,
495
498
  events: sink,
496
499
  conversations,
500
+ attachments: attachmentPort({
501
+ dataDir: attachmentDir,
502
+ registry: () => services.find(CONVERSATIONS),
503
+ logger,
504
+ }),
497
505
  surfaces,
498
506
  turns,
499
507
  database: () => {
@@ -518,7 +526,6 @@ export async function testPlugin(
518
526
  // With a team given, the agent server's claim and its target are in the router, as on a host.
519
527
  const team = given.get(AGENTS.id)?.given;
520
528
  const agentServer = team && "team" in team ? services.get(AGENTS) : undefined;
521
- const attachmentDir = mkdtempSync(join(tmpdir(), "roundtable-test-plugin-"));
522
529
  const identity = services.find(IDENTITY);
523
530
  // Who wrote a message, and whom a background turn runs as: by the given IDENTITY, else the owner alone, at the owner tier.
524
531
  const people = mapIdentity({ owners: [owner.id] });
@@ -625,6 +632,7 @@ export async function testPlugin(
625
632
  conversation: { visibility: "shared" },
626
633
  addressee: THE_SPEAKER,
627
634
  memory: "speaker",
635
+ attachmentDir: ownerAttachmentDir(attachmentDir, channel),
628
636
  speaker: () => runOptions?.speaker,
629
637
  runTask: async () => {
630
638
  throw new Error("test session cannot run tasks");