pi-roundtable 0.9.7 → 0.9.9

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,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.9] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - `attachments.retention` (`AttachmentsConfig`, `AttachmentRetention`: `{ maxAgeMs, sweepEveryMs? }`) in the configuration, and `RoundtableOptions.attachments` for a host built by hand: the host removes the files turns used once they are `maxAgeMs` old, counted from the turn that took the file, sweeping when it starts and every `sweepEveryMs` (default one hour, at most `maxAgeMs`). It is off by default, where used files live as long as their conversation. It needs `dataDir`; a setting the host cannot keep stops the start and names it. The logs carry counts only (`files`, `bytes`, `unattributedBytes`), never a file name.
13
+ - `AttachmentPort.expireUsed({ olderThanMs })` (`ExpireUsedOptions`, `ExpiredAttachments`), the call the sweep makes: it removes the used files older than the age and takes exactly their bytes off each owner's used-bytes tally for the conversation, under the same per-person serialization as a message taking files, so a sweep never races a turn or a conversation's deletion. The allowance behind `usedBytesLimit` (the web chat's `usedAttachmentBytesPerPrincipal`) no longer stays full for good once a host sweeps its files. The method is optional, so a custom `AttachmentPort` written before it keeps type-checking; a retention setting refuses a port without it.
14
+
15
+ ### Changed
16
+
17
+ - A used file's record now carries its owner (a hash, never the principal id), when a turn took it and its size. A file used before this has none: the sweep ages it by its modified time, and gives the bytes back to the one person with a tally in its conversation; with several people it removes the file and reports the bytes as `unattributedBytes` without lowering any tally.
18
+ - `read_attachment` and `ToolTurn.attachment()` answer a file removed after its retention period with a refusal that says so, and not "no attachment named …". The removed file's record keeps only that mark: neither the person's file name nor its type.
19
+
20
+ ## [0.9.8] - 2026-10-10
21
+
22
+ ### Added
23
+
24
+ - A tool's `hold` may return a promise and takes the turn as a second argument, the `ToolTurn` its `run` receives (the speaker, the channel, the agent, and a `signal`), so a plugin can describe a held call with data it fetches as the current speaker, such as an entity's display name looked up with the speaker's own credentials. The confirmation gate awaits it before it posts the card, so the card, `ApprovalDetails.action` (the web chat's `approval.action`) and the held call carry the fetched text. It waits `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`); a rejection, a throw or a timeout holds the call under a generic description (`run <tool>`) and never lets it run. `signal` aborts when the turn stops or the wait ends, and a turn stopped meanwhile opens no card and holds nothing. Calls made together are held and shown in the order they were made, each with its own description.
25
+ - `HoldRule.describeInTurn(tool, input, context, turn)`, the same for a hold rule of a plugin's `holdRules`: it may return a promise and the gate asks it instead of `describe` in a turn. `HoldCheck.inTurn` asks a chain's rules as a turn does. `describe` stays required and is what a host with no turn sees.
26
+
27
+ ### Changed
28
+
29
+ - A hold that throws, not only one that rejects, is now held under a generic description in a turn; it used to fail the tool call. A hold that declares only its arguments and answers at once behaves exactly as before, and is still called with the arguments alone where a host checks the hold with no turn, such as a precheck script's tool call. A hold that declares `turn` is not called there: it is described as `run <tool>` and counts as held.
30
+ - Hold lookups are awaited one tool call at a time, because the agent loop checks calls one after another: a message with N calls whose lookups hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS`, and an approved call in the confirming turn still waits for its lookup. Keep lookups fast. This is documented in `docs/plugins.md`; behavior is unchanged.
31
+ - A hold that declares `turn` through a wrapper (so its arity is not visible), or returns a thenable without `catch`, is now held as `run <tool>` where there is no turn, instead of failing with an obscure `TypeError`.
32
+
8
33
  ## [0.9.7] - 2026-10-10
9
34
 
10
35
  ### Added
package/docs/plugins.md CHANGED
@@ -543,6 +543,7 @@ A plugin whose clients upload a file themselves, over HTTP for instance, has the
543
543
  | `remove(channel, principalId, file)` | Discards a saved file no turn used; `false` when there is none |
544
544
  | `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 |
545
545
  | `pendingBytes(principalId)` | The bytes the principal has saved that no turn used yet, for a quota of your own |
546
+ | `expireUsed({ olderThanMs })` | Optional on a custom port; the host's own has it. Removes the files turns used more than that long ago and returns `{ files, bytes, unattributedBytes }`; the owners' used-bytes tallies shrink by exactly the bytes removed. See [Retention](#retention-removing-used-files-after-a-period) |
546
547
 
547
548
  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.
548
549
  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.
@@ -554,6 +555,34 @@ The core limits only the size; the types a plugin accepts, the number of files,
554
555
 
555
556
  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)).
556
557
 
558
+ #### Retention: removing used files after a period
559
+
560
+ Left alone, a file a turn used lives as long as its conversation.
561
+ A host that must not keep uploads (for privacy, say) sets a retention period, and the core removes used files that old:
562
+
563
+ ```ts
564
+ export default {
565
+ // ...
566
+ attachments: { retention: { maxAgeMs: 7 * 24 * 60 * 60_000 } },
567
+ } satisfies RoundtableConfig;
568
+ ```
569
+
570
+ `maxAgeMs` counts from the turn that took the file, not from the upload, so a file that waited a day before a message used it still lives its full period.
571
+ The host sweeps when it starts and then every `sweepEveryMs` (default one hour, never more than `maxAgeMs`); a host built without `defineRoundtable` passes the same object as `RoundtableOptions.attachments`.
572
+ It needs a `dataDir`, and `maxAgeMs` and `sweepEveryMs` must be positive whole numbers of milliseconds: a host that cannot keep the setting stops at start and names it.
573
+ A plugin that wants its own schedule calls `context.attachments.expireUsed({ olderThanMs })` itself.
574
+
575
+ A sweep, for each person who used files:
576
+
577
+ - runs with that person's other attachment calls, one after another, so it never races a message taking files; a conversation deleted meanwhile is skipped, not brought back;
578
+ - removes the file, then replaces its record with a mark that keeps no file name or type, then takes exactly the removed bytes off that person's used-bytes tally for the conversation. A failure part way leaves a tally that is too high, never too low, and the next sweep finishes the job. The `usedBytesLimit` allowance a plugin enforces (the web chat's `usedAttachmentBytesPerPrincipal`) comes back as files expire, instead of filling for good.
579
+
580
+ After that, `read_attachment` and `turn.attachment(file)` answer a clear refusal that the file "was removed after its retention period", with neither a host path nor a bare "not found". The refusal is the only trace; the person's name for the file is gone.
581
+ Files waiting for a turn are not touched: `discardPending` is theirs.
582
+ A file a turn used under an earlier release has no recorded owner; the sweep ages it by the file's modified time and gives its bytes back to the one person with a tally in that conversation. When several people have tallies there, it removes the file but cannot tell whose bytes they were, so no tally shrinks and the bytes are reported as `unattributedBytes`; deleting the conversation clears the tallies.
583
+ Only files taken through `context.attachments` are covered. Files the core downloads from Discord links keep their conversation's lifetime.
584
+ The host logs counts only (`files`, `bytes`, `unattributedBytes`), never a file name, and a failed sweep only its error code.
585
+
557
586
  ### `tools`: what agents can call
558
587
 
559
588
  `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.
@@ -561,7 +590,7 @@ Once a turn has used a file, the model reads it with `read_attachment`, and a to
561
590
  `turn.workspace` is `{ workspace, scratchDir? }` in a session with a shared workspace (an agent's), the same roots as `SessionContext.workspace`, and undefined elsewhere: a tool that makes a file can save it there, under the scratch dir when there is one, and name its path, so the model sends it on with `attach_file` or a `discord_send_message` file `path` instead of carrying its bytes.
562
591
  It returns the text the model reads.
563
592
  `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.
564
- Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
593
+ Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads, and so does a file the host's [retention period](#retention-removing-used-files-after-a-period) removed, which says so.
565
594
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
566
595
 
567
596
  <!-- example: examples/tools.ts -->
@@ -714,6 +743,38 @@ export const cleanup = definePlugin({
714
743
  ```
715
744
  <!-- /example -->
716
745
 
746
+ #### A hold that looks things up
747
+
748
+ A hold may need something it must fetch as the speaker before it can describe the call, such as the display name of the entity an id stands for, read with the speaker's own credentials.
749
+ A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel` and the `agent` of the turn that makes the call.
750
+
751
+ ```ts
752
+ defineTool({
753
+ name: "ticket_close",
754
+ description: "Close a ticket.",
755
+ parameters: Type.Object({ id: Type.String() }),
756
+ minTier: "member",
757
+ hold: async ({ id }, turn) => {
758
+ const ticket = await tickets.get(id, turn.speaker, turn.signal);
759
+ return `Close ticket ${ticket.title}`;
760
+ },
761
+ run: ({ id }) => tickets.close(id),
762
+ });
763
+ ```
764
+
765
+ The gate awaits the description before it posts the card, so the card's bold line, the `action` of `ApprovalDetails` (the web chat's `approval.action`) and the held call all carry the fetched text.
766
+
767
+ - `turn.signal` aborts when the turn stops, or when the wait ends. A turn stopped while its description is awaited opens no card and holds nothing.
768
+ - The wait lasts `HOLD_DESCRIBE_TIMEOUT_MS` (10 seconds, exported from `pi-roundtable/kit`). A rejection, a throw or a timeout holds the call under a generic description (`run <tool>`, in the host's locale), never lets it run: the owner still sees the exact input on the card.
769
+ - Returning `undefined` after the lookup lets the call run, as for a hold that answers at once.
770
+ - Calls made together are held, and their cards opened, in the order they were made, each with its own description, whichever lookup returns first.
771
+ - The agent loop runs one tool call's checks after another's, so hold lookups are awaited one call at a time: a model message with N calls whose lookups all hang waits up to N × `HOLD_DESCRIBE_TIMEOUT_MS` before the last card. This holds in the confirming turn too, where an already approved call still waits for its lookup before it is released. Keep lookups fast and give them a short timeout of their own.
772
+ - A hold must change nothing: it may be called again for the same call, such as when the owner's confirming message makes the call again.
773
+ - A hold that declares only its arguments and answers at once is called as it always was. Where a host checks the hold with no turn, such as the `holds` of a test harness or a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s tool call, a hold that declares `turn` is not called; it is described as `run <tool>` and so counts as held.
774
+
775
+ A rule of `holdRules` does the same with `describeInTurn(tool, input, context, turn)`, which may return a promise and which the gate asks instead of `describe` in a turn.
776
+ `describe` is still required and is what a host without a turn sees. `holdChain(rules).inTurn(tool, input, context, turn)` asks the rules as a turn does, in order: each rule's `describeInTurn`, or its `describe` for a rule without one.
777
+
717
778
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
718
779
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
719
780
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
@@ -2893,7 +2954,7 @@ It returns:
2893
2954
  |---|---|
2894
2955
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2895
2956
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2896
- | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined` |
2957
+ | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined`; `holds.inTurn(tool, input, context, turn)` asks them as a turn does, and returns a promise when a hold looks things up |
2897
2958
  | `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2898
2959
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2899
2960
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
@@ -3284,7 +3345,9 @@ Import from the entries listed below; source area files are internal.
3284
3345
  | `AttachmentRef` | `pi-roundtable` | type |
3285
3346
  | `AttachmentRefusal` | `pi-roundtable` | value |
3286
3347
  | `AttachmentRefusalCode` | `pi-roundtable` | type |
3348
+ | `AttachmentRetention` | `pi-roundtable` | type |
3287
3349
  | `AttachmentUpload` | `pi-roundtable` | type |
3350
+ | `AttachmentsConfig` | `pi-roundtable` | type |
3288
3351
  | `AvatarMode` | `pi-roundtable` | type |
3289
3352
  | `AvatarStudio` | `pi-roundtable` | type |
3290
3353
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
@@ -3323,6 +3386,8 @@ Import from the entries listed below; source area files are internal.
3323
3386
  | `DrainOptions` | `pi-roundtable` | type |
3324
3387
  | `EventHandlers` | `pi-roundtable` | type |
3325
3388
  | `EventSink` | `pi-roundtable` | type |
3389
+ | `ExpireUsedOptions` | `pi-roundtable` | type |
3390
+ | `ExpiredAttachments` | `pi-roundtable` | type |
3326
3391
  | `GroupStatus` | `pi-roundtable` | type |
3327
3392
  | `HeldActionStore` | `pi-roundtable` | type |
3328
3393
  | `HeldCall` | `pi-roundtable` | type |
@@ -3606,6 +3671,7 @@ Import from the entries listed below; source area files are internal.
3606
3671
  | `formatModelRef` | `pi-roundtable/kit` | value |
3607
3672
  | `headline` | `pi-roundtable/kit` | value |
3608
3673
  | `holdChain` | `pi-roundtable/kit` | value |
3674
+ | `HOLD_DESCRIBE_TIMEOUT_MS` | `pi-roundtable/kit` | value |
3609
3675
  | `COMPACT_HEADROOM_TOKENS` | `pi-roundtable/kit` | value |
3610
3676
  | `CompactionTiers` | `pi-roundtable/kit` | value |
3611
3677
  | `compactionEngine` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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",
@@ -7,6 +7,9 @@ export const STAGING = "attachments-pending";
7
7
  /** Where each principal's tally of used bytes per conversation is kept. */
8
8
  export const USAGE = "attachments-used";
9
9
 
10
+ /** The record of a saved file, in a directory next to it that no file name can be. */
11
+ export const RECORDS = ".records";
12
+
10
13
  /** A channel key as a single path segment. */
11
14
  export function channelSegment(channel: ChannelKey): string {
12
15
  return channel.replace(/[^A-Za-z0-9_-]/g, "_");
@@ -0,0 +1,170 @@
1
+ import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExpiredAttachments } from "../contract/attachments.ts";
4
+ import { listDir, RECORDS, USAGE } from "./attachment-dir.ts";
5
+ import { isAttachmentName } from "./attachment-name.ts";
6
+ import { shrinkTally } from "./attachment-tally.ts";
7
+
8
+ /** What the core records about a file a turn used; the first two come from its upload. */
9
+ export interface UsedRecord {
10
+ name: string;
11
+ contentType: string;
12
+ /** The owner's directory segment (a hash, never the id), when the core recorded it. */
13
+ principal?: string;
14
+ /** When the turn took the file, in epoch milliseconds. */
15
+ usedAt?: number;
16
+ size?: number;
17
+ }
18
+
19
+ /** What stays of a record once its file is removed: when, and nothing the person named. */
20
+ interface ExpiredMark {
21
+ expiredAt: number;
22
+ }
23
+
24
+ /** The record file of `file` in a conversation's attachment directory. */
25
+ export function recordPath(dir: string, file: string): string {
26
+ return join(dir, RECORDS, `${file}.json`);
27
+ }
28
+
29
+ /** The record as parsed, or undefined when there is none or it is not an object. */
30
+ async function readRecord(path: string): Promise<unknown> {
31
+ try {
32
+ return JSON.parse(await readFile(path, "utf8"));
33
+ } catch (error) {
34
+ if (error instanceof SyntaxError) return undefined;
35
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ const isExpiredMark = (value: unknown): value is ExpiredMark =>
41
+ typeof value === "object" &&
42
+ value !== null &&
43
+ typeof (value as ExpiredMark).expiredAt === "number";
44
+
45
+ const isUsedRecord = (value: unknown): value is UsedRecord =>
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ !isExpiredMark(value) &&
49
+ typeof (value as UsedRecord).name === "string";
50
+
51
+ /** Whether the file's retention period ended and the core removed it. */
52
+ export async function wasExpired(dir: string, file: string): Promise<boolean> {
53
+ if (!isAttachmentName(file)) return false;
54
+ return isExpiredMark(await readRecord(recordPath(dir, file)));
55
+ }
56
+
57
+ /** What a tool or the model reads for a file removed after its retention period. */
58
+ export const expiredMessage = (shown: string): string =>
59
+ `"${shown}" was removed after its retention period and cannot be read any more. Ask the person to attach it again.`;
60
+
61
+ export interface ExpiryDeps {
62
+ dataDir: string;
63
+ now(): number;
64
+ /** Runs after the principal's earlier calls, as every call that changes their files does. */
65
+ exclusive<T>(principal: string, run: () => Promise<T>): Promise<T>;
66
+ }
67
+
68
+ /** A used file the sweep found old, with the owner it could tell. */
69
+ interface Candidate {
70
+ channel: string;
71
+ file: string;
72
+ }
73
+
74
+ /** Whoever the owner of an unowned record could only be: a lone tally in the conversation, else none. */
75
+ async function soleHolders(
76
+ dataDir: string,
77
+ ): Promise<Map<string, string | null>> {
78
+ const holders = new Map<string, string | null>();
79
+ const root = join(dataDir, USAGE);
80
+ for (const principal of await listDir(root))
81
+ for (const entry of await listDir(join(root, principal))) {
82
+ if (entry.endsWith(".tmp")) continue;
83
+ holders.set(entry, holders.has(entry) ? null : principal);
84
+ }
85
+ return holders;
86
+ }
87
+
88
+ /** The used files older than `cutoff`, grouped by the owner whose calls they run with. */
89
+ async function findOld(
90
+ dataDir: string,
91
+ cutoff: number,
92
+ ): Promise<Map<string, Candidate[]>> {
93
+ const holders = await soleHolders(dataDir);
94
+ const groups = new Map<string, Candidate[]>();
95
+ const root = join(dataDir, "attachments");
96
+ for (const channel of await listDir(root)) {
97
+ const dir = join(root, channel);
98
+ for (const entry of await listDir(join(dir, RECORDS))) {
99
+ if (!entry.endsWith(".json")) continue;
100
+ const file = entry.slice(0, -".json".length);
101
+ const record = await readRecord(recordPath(dir, file));
102
+ if (!isUsedRecord(record)) continue;
103
+ const usedAt =
104
+ record.usedAt ??
105
+ (await stat(join(dir, file)).catch(() => undefined))?.mtimeMs;
106
+ if (usedAt === undefined || usedAt >= cutoff) continue;
107
+ const owner = record.principal ?? holders.get(channel) ?? "";
108
+ groups.set(owner, [...(groups.get(owner) ?? []), { channel, file }]);
109
+ }
110
+ }
111
+ return groups;
112
+ }
113
+
114
+ /**
115
+ * Removes the files turns used before `now - olderThanMs`. Each owner's files go in one call that
116
+ * runs with their other calls: the file, then its record (which becomes a mark that keeps no
117
+ * name), then the bytes off their tally for the conversation, so a failure part way leaves a
118
+ * tally that is too high, never too low.
119
+ */
120
+ export async function expireUsedFiles(
121
+ deps: ExpiryDeps,
122
+ olderThanMs: number,
123
+ ): Promise<ExpiredAttachments> {
124
+ const { dataDir } = deps;
125
+ const cutoff = deps.now() - olderThanMs;
126
+ const total: ExpiredAttachments = {
127
+ files: 0,
128
+ bytes: 0,
129
+ unattributedBytes: 0,
130
+ };
131
+ for (const [owner, candidates] of await findOld(dataDir, cutoff)) {
132
+ await deps.exclusive(owner || "unattributed", async () => {
133
+ const removed = new Map<string, number>();
134
+ for (const { channel, file } of candidates) {
135
+ const dir = join(dataDir, "attachments", channel);
136
+ const path = recordPath(dir, file);
137
+ const record = await readRecord(path);
138
+ // A turn may have deleted the conversation, or an earlier call this one.
139
+ if (!isUsedRecord(record)) continue;
140
+ const info = await stat(join(dir, file)).catch(() => undefined);
141
+ const size = info?.size ?? record.size ?? 0;
142
+ const usedAt = record.usedAt ?? info?.mtimeMs;
143
+ if (usedAt === undefined || usedAt >= cutoff) continue;
144
+ await rm(join(dir, file), { force: true });
145
+ const mark: ExpiredMark = { expiredAt: deps.now() };
146
+ if (!(await writeMark(path, mark))) continue;
147
+ total.files += 1;
148
+ total.bytes += size;
149
+ if (owner === "") total.unattributedBytes += size;
150
+ else removed.set(channel, (removed.get(channel) ?? 0) + size);
151
+ }
152
+ for (const [channel, bytes] of removed)
153
+ await shrinkTally(join(dataDir, USAGE, owner, channel), bytes);
154
+ });
155
+ }
156
+ return total;
157
+ }
158
+
159
+ /** Replaces a record by its mark; false when the conversation's directory is gone. */
160
+ async function writeMark(path: string, mark: ExpiredMark): Promise<boolean> {
161
+ const temporary = `${path}.tmp`;
162
+ try {
163
+ await writeFile(temporary, JSON.stringify(mark));
164
+ await rename(temporary, path);
165
+ return true;
166
+ } catch (error) {
167
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
168
+ throw error;
169
+ }
170
+ }
@@ -28,6 +28,7 @@ export function attachmentPort(options: {
28
28
  remove: refuse,
29
29
  discardPending: refuse,
30
30
  pendingBytes: refuse,
31
+ expireUsed: refuse,
31
32
  };
32
33
  }
33
34
  return new AttachmentStore({ dataDir, registry, logger });
@@ -0,0 +1,116 @@
1
+ import type { AttachmentPort } from "../contract/attachments.ts";
2
+ import { PluginError } from "../errors.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import type { Service } from "../plugin.ts";
5
+
6
+ /** How long used files are kept, and how often the host looks for the ones past it. */
7
+ export interface AttachmentRetention {
8
+ /** Files a turn used more than this many milliseconds ago are removed. */
9
+ maxAgeMs: number;
10
+ /** The wait between two sweeps; default one hour, and never more than `maxAgeMs`. */
11
+ sweepEveryMs?: number;
12
+ }
13
+
14
+ /** The wait between two sweeps when the host sets none. */
15
+ export const DEFAULT_SWEEP_EVERY_MS = 60 * 60_000;
16
+
17
+ /** The port's `expireUsed`; `retentionServices` refuses a port without it before a sweep can run. */
18
+ function expireUsedOf(
19
+ attachments: AttachmentPort,
20
+ ): NonNullable<AttachmentPort["expireUsed"]> {
21
+ const expire = attachments.expireUsed;
22
+ if (expire === undefined)
23
+ throw new PluginError("this attachment port cannot expire used files");
24
+ return expire.bind(attachments);
25
+ }
26
+
27
+ /** The wait between two sweeps for `retention`. */
28
+ function sweepPeriod(retention: AttachmentRetention): number {
29
+ return Math.min(
30
+ retention.sweepEveryMs ?? DEFAULT_SWEEP_EVERY_MS,
31
+ retention.maxAgeMs,
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The service that removes used attachments past their retention period: one sweep when the host
37
+ * starts, then one every period, never two at once. A sweep that fails is logged by its error
38
+ * code alone, since a file system error names the file, and the next sweep tries again. The logs
39
+ * carry counts, never a file's name.
40
+ */
41
+ export function retentionService(
42
+ attachments: AttachmentPort,
43
+ retention: AttachmentRetention,
44
+ logger: Logger,
45
+ ): Service {
46
+ let timer: ReturnType<typeof setInterval> | undefined;
47
+ let sweeping: Promise<void> | undefined;
48
+ const sweep = async (): Promise<void> => {
49
+ try {
50
+ const result = await expireUsedOf(attachments)({
51
+ olderThanMs: retention.maxAgeMs,
52
+ });
53
+ if (result.files > 0)
54
+ logger.info(
55
+ {
56
+ files: result.files,
57
+ bytes: result.bytes,
58
+ unattributedBytes: result.unattributedBytes,
59
+ },
60
+ "used attachments past their retention period removed",
61
+ );
62
+ } catch (error) {
63
+ const code = (error as NodeJS.ErrnoException).code;
64
+ logger.warn(
65
+ code === undefined ? {} : { code },
66
+ "used attachments past their retention period not removed",
67
+ );
68
+ }
69
+ };
70
+ const once = (): Promise<void> => {
71
+ sweeping ??= sweep().finally(() => {
72
+ sweeping = undefined;
73
+ });
74
+ return sweeping;
75
+ };
76
+ return {
77
+ name: "attachment-retention",
78
+ start: async () => {
79
+ await once();
80
+ timer = setInterval(() => void once(), sweepPeriod(retention));
81
+ },
82
+ stop: async () => {
83
+ clearInterval(timer);
84
+ timer = undefined;
85
+ await sweeping;
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The retention service for the host's options, or none when it sets no retention. A setting the
92
+ * host cannot keep stops the boot, naming it: no `dataDir` to keep files in, or an age or period
93
+ * that is not a positive whole number of milliseconds.
94
+ */
95
+ export function retentionServices(
96
+ retention: AttachmentRetention | undefined,
97
+ dataDir: string | undefined,
98
+ attachments: AttachmentPort,
99
+ logger: Logger,
100
+ ): Service[] {
101
+ if (!retention) return [];
102
+ if (dataDir === undefined)
103
+ throw new PluginError(
104
+ "attachments.retention needs a dataDir: set dataDir in the configuration, or the dataDir option of the host.",
105
+ );
106
+ if (attachments.expireUsed === undefined)
107
+ throw new PluginError(
108
+ "attachments.retention needs an attachment port with expireUsed; this one has none.",
109
+ );
110
+ for (const [key, value] of Object.entries(retention))
111
+ if (!Number.isInteger(value) || value < 1)
112
+ throw new PluginError(
113
+ `attachments.retention.${key} must be a positive whole number of milliseconds; got ${String(value)}.`,
114
+ );
115
+ return [retentionService(attachments, retention, logger)];
116
+ }
@@ -1,11 +1,12 @@
1
- import { createHash } from "node:crypto";
2
1
  import { mkdirSync } from "node:fs";
3
- import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
- import { dirname, join } from "node:path";
2
+ import { rename, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
5
4
  import {
6
5
  type AttachmentPort,
7
6
  AttachmentRefusal,
8
7
  type AttachmentUpload,
8
+ type ExpiredAttachments,
9
+ type ExpireUsedOptions,
9
10
  type TurnAttachmentOptions,
10
11
  } from "../contract/attachments.ts";
11
12
  import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
@@ -19,22 +20,24 @@ import {
19
20
  channelSegment,
20
21
  listDir,
21
22
  ownerAttachmentDir,
23
+ RECORDS,
22
24
  removeIfEmpty,
23
25
  STAGING,
24
26
  USAGE,
25
27
  } from "./attachment-dir.ts";
28
+ import { expireUsedFiles, type UsedRecord } from "./attachment-expiry.ts";
26
29
  import { MAX_ATTACHMENT_BYTES, safeFileName } from "./attachment-fetcher.ts";
27
30
  import { isAttachmentName } from "./attachment-name.ts";
31
+ import { principalSegment, readTally, writeTally } from "./attachment-tally.ts";
28
32
  import { modelImagesOf } from "./model-images.ts";
29
33
 
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
34
  export interface AttachmentStoreOptions {
34
35
  dataDir: string;
35
36
  /** Read when used, once the host linked its services; undefined when no registry records conversations. */
36
37
  registry(): Pick<ConversationRegistry, "get"> | undefined;
37
38
  logger: Logger;
39
+ /** The clock that stamps the turn that takes a file; epoch milliseconds, default `Date.now`. */
40
+ now?: () => number;
38
41
  }
39
42
 
40
43
  interface StagedRecord {
@@ -48,35 +51,6 @@ const isRecord = (value: unknown): value is StagedRecord =>
48
51
  typeof (value as StagedRecord).name === "string" &&
49
52
  typeof (value as StagedRecord).contentType === "string";
50
53
 
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
54
  /**
81
55
  * The files plugins take from people outside a turn. A saved file is staged under its principal and
82
56
  * its conversation until a turn uses it, then moved into the conversation's attachment directory,
@@ -240,6 +214,18 @@ export class AttachmentStore implements AttachmentPort {
240
214
  join(staging, RECORDS, `${stored.file}.json`),
241
215
  join(target, RECORDS, `${stored.file}.json`),
242
216
  );
217
+ // Who used the file and when, for the sweep that expires it and gives the tally back.
218
+ const used: UsedRecord = {
219
+ name: stored.name,
220
+ contentType: stored.contentType,
221
+ principal: principalSegment(principalId),
222
+ usedAt: (this.#options.now ?? Date.now)(),
223
+ size: stored.size,
224
+ };
225
+ await Bun.write(
226
+ join(target, RECORDS, `${stored.file}.json`),
227
+ JSON.stringify(used),
228
+ );
243
229
  }
244
230
  await writeTally(tally, before + added);
245
231
  } catch (error) {
@@ -335,6 +321,17 @@ export class AttachmentStore implements AttachmentPort {
335
321
  return total;
336
322
  }
337
323
 
324
+ async expireUsed(options: ExpireUsedOptions): Promise<ExpiredAttachments> {
325
+ return expireUsedFiles(
326
+ {
327
+ dataDir: this.#options.dataDir,
328
+ now: this.#options.now ?? Date.now,
329
+ exclusive: (principal, run) => this.#exclusive(principal, run),
330
+ },
331
+ options.olderThanMs,
332
+ );
333
+ }
334
+
338
335
  /** The bytes the principal's turns used, across their conversations. */
339
336
  async #usedBytes(principalId: string): Promise<number> {
340
337
  const dir = this.#tallyDir(principalId);
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ /** A principal id as one path segment; hashed, so two ids never share a directory. */
6
+ export function principalSegment(principalId: string): string {
7
+ return createHash("sha256").update(principalId).digest("hex").slice(0, 32);
8
+ }
9
+
10
+ /** The bytes of a tally file; none when it is missing or unreadable as a number. */
11
+ export async function readTally(path: string): Promise<number> {
12
+ try {
13
+ const value = Number(await readFile(path, "utf8"));
14
+ return Number.isFinite(value) && value > 0 ? value : 0;
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ /** Writes a tally whole or not at all, creating its directory if a conversation's deletion removed it meanwhile. */
22
+ export async function writeTally(path: string, bytes: number): Promise<void> {
23
+ const temporary = `${path}.tmp`;
24
+ try {
25
+ await writeFile(temporary, String(bytes));
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
28
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
29
+ await writeFile(temporary, String(bytes));
30
+ }
31
+ await rename(temporary, path);
32
+ }
33
+
34
+ /**
35
+ * Takes `bytes` off a tally that is there, never below zero. A tally that is gone, because the
36
+ * conversation was deleted, stays gone: nothing here brings a deleted conversation back.
37
+ */
38
+ export async function shrinkTally(path: string, bytes: number): Promise<void> {
39
+ const temporary = `${path}.tmp`;
40
+ try {
41
+ const before = await readFile(path, "utf8");
42
+ const value = Number(before);
43
+ const left = Math.max(0, (Number.isFinite(value) ? value : 0) - bytes);
44
+ await writeFile(temporary, String(left));
45
+ await rename(temporary, path);
46
+ } catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
48
+ }
49
+ }