pi-roundtable 0.9.8 → 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,18 @@ 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
+
8
20
  ## [0.9.8] - 2026-10-10
9
21
 
10
22
  ### 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 -->
@@ -3316,7 +3345,9 @@ Import from the entries listed below; source area files are internal.
3316
3345
  | `AttachmentRef` | `pi-roundtable` | type |
3317
3346
  | `AttachmentRefusal` | `pi-roundtable` | value |
3318
3347
  | `AttachmentRefusalCode` | `pi-roundtable` | type |
3348
+ | `AttachmentRetention` | `pi-roundtable` | type |
3319
3349
  | `AttachmentUpload` | `pi-roundtable` | type |
3350
+ | `AttachmentsConfig` | `pi-roundtable` | type |
3320
3351
  | `AvatarMode` | `pi-roundtable` | type |
3321
3352
  | `AvatarStudio` | `pi-roundtable` | type |
3322
3353
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
@@ -3355,6 +3386,8 @@ Import from the entries listed below; source area files are internal.
3355
3386
  | `DrainOptions` | `pi-roundtable` | type |
3356
3387
  | `EventHandlers` | `pi-roundtable` | type |
3357
3388
  | `EventSink` | `pi-roundtable` | type |
3389
+ | `ExpireUsedOptions` | `pi-roundtable` | type |
3390
+ | `ExpiredAttachments` | `pi-roundtable` | type |
3358
3391
  | `GroupStatus` | `pi-roundtable` | type |
3359
3392
  | `HeldActionStore` | `pi-roundtable` | type |
3360
3393
  | `HeldCall` | `pi-roundtable` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.8",
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
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
+ import { RECORDS } from "./attachment-dir.ts";
3
+ import { expiredMessage, wasExpired } from "./attachment-expiry.ts";
2
4
  import { isAttachmentName, plainText } from "./attachment-name.ts";
3
- import { RECORDS } from "./attachment-store.ts";
4
5
 
5
6
  /** A file someone attached to the conversation, as a tool reads it. */
6
7
  export interface ToolAttachment {
@@ -19,6 +20,15 @@ export class AttachmentLookupError extends Error {
19
20
  override name = "AttachmentLookupError";
20
21
  }
21
22
 
23
+ /** A file the core removed after its retention period; the message says so, with no path. */
24
+ export class AttachmentExpiredError extends AttachmentLookupError {
25
+ override name = "AttachmentExpiredError";
26
+
27
+ constructor(shown: string) {
28
+ super(expiredMessage(shown));
29
+ }
30
+ }
31
+
22
32
  interface Recorded {
23
33
  name?: unknown;
24
34
  contentType?: unknown;
@@ -36,10 +46,12 @@ export async function openAttachment(
36
46
  if (!isAttachmentName(file))
37
47
  throw new AttachmentLookupError(`"${shown}" is not an attachment name`);
38
48
  const handle = Bun.file(join(dir, file));
39
- if (!(await handle.exists()))
49
+ if (!(await handle.exists())) {
50
+ if (await wasExpired(dir, file)) throw new AttachmentExpiredError(shown);
40
51
  throw new AttachmentLookupError(
41
52
  `no attachment named "${shown}" in this channel`,
42
53
  );
54
+ }
43
55
  const record = Bun.file(join(dir, RECORDS, `${file}.json`));
44
56
  const recorded: Recorded = (await record.exists()) ? await record.json() : {};
45
57
  return {
@@ -0,0 +1,28 @@
1
+ import type { AttachmentRetention } from "../attachments/attachment-retention.ts";
2
+ import { integer, optional, shape } from "./schema.ts";
3
+
4
+ /** The configuration's `attachments`: what the host does with the files people send. */
5
+ export interface AttachmentsConfig {
6
+ /**
7
+ * Removes the files turns used once they are `maxAgeMs` old (counted from the turn that took
8
+ * the file), giving the owners' used-bytes allowance back; sweeps when the host starts and every
9
+ * `sweepEveryMs` (default one hour, at most `maxAgeMs`). Left out, used files live as long as
10
+ * their conversation. Files waiting for a turn are not touched; the plugin that took them
11
+ * discards those.
12
+ */
13
+ retention?: AttachmentRetention;
14
+ }
15
+
16
+ const YEAR_MS = 365 * 24 * 60 * 60_000;
17
+
18
+ /** The `attachments` key's shape. */
19
+ export const attachmentsShape = optional(
20
+ shape({
21
+ retention: optional(
22
+ shape({
23
+ maxAgeMs: integer(1, YEAR_MS),
24
+ sweepEveryMs: optional(integer(1, YEAR_MS)),
25
+ }),
26
+ ),
27
+ }),
28
+ );
@@ -26,6 +26,7 @@ import {
26
26
  type ResolvedDiscord,
27
27
  resolvedDiscord,
28
28
  } from "./adapters.ts";
29
+ import { type AttachmentsConfig, attachmentsShape } from "./attachments.ts";
29
30
  import {
30
31
  bool,
31
32
  guarded,
@@ -98,6 +99,8 @@ export interface RoundtableConfig {
98
99
  database: { url: string };
99
100
  /** Where the process keeps its files, such as pictures, attachments, and skills. */
100
101
  dataDir: string;
102
+ /** What the host does with the files people send, such as removing them after a retention period. */
103
+ attachments?: AttachmentsConfig;
101
104
  /** Pi's agent directory, holding the model logins; default `<dataDir>/pi`. */
102
105
  agentDir?: string;
103
106
  /** The model of the agents, written `<provider>/<id>`. */
@@ -211,6 +214,7 @@ const schema = shape({
211
214
  discord: optional(discordShape),
212
215
  database: shape({ url: text }),
213
216
  dataDir: text,
217
+ attachments: attachmentsShape,
214
218
  agentDir: optional(text),
215
219
  model: text,
216
220
  thinking: optional(oneOf(...THINKING_LEVELS)),
@@ -306,6 +310,8 @@ export interface ResolvedConfig {
306
310
  discord?: ResolvedDiscord;
307
311
  databaseUrl: string;
308
312
  dataDir: string;
313
+ /** Absent `retention`: used files live as long as their conversation. */
314
+ attachments: AttachmentsConfig;
309
315
  agentDir: string;
310
316
  model: ModelRef;
311
317
  thinking: ThinkingLevel;
@@ -421,6 +427,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
421
427
  ...(discord ? { discord: resolvedDiscord(discord, slug) } : {}),
422
428
  databaseUrl: config.database.url,
423
429
  dataDir: config.dataDir,
430
+ attachments: config.attachments?.retention
431
+ ? { retention: config.attachments.retention }
432
+ : {},
424
433
  agentDir: config.agentDir ?? `${config.dataDir}/pi`,
425
434
  model,
426
435
  thinking,
@@ -44,6 +44,26 @@ export interface TurnAttachmentOptions {
44
44
  usedBytesLimit?: number;
45
45
  }
46
46
 
47
+ /** What `expireUsed` asks: how old a used file must be to go. */
48
+ export interface ExpireUsedOptions {
49
+ /** Files a turn used more than this many milliseconds ago are removed. */
50
+ olderThanMs: number;
51
+ }
52
+
53
+ /** What `expireUsed` removed. */
54
+ export interface ExpiredAttachments {
55
+ /** How many used files were removed. */
56
+ files: number;
57
+ /** Their bytes; the owners' used-bytes tallies shrank by as much, less `unattributedBytes`. */
58
+ bytes: number;
59
+ /**
60
+ * Bytes of removed files whose owner the core could not tell, because a turn used them before
61
+ * the core recorded owners and their conversation has more than one person's tally. The files
62
+ * are gone, but no tally shrank for them; deleting the conversation clears the tallies.
63
+ */
64
+ unattributedBytes: number;
65
+ }
66
+
47
67
  /**
48
68
  * Files a plugin takes from a person outside a turn, such as an upload over HTTP, kept for the
49
69
  * conversation until a turn uses them. A saved file waits for its person: only the principal that
@@ -89,4 +109,17 @@ export interface AttachmentPort {
89
109
  discardPending(olderThan: Date): Promise<number>;
90
110
  /** The bytes the principal has saved that no turn used yet, across their conversations. */
91
111
  pendingBytes(principalId: string): Promise<number>;
112
+ /**
113
+ * Removes the files turns used more than `olderThanMs` ago, and lowers each owner's used-bytes
114
+ * tally for the conversation by exactly the bytes removed, so the allowance `usedBytesLimit`
115
+ * checks comes back. The age counts from the turn that took the file, not from its upload. It
116
+ * runs one after another with that person's other calls, so it never races a message taking
117
+ * files. A removed file leaves a mark without its name: `read_attachment` and
118
+ * `ToolTurn.attachment()` then say the file was removed after its retention period. Files
119
+ * waiting for a turn are not touched; `discardPending` is theirs. A host sets
120
+ * `attachments.retention` to run this on a timer. Optional, so a port a plugin wrote before it
121
+ * existed still type-checks; the host's own port always has it, and a retention setting refuses
122
+ * a port without it.
123
+ */
124
+ expireUsed?(options: ExpireUsedOptions): Promise<ExpiredAttachments>;
92
125
  }
@@ -279,6 +279,9 @@ export async function defineRoundtable(
279
279
  },
280
280
  database: { url: config.databaseUrl },
281
281
  dataDir: config.dataDir,
282
+ ...(config.attachments.retention
283
+ ? { attachments: { retention: config.attachments.retention } }
284
+ : {}),
282
285
  toolTiers: toolTiers(config.toolTiers, logger),
283
286
  listeners: [
284
287
  ...(config.http ? [publicListener(config.http)] : []),
package/src/core/host.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { SQL } from "bun";
2
2
  import { attachmentPort } from "./attachments/attachment-port.ts";
3
+ import {
4
+ type AttachmentRetention,
5
+ retentionServices,
6
+ } from "./attachments/attachment-retention.ts";
3
7
  import type { ConversationPort } from "./contract/channels.ts";
4
8
  import type { SurfacePort } from "./contract/surface.ts";
5
9
  import { openPool, runMigrations } from "./db/migrations.ts";
@@ -82,6 +86,13 @@ export interface RoundtableOptions {
82
86
  database?: { url: string };
83
87
  /** The directory the host keeps files in; `context.attachments` keeps its files there. Without it that port refuses every call. */
84
88
  dataDir?: string;
89
+ /**
90
+ * What the host does with the attachments it keeps. `retention` removes the files turns used once
91
+ * they are `maxAgeMs` old, sweeping at start and every `sweepEveryMs` (default one hour), and gives
92
+ * the owners' used-bytes allowance back; without it used files live as long as their conversation.
93
+ * Needs `dataDir`.
94
+ */
95
+ attachments?: { retention?: AttachmentRetention };
85
96
  /** Receives the work a shutdown drain gave up on, before any service stops. */
86
97
  aborted?: (left: string[]) => Promise<void>;
87
98
  /**
@@ -270,6 +281,17 @@ export class Roundtable {
270
281
  this.#services = new ServiceRegistry(this.#active);
271
282
  this.#services.checkRequires();
272
283
  const providers = resolveProviders(this.#active, this.#options.judgeModel);
284
+ const attachments = attachmentPort({
285
+ dataDir: this.#options.dataDir,
286
+ registry: () => this.#services?.find(CONVERSATIONS),
287
+ logger,
288
+ });
289
+ const retention = retentionServices(
290
+ this.#options.attachments?.retention,
291
+ this.#options.dataDir,
292
+ attachments,
293
+ logger,
294
+ );
273
295
  await this.#migrate();
274
296
  this.#registry = await collectContributions(
275
297
  this.#active,
@@ -287,11 +309,7 @@ export class Roundtable {
287
309
  toolTiers: this.#tiers,
288
310
  events: this.#events.sink,
289
311
  conversations: this.#conversations(),
290
- attachments: attachmentPort({
291
- dataDir: this.#options.dataDir,
292
- registry: () => this.#services?.find(CONVERSATIONS),
293
- logger,
294
- }),
312
+ attachments,
295
313
  surfaces: this.#surfaces(),
296
314
  turns: this.#turns(),
297
315
  database: () => {
@@ -331,7 +349,7 @@ export class Roundtable {
331
349
  });
332
350
  const http = new HttpListeners(listeners, routes, logger);
333
351
  for (const plugin of this.#active) await plugin.preflight?.();
334
- for (const service of this.#registry.services) {
352
+ for (const service of [...this.#registry.services, ...retention]) {
335
353
  await service.start?.();
336
354
  this.#started.push(service);
337
355
  }
@@ -1,6 +1,8 @@
1
1
  import { join } from "node:path";
2
2
  import { extractText, getDocumentProxy } from "unpdf";
3
+ import { wasExpired } from "../attachments/attachment-expiry.ts";
3
4
  import { isAttachmentName, plainText } from "../attachments/attachment-name.ts";
5
+ import { AttachmentExpiredError } from "../attachments/tool-attachment.ts";
4
6
 
5
7
  /** Characters returned per read; the model can ask for a later offset. */
6
8
  export const READ_LIMIT = 60_000;
@@ -72,6 +74,8 @@ export async function readAttachment(
72
74
  }
73
75
  const handle = Bun.file(join(dir, file));
74
76
  if (!(await handle.exists())) {
77
+ if (await wasExpired(dir, file))
78
+ throw new AttachmentExpiredError(plainText(file));
75
79
  throw new Error(`no attachment named "${file}" in this channel`);
76
80
  }
77
81
  let bytes: Uint8Array;
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 { AttachmentRetention } from "./core/attachments/attachment-retention.ts";
19
20
  export type { ToolAttachment } from "./core/attachments/tool-attachment.ts";
20
21
  export {
21
22
  AGENT_SERVER_PLUGIN,
@@ -26,6 +27,7 @@ export type {
26
27
  AccessOwnerConfig,
27
28
  AccessTierConfig,
28
29
  } from "./core/config/access.ts";
30
+ export type { AttachmentsConfig } from "./core/config/attachments.ts";
29
31
  export type {
30
32
  AdapterConfig,
31
33
  BackgroundConfig,
@@ -38,6 +40,8 @@ export type {
38
40
  AttachmentPort,
39
41
  AttachmentRefusalCode,
40
42
  AttachmentUpload,
43
+ ExpiredAttachments,
44
+ ExpireUsedOptions,
41
45
  TurnAttachmentOptions,
42
46
  } from "./core/contract/attachments.ts";
43
47
  export { AttachmentRefusal } from "./core/contract/attachments.ts";