pi-roundtable 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/plugins.md +37 -2
- package/package.json +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-port.ts +34 -0
- package/src/core/attachments/attachment-store.ts +363 -0
- package/src/core/attachments/model-images.ts +39 -0
- package/src/core/attachments/prompt-block.ts +6 -2
- package/src/core/attachments/tool-attachment.ts +62 -0
- package/src/core/attachments/turn-attachments.ts +4 -21
- package/src/core/contract/attachments.ts +92 -0
- package/src/core/define-roundtable.ts +1 -0
- package/src/core/define.ts +25 -0
- package/src/core/host.ts +8 -0
- package/src/core/plugin.ts +6 -0
- package/src/core/runtime/pi-agent-runtime.ts +5 -1
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/sessions.ts +2 -0
- package/src/core/shared/attachment-reader.ts +11 -4
- package/src/index.ts +8 -0
- package/src/testing.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.3] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- A long non-ASCII upload name no longer fails with `ENAMETOOLONG` on Linux: a stored file name is cut by UTF-8 bytes (150 at most, on a code point, keeping the end so the extension stays) instead of by UTF-16 units, so the file name and its `.json` record fit the 255 bytes a file system takes. A leading dot left by the cut is replaced too. The Discord download path shares the fix. `AttachmentPort.save` removes the data file when the record write fails, so a half-saved file no longer counts as waiting.
|
|
13
|
+
- Calls on one principal's attachments run one after another, so two messages that name overlapping files no longer leave the refused one's other files moved into the conversation: `turnAttachments` moves the whole set or none, rolls back a partial move, and answers a file a sweep or a message took meanwhile with `AttachmentRefusal` (`unknown_file`) instead of a raw `ENOENT`.
|
|
14
|
+
- The `## Attachments` block prints a name and a content type a prompt can trust: control characters, line and paragraph separators and bidi controls in a name become underscores and quotes become apostrophes, and a content type that is not a plain `type/subtype` prints as `application/octet-stream`.
|
|
15
|
+
- `ToolTurn.attachment()` and `read_attachment` refuse a name with a NUL byte as "not an attachment name"; a NUL name no longer surfaces a runtime error that quoted the host's attachment directory, and a file that cannot be read reports its name, not its path.
|
|
16
|
+
- `discardPending` removes the staging directories it emptied, so a conversation's uploads leave no empty directories behind.
|
|
17
|
+
- `deleteConversation` of the Pi runtime removes the conversation's attachment directory, the files saved for it that no turn used, and its share of the used-bytes tally, so a deleted conversation keeps no files (a conversation deleted in the instant a message is taking its files can leave that message's share of the tally behind).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `TurnAttachmentOptions` and `AttachmentPort.turnAttachments(channel, principalId, files, options?)`: `options.usedBytesLimit` caps the bytes one principal's turns keep across all their conversations. A call that would pass it moves nothing and is refused with the new `AttachmentRefusalCode` `quota_exceeded`; without it the core sets no limit. The core tallies the bytes per principal and conversation, and deleting a conversation gives its bytes back.
|
|
22
|
+
|
|
23
|
+
### Documented
|
|
24
|
+
|
|
25
|
+
- The guide says what the port checks (a private conversation's record) and what it does not: a channel with no record, or any channel on a host with no conversation registry, is admitted for every principal, so the plugin that owns a channel checks that the caller may use it before every call.
|
|
26
|
+
|
|
27
|
+
## [0.9.2] - 2026-10-10
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `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.
|
|
32
|
+
- `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.
|
|
33
|
+
|
|
8
34
|
## [0.9.1] - 2026-10-10
|
|
9
35
|
|
|
10
36
|
### 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,37 @@ 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, options?)` | 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`), or when `options.usedBytesLimit` would be passed (`quota_exceeded`); then none moves, even when other calls for the same principal run at once |
|
|
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
|
+
**The plugin that owns a channel checks that the caller may use it, before every call.** The port knows only the conversation's record: a channel with no record yet, such as a conversation opened but not written in, is admitted for every principal, and so is every channel on a host with no conversation registry. A plugin that takes the channel key from a person (a URL, a frame) and passes it on unchecked lets that person stage files into, and move files into the attachment directory of, a conversation that is not theirs. Check first that the channel is the person's, as the web chat does before it saves or uses a file.
|
|
549
|
+
`usedBytesLimit` caps what one principal's turns keep, across all their conversations; the core sets no limit of its own, and a deleted conversation gives its bytes back.
|
|
550
|
+
Deleting a conversation (`runtime.deleteConversation`) also removes its attachment directory, the files saved for it that no turn used, and its share of the tally.
|
|
551
|
+
The calls reject with `AttachmentRefusal`, and throw a `PluginError` when the host has no `dataDir` (a host built by `defineRoundtable` always has one).
|
|
552
|
+
The core limits only the size; the types a plugin accepts, the number of files, and the rate are its own to limit.
|
|
553
|
+
|
|
554
|
+
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)).
|
|
555
|
+
|
|
531
556
|
### `tools`: what agents can call
|
|
532
557
|
|
|
533
558
|
`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 `
|
|
559
|
+
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `attachFile`, and `attachment`.
|
|
535
560
|
It returns the text the model reads.
|
|
561
|
+
`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.
|
|
562
|
+
Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
|
|
536
563
|
Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
|
|
537
564
|
|
|
538
565
|
<!-- example: examples/tools.ts -->
|
|
@@ -2709,12 +2736,14 @@ The package README documents every frame, error code, close code, and limit.
|
|
|
2709
2736
|
The verifier is generic; what makes it safe is how it is pointed at your provider.
|
|
2710
2737
|
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
2738
|
|
|
2712
|
-
The web chat takes
|
|
2739
|
+
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).
|
|
2740
|
+
It takes no system error reports (`takesSystemReports: false`), so `ops.conversation` cannot name it.
|
|
2713
2741
|
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
2742
|
Leave out `web_search` and `fetch_content` unless people may make the host fetch any address, internal ones included.
|
|
2715
2743
|
|
|
2716
2744
|
[webchat-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md
|
|
2717
2745
|
[webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
|
|
2746
|
+
[webchat-attachments]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#attachments
|
|
2718
2747
|
|
|
2719
2748
|
### What plugins do not extend
|
|
2720
2749
|
|
|
@@ -3219,7 +3248,11 @@ Import from the entries listed below; source area files are internal.
|
|
|
3219
3248
|
| `Approval` | `pi-roundtable` | type |
|
|
3220
3249
|
| `AskOption` | `pi-roundtable` | type |
|
|
3221
3250
|
| `AttachmentFailure` | `pi-roundtable` | type |
|
|
3251
|
+
| `AttachmentPort` | `pi-roundtable` | type |
|
|
3222
3252
|
| `AttachmentRef` | `pi-roundtable` | type |
|
|
3253
|
+
| `AttachmentRefusal` | `pi-roundtable` | value |
|
|
3254
|
+
| `AttachmentRefusalCode` | `pi-roundtable` | type |
|
|
3255
|
+
| `AttachmentUpload` | `pi-roundtable` | type |
|
|
3223
3256
|
| `AvatarMode` | `pi-roundtable` | type |
|
|
3224
3257
|
| `AvatarStudio` | `pi-roundtable` | type |
|
|
3225
3258
|
| `BACKGROUND_TURNS` | `pi-roundtable` | value |
|
|
@@ -3388,6 +3421,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3388
3421
|
| `ThinkingSetting` | `pi-roundtable` | type |
|
|
3389
3422
|
| `Tier` | `pi-roundtable` | type |
|
|
3390
3423
|
| `TierConfig` | `pi-roundtable` | type |
|
|
3424
|
+
| `ToolAttachment` | `pi-roundtable` | type |
|
|
3391
3425
|
| `ToolContribution` | `pi-roundtable` | type |
|
|
3392
3426
|
| `ToolRefusal` | `pi-roundtable` | value |
|
|
3393
3427
|
| `ToolSelection` | `pi-roundtable` | type |
|
|
@@ -3397,6 +3431,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3397
3431
|
| `ToolTurn` | `pi-roundtable` | type |
|
|
3398
3432
|
| `TranscriptEntry` | `pi-roundtable` | type |
|
|
3399
3433
|
| `TransientTask` | `pi-roundtable` | type |
|
|
3434
|
+
| `TurnAttachmentOptions` | `pi-roundtable` | type |
|
|
3400
3435
|
| `TurnAttachments` | `pi-roundtable` | type |
|
|
3401
3436
|
| `TurnConversation` | `pi-roundtable` | type |
|
|
3402
3437
|
| `TurnEndEvent` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { readdir, rm, rmdir } from "node:fs/promises";
|
|
1
2
|
import { join } from "node:path";
|
|
2
3
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
4
|
|
|
5
|
+
/** Where saved files wait for a turn, beside the conversations' attachment directories. */
|
|
6
|
+
export const STAGING = "attachments-pending";
|
|
7
|
+
/** Where each principal's tally of used bytes per conversation is kept. */
|
|
8
|
+
export const USAGE = "attachments-used";
|
|
9
|
+
|
|
4
10
|
/** A channel key as a single path segment. */
|
|
5
11
|
export function channelSegment(channel: ChannelKey): string {
|
|
6
12
|
return channel.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
@@ -13,3 +19,48 @@ export function ownerAttachmentDir(
|
|
|
13
19
|
): string {
|
|
14
20
|
return join(dataDir, "attachments", channelSegment(channel));
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
/** The entries of `dir`; none when it is not there. */
|
|
24
|
+
export async function listDir(dir: string): Promise<string[]> {
|
|
25
|
+
try {
|
|
26
|
+
return await readdir(dir);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Removes `dir` when it is empty; a directory that has entries, or is gone, stays as it is. */
|
|
34
|
+
export async function removeIfEmpty(dir: string): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
await rmdir(dir);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
39
|
+
if (code !== "ENOTEMPTY" && code !== "EEXIST" && code !== "ENOENT")
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Removes everything the core keeps for a deleted conversation: the files its turns used, the
|
|
46
|
+
* files people saved for it and no turn used, and the tally of what each principal used in it.
|
|
47
|
+
*/
|
|
48
|
+
export async function discardConversationFiles(
|
|
49
|
+
dataDir: string,
|
|
50
|
+
channel: ChannelKey,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const segment = channelSegment(channel);
|
|
53
|
+
await rm(ownerAttachmentDir(dataDir, channel), {
|
|
54
|
+
recursive: true,
|
|
55
|
+
force: true,
|
|
56
|
+
});
|
|
57
|
+
for (const area of [STAGING, USAGE]) {
|
|
58
|
+
const root = join(dataDir, area);
|
|
59
|
+
for (const principal of await listDir(root)) {
|
|
60
|
+
const dir = join(root, principal);
|
|
61
|
+
await rm(join(dir, segment), { recursive: true, force: true });
|
|
62
|
+
await rm(join(dir, `${segment}.tmp`), { force: true });
|
|
63
|
+
await removeIfEmpty(dir);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AttachmentRef,
|
|
6
6
|
StoredAttachment,
|
|
7
7
|
} from "../domain/attachment.ts";
|
|
8
|
+
import { plainText } from "./attachment-name.ts";
|
|
8
9
|
|
|
9
10
|
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
10
11
|
|
|
@@ -22,13 +23,36 @@ export interface FetchResult {
|
|
|
22
23
|
failures: AttachmentFailure[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
+
/** The most UTF-8 bytes a stored name keeps; a file system takes 255 bytes for the whole file name. */
|
|
27
|
+
const MAX_NAME_BYTES = 150;
|
|
28
|
+
|
|
29
|
+
const utf8Length = (codePoint: number): number =>
|
|
30
|
+
codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4;
|
|
31
|
+
|
|
32
|
+
/** The end of `text` that fits `max` UTF-8 bytes, cut on a code point. */
|
|
33
|
+
function lastBytes(text: string, max: number): string {
|
|
34
|
+
const points = [...text];
|
|
35
|
+
let bytes = 0;
|
|
36
|
+
let start = points.length;
|
|
37
|
+
while (start > 0) {
|
|
38
|
+
const size = utf8Length(points[start - 1]?.codePointAt(0) ?? 0);
|
|
39
|
+
if (bytes + size > max) break;
|
|
40
|
+
bytes += size;
|
|
41
|
+
start -= 1;
|
|
42
|
+
}
|
|
43
|
+
return points.slice(start).join("");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Keeps a file name readable while removing path separators and control characters, and keeps the
|
|
48
|
+
* end of a long name (its extension) within 150 UTF-8 bytes so a prefix and a record suffix still
|
|
49
|
+
* fit the 255 bytes a file system takes.
|
|
50
|
+
*/
|
|
26
51
|
export function safeFileName(name: string): string {
|
|
27
|
-
const cleaned =
|
|
28
|
-
.normalize("NFC")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.slice(-120);
|
|
52
|
+
const cleaned = lastBytes(
|
|
53
|
+
plainText(name.toWellFormed().normalize("NFC").replace(/[/\\]/g, "_")),
|
|
54
|
+
MAX_NAME_BYTES,
|
|
55
|
+
).replace(/^\.+/, "_");
|
|
32
56
|
return cleaned || "file";
|
|
33
57
|
}
|
|
34
58
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Characters that must not reach a prompt or a file name as they are: controls, the line and
|
|
5
|
+
* paragraph separators, and the bidi controls that reorder or hide the text around them.
|
|
6
|
+
*/
|
|
7
|
+
const UNSAFE_CHARS =
|
|
8
|
+
/[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
|
|
9
|
+
|
|
10
|
+
/** `value` with each unsafe character replaced by an underscore. */
|
|
11
|
+
export function plainText(value: string): string {
|
|
12
|
+
return value.replace(UNSAFE_CHARS, "_");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether `file` could be a name the attachment stores give out: one path segment, not hidden, no NUL. */
|
|
16
|
+
export function isAttachmentName(file: string): boolean {
|
|
17
|
+
return (
|
|
18
|
+
file !== "" &&
|
|
19
|
+
basename(file) === file &&
|
|
20
|
+
!file.startsWith(".") &&
|
|
21
|
+
!file.includes("\0")
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TYPE_GRAMMAR = /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The type a prompt may print: `type` without its parameters (`text/plain; charset=utf-8` is
|
|
29
|
+
* `text/plain`) when that is a plain type/subtype, else the generic binary type.
|
|
30
|
+
*/
|
|
31
|
+
export function plainContentType(type: string): string {
|
|
32
|
+
const base = type.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
33
|
+
return TYPE_GRAMMAR.test(base) ? base : "application/octet-stream";
|
|
34
|
+
}
|
|
@@ -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,363 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type AttachmentPort,
|
|
7
|
+
AttachmentRefusal,
|
|
8
|
+
type AttachmentUpload,
|
|
9
|
+
type TurnAttachmentOptions,
|
|
10
|
+
} from "../contract/attachments.ts";
|
|
11
|
+
import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
|
|
12
|
+
import type {
|
|
13
|
+
StoredAttachment,
|
|
14
|
+
TurnAttachments,
|
|
15
|
+
} from "../domain/attachment.ts";
|
|
16
|
+
import type { Logger } from "../log.ts";
|
|
17
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
18
|
+
import {
|
|
19
|
+
channelSegment,
|
|
20
|
+
listDir,
|
|
21
|
+
ownerAttachmentDir,
|
|
22
|
+
removeIfEmpty,
|
|
23
|
+
STAGING,
|
|
24
|
+
USAGE,
|
|
25
|
+
} from "./attachment-dir.ts";
|
|
26
|
+
import { MAX_ATTACHMENT_BYTES, safeFileName } from "./attachment-fetcher.ts";
|
|
27
|
+
import { isAttachmentName } from "./attachment-name.ts";
|
|
28
|
+
import { modelImagesOf } from "./model-images.ts";
|
|
29
|
+
|
|
30
|
+
/** The record of a saved file, in a directory next to it that no file name can be. */
|
|
31
|
+
export const RECORDS = ".records";
|
|
32
|
+
|
|
33
|
+
export interface AttachmentStoreOptions {
|
|
34
|
+
dataDir: string;
|
|
35
|
+
/** Read when used, once the host linked its services; undefined when no registry records conversations. */
|
|
36
|
+
registry(): Pick<ConversationRegistry, "get"> | undefined;
|
|
37
|
+
logger: Logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StagedRecord {
|
|
41
|
+
name: string;
|
|
42
|
+
contentType: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isRecord = (value: unknown): value is StagedRecord =>
|
|
46
|
+
typeof value === "object" &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
typeof (value as StagedRecord).name === "string" &&
|
|
49
|
+
typeof (value as StagedRecord).contentType === "string";
|
|
50
|
+
|
|
51
|
+
/** A principal id as one path segment; hashed, so two ids never share a directory. */
|
|
52
|
+
function principalSegment(principalId: string): string {
|
|
53
|
+
return createHash("sha256").update(principalId).digest("hex").slice(0, 32);
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
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`;
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(temporary, String(bytes));
|
|
72
|
+
} catch (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));
|
|
76
|
+
}
|
|
77
|
+
await rename(temporary, path);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The files plugins take from people outside a turn. A saved file is staged under its principal and
|
|
82
|
+
* its conversation until a turn uses it, then moved into the conversation's attachment directory,
|
|
83
|
+
* the one `read_attachment` reads. The conversation's record decides who may stage: a private
|
|
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.
|
|
89
|
+
*/
|
|
90
|
+
export class AttachmentStore implements AttachmentPort {
|
|
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>>();
|
|
94
|
+
|
|
95
|
+
constructor(options: AttachmentStoreOptions) {
|
|
96
|
+
this.#options = options;
|
|
97
|
+
}
|
|
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
|
+
|
|
113
|
+
#stagingDir(channel: ChannelKey, principalId: string): string {
|
|
114
|
+
return join(
|
|
115
|
+
this.#options.dataDir,
|
|
116
|
+
STAGING,
|
|
117
|
+
principalSegment(principalId),
|
|
118
|
+
channelSegment(channel),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#tallyDir(principalId: string): string {
|
|
123
|
+
return join(this.#options.dataDir, USAGE, principalSegment(principalId));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Refuses a principal other than the one a private conversation belongs to. */
|
|
127
|
+
async #admit(channel: ChannelKey, principalId: string): Promise<void> {
|
|
128
|
+
const record = await this.#options.registry()?.get(channel);
|
|
129
|
+
if (record?.visibility === "private" && record.principalId !== principalId)
|
|
130
|
+
throw new AttachmentRefusal(
|
|
131
|
+
"forbidden",
|
|
132
|
+
`${channel} is private to someone else, so its files cannot be saved or used by ${principalId}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async save(
|
|
137
|
+
channel: ChannelKey,
|
|
138
|
+
principalId: string,
|
|
139
|
+
upload: AttachmentUpload,
|
|
140
|
+
): Promise<StoredAttachment> {
|
|
141
|
+
await this.#admit(channel, principalId);
|
|
142
|
+
const size = upload.data.byteLength;
|
|
143
|
+
if (size > MAX_ATTACHMENT_BYTES)
|
|
144
|
+
throw new AttachmentRefusal(
|
|
145
|
+
"too_large",
|
|
146
|
+
`a file may be at most 25 MiB; "${upload.name}" is ${size} bytes`,
|
|
147
|
+
);
|
|
148
|
+
const dir = this.#stagingDir(channel, principalId);
|
|
149
|
+
const file = `${crypto.randomUUID()}-${safeFileName(upload.name)}`;
|
|
150
|
+
const path = join(dir, file);
|
|
151
|
+
const recordPath = join(dir, RECORDS, `${file}.json`);
|
|
152
|
+
const record: StagedRecord = {
|
|
153
|
+
name: upload.name,
|
|
154
|
+
contentType: upload.contentType,
|
|
155
|
+
};
|
|
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
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
name: upload.name,
|
|
170
|
+
file,
|
|
171
|
+
path,
|
|
172
|
+
contentType: upload.contentType,
|
|
173
|
+
size,
|
|
174
|
+
fromReference: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async turnAttachments(
|
|
179
|
+
channel: ChannelKey,
|
|
180
|
+
principalId: string,
|
|
181
|
+
files: readonly string[],
|
|
182
|
+
options: TurnAttachmentOptions = {},
|
|
183
|
+
): Promise<TurnAttachments> {
|
|
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[]> {
|
|
202
|
+
const staging = this.#stagingDir(channel, principalId);
|
|
203
|
+
const target = ownerAttachmentDir(this.#options.dataDir, channel);
|
|
204
|
+
const found: StoredAttachment[] = [];
|
|
205
|
+
for (const file of new Set(files)) {
|
|
206
|
+
const record = await this.#recordOf(staging, file);
|
|
207
|
+
if (!record)
|
|
208
|
+
throw new AttachmentRefusal(
|
|
209
|
+
"unknown_file",
|
|
210
|
+
`no saved file "${file}" is waiting for this person in ${channel}`,
|
|
211
|
+
);
|
|
212
|
+
found.push({
|
|
213
|
+
name: record.name,
|
|
214
|
+
file,
|
|
215
|
+
path: join(target, file),
|
|
216
|
+
contentType: record.contentType,
|
|
217
|
+
size: (await stat(join(staging, file))).size,
|
|
218
|
+
fromReference: false,
|
|
219
|
+
});
|
|
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
|
+
}
|
|
232
|
+
mkdirSync(join(target, RECORDS), { recursive: true, mode: 0o700 });
|
|
233
|
+
mkdirSync(this.#tallyDir(principalId), { recursive: true, mode: 0o700 });
|
|
234
|
+
const moved: StoredAttachment[] = [];
|
|
235
|
+
try {
|
|
236
|
+
for (const stored of found) {
|
|
237
|
+
await rename(join(staging, stored.file), stored.path);
|
|
238
|
+
moved.push(stored);
|
|
239
|
+
await rename(
|
|
240
|
+
join(staging, RECORDS, `${stored.file}.json`),
|
|
241
|
+
join(target, RECORDS, `${stored.file}.json`),
|
|
242
|
+
);
|
|
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
|
+
);
|
|
270
|
+
await rename(
|
|
271
|
+
join(target, RECORDS, `${stored.file}.json`),
|
|
272
|
+
join(staging, RECORDS, `${stored.file}.json`),
|
|
273
|
+
).catch(() => undefined);
|
|
274
|
+
}
|
|
275
|
+
await writeTally(tally, before).catch(() => undefined);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async remove(
|
|
279
|
+
channel: ChannelKey,
|
|
280
|
+
principalId: string,
|
|
281
|
+
file: string,
|
|
282
|
+
): Promise<boolean> {
|
|
283
|
+
await this.#admit(channel, principalId);
|
|
284
|
+
const staging = this.#stagingDir(channel, principalId);
|
|
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
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async discardPending(olderThan: Date): Promise<number> {
|
|
294
|
+
const root = join(this.#options.dataDir, STAGING);
|
|
295
|
+
let discarded = 0;
|
|
296
|
+
for (const principal of await listDir(root)) {
|
|
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);
|
|
312
|
+
}
|
|
313
|
+
await removeIfEmpty(join(root, principal));
|
|
314
|
+
return count;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return discarded;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async pendingBytes(principalId: string): Promise<number> {
|
|
321
|
+
const root = join(
|
|
322
|
+
this.#options.dataDir,
|
|
323
|
+
STAGING,
|
|
324
|
+
principalSegment(principalId),
|
|
325
|
+
);
|
|
326
|
+
let total = 0;
|
|
327
|
+
for (const channel of await listDir(root)) {
|
|
328
|
+
const dir = join(root, channel);
|
|
329
|
+
for (const entry of await listDir(dir)) {
|
|
330
|
+
if (entry === RECORDS) continue;
|
|
331
|
+
total +=
|
|
332
|
+
(await stat(join(dir, entry)).catch(() => undefined))?.size ?? 0;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return total;
|
|
336
|
+
}
|
|
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
|
+
|
|
348
|
+
/** The record of a staged file, or undefined when it is not staged here. */
|
|
349
|
+
async #recordOf(
|
|
350
|
+
staging: string,
|
|
351
|
+
file: string,
|
|
352
|
+
): Promise<StagedRecord | undefined> {
|
|
353
|
+
if (!isAttachmentName(file)) return undefined;
|
|
354
|
+
const stored = Bun.file(join(staging, RECORDS, `${file}.json`));
|
|
355
|
+
if (
|
|
356
|
+
!(await stored.exists()) ||
|
|
357
|
+
!(await Bun.file(join(staging, file)).exists())
|
|
358
|
+
)
|
|
359
|
+
return undefined;
|
|
360
|
+
const value: unknown = await stored.json();
|
|
361
|
+
return isRecord(value) ? value : undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isAttachmentName, plainText } from "./attachment-name.ts";
|
|
3
|
+
import { RECORDS } from "./attachment-store.ts";
|
|
4
|
+
|
|
5
|
+
/** A file someone attached to the conversation, as a tool reads it. */
|
|
6
|
+
export interface ToolAttachment {
|
|
7
|
+
/** The file name inside the conversation's attachment store; what `## Attachments` lists. */
|
|
8
|
+
file: string;
|
|
9
|
+
/** The name the person gave it, when the core recorded one; else the file name. */
|
|
10
|
+
name: string;
|
|
11
|
+
contentType: string;
|
|
12
|
+
size: number;
|
|
13
|
+
/** The file's whole content. */
|
|
14
|
+
bytes(): Promise<Uint8Array>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A name that is no attachment of the conversation, or a file that is not there. */
|
|
18
|
+
export class AttachmentLookupError extends Error {
|
|
19
|
+
override name = "AttachmentLookupError";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Recorded {
|
|
23
|
+
name?: unknown;
|
|
24
|
+
contentType?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Opens one attachment of a conversation's store for a tool. `file` must be a name in `dir`, as
|
|
29
|
+
* `read_attachment` takes it: a path or a hidden name is refused.
|
|
30
|
+
*/
|
|
31
|
+
export async function openAttachment(
|
|
32
|
+
dir: string,
|
|
33
|
+
file: string,
|
|
34
|
+
): Promise<ToolAttachment> {
|
|
35
|
+
const shown = plainText(file);
|
|
36
|
+
if (!isAttachmentName(file))
|
|
37
|
+
throw new AttachmentLookupError(`"${shown}" is not an attachment name`);
|
|
38
|
+
const handle = Bun.file(join(dir, file));
|
|
39
|
+
if (!(await handle.exists()))
|
|
40
|
+
throw new AttachmentLookupError(
|
|
41
|
+
`no attachment named "${shown}" in this channel`,
|
|
42
|
+
);
|
|
43
|
+
const record = Bun.file(join(dir, RECORDS, `${file}.json`));
|
|
44
|
+
const recorded: Recorded = (await record.exists()) ? await record.json() : {};
|
|
45
|
+
return {
|
|
46
|
+
file,
|
|
47
|
+
name: typeof recorded.name === "string" ? recorded.name : file,
|
|
48
|
+
contentType:
|
|
49
|
+
typeof recorded.contentType === "string"
|
|
50
|
+
? recorded.contentType
|
|
51
|
+
: handle.type,
|
|
52
|
+
size: handle.size,
|
|
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
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
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,92 @@
|
|
|
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
|
+
/** Using the files would take the principal past the `usedBytesLimit` the plugin passed. */
|
|
24
|
+
| "quota_exceeded";
|
|
25
|
+
|
|
26
|
+
/** An attachment call the store refused; `code` says why, and the plugin decides what its client sees. */
|
|
27
|
+
export class AttachmentRefusal extends Error {
|
|
28
|
+
override name = "AttachmentRefusal";
|
|
29
|
+
readonly code: AttachmentRefusalCode;
|
|
30
|
+
|
|
31
|
+
constructor(code: AttachmentRefusalCode, message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
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
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Files a plugin takes from a person outside a turn, such as an upload over HTTP, kept for the
|
|
49
|
+
* conversation until a turn uses them. A saved file waits for its person: only the principal that
|
|
50
|
+
* saved it may use it, in the conversation it was saved for. A private conversation refuses every
|
|
51
|
+
* other principal; a shared one lets each use their own. Using a file moves it into the
|
|
52
|
+
* conversation's attachment directory, where the turn's `read_attachment` and
|
|
53
|
+
* `ToolTurn.attachment()` find it. Calls reject with `AttachmentRefusal` for a refused call, and
|
|
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.
|
|
62
|
+
*/
|
|
63
|
+
export interface AttachmentPort {
|
|
64
|
+
/** Keeps a file for the principal in the conversation until a turn uses it. */
|
|
65
|
+
save(
|
|
66
|
+
channel: ChannelKey,
|
|
67
|
+
principalId: string,
|
|
68
|
+
upload: AttachmentUpload,
|
|
69
|
+
): Promise<StoredAttachment>;
|
|
70
|
+
/**
|
|
71
|
+
* The turn's attachments for files `save` returned: each moves into the conversation, up to four
|
|
72
|
+
* images are prepared for the model, and every file is named in the turn's `## Attachments`
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
turnAttachments(
|
|
77
|
+
channel: ChannelKey,
|
|
78
|
+
principalId: string,
|
|
79
|
+
files: readonly string[],
|
|
80
|
+
options?: TurnAttachmentOptions,
|
|
81
|
+
): Promise<TurnAttachments>;
|
|
82
|
+
/** Discards a saved file no turn used; false when there is none. */
|
|
83
|
+
remove(
|
|
84
|
+
channel: ChannelKey,
|
|
85
|
+
principalId: string,
|
|
86
|
+
file: string,
|
|
87
|
+
): Promise<boolean>;
|
|
88
|
+
/** Discards every saved file no turn used that was saved before `olderThan`; returns how many. */
|
|
89
|
+
discardPending(olderThan: Date): Promise<number>;
|
|
90
|
+
/** The bytes the principal has saved that no turn used yet, across their conversations. */
|
|
91
|
+
pendingBytes(principalId: string): Promise<number>;
|
|
92
|
+
}
|
|
@@ -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)] : []),
|
package/src/core/define.ts
CHANGED
|
@@ -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: () => {
|
package/src/core/plugin.ts
CHANGED
|
@@ -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. */
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
type ModelRuntime,
|
|
4
4
|
SessionManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
discardConversationFiles,
|
|
8
|
+
ownerAttachmentDir,
|
|
9
|
+
} from "../attachments/attachment-dir.ts";
|
|
7
10
|
import { withAttachmentsBlock } from "../attachments/prompt-block.ts";
|
|
8
11
|
import type { AgentRuntime } from "../contract/runtime.ts";
|
|
9
12
|
import { NO_ATTACHMENTS, type TurnAttachments } from "../domain/attachment.ts";
|
|
@@ -447,6 +450,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
447
450
|
async deleteConversation(channel: ChannelKey): Promise<void> {
|
|
448
451
|
await this.#sessions.forget(channel);
|
|
449
452
|
rmSync(this.#factory.sessionDir(channel), { recursive: true, force: true });
|
|
453
|
+
await discardConversationFiles(this.#options.dataDir, channel);
|
|
450
454
|
this.#options.logger.info({ channel }, "conversation deleted");
|
|
451
455
|
}
|
|
452
456
|
|
package/src/core/sessions.ts
CHANGED
|
@@ -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. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
2
|
import { extractText, getDocumentProxy } from "unpdf";
|
|
3
|
+
import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
|
|
3
4
|
|
|
4
5
|
/** Characters returned per read; the model can ask for a later offset. */
|
|
5
6
|
export const READ_LIMIT = 60_000;
|
|
@@ -66,14 +67,20 @@ export async function readAttachment(
|
|
|
66
67
|
file: string,
|
|
67
68
|
offset = 0,
|
|
68
69
|
): Promise<ReadResult> {
|
|
69
|
-
if (
|
|
70
|
-
throw new Error(`"${file}" is not an attachment name`);
|
|
70
|
+
if (!isAttachmentName(file)) {
|
|
71
|
+
throw new Error(`"${plainText(file)}" is not an attachment name`);
|
|
71
72
|
}
|
|
72
73
|
const handle = Bun.file(join(dir, file));
|
|
73
74
|
if (!(await handle.exists())) {
|
|
74
75
|
throw new Error(`no attachment named "${file}" in this channel`);
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
+
let bytes: Uint8Array;
|
|
78
|
+
try {
|
|
79
|
+
bytes = new Uint8Array(await handle.arrayBuffer());
|
|
80
|
+
} catch {
|
|
81
|
+
// A file system error names the path on the host; the model sees only the attachment's name.
|
|
82
|
+
throw new Error(`"${plainText(file)}" could not be read`);
|
|
83
|
+
}
|
|
77
84
|
let text: string;
|
|
78
85
|
if (isPdf(file, bytes)) {
|
|
79
86
|
const pdf = await getDocumentProxy(bytes);
|
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,13 @@ 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
|
+
TurnAttachmentOptions,
|
|
42
|
+
} from "./core/contract/attachments.ts";
|
|
43
|
+
export { AttachmentRefusal } from "./core/contract/attachments.ts";
|
|
36
44
|
export type {
|
|
37
45
|
Admission,
|
|
38
46
|
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");
|