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.
@@ -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)] : []),
@@ -1,17 +1,14 @@
1
1
  import type { Static, TObject } from "typebox";
2
- import {
3
- AttachmentLookupError,
4
- openAttachment,
5
- type ToolAttachment,
6
- } from "./attachments/tool-attachment.ts";
2
+ import type { ToolAttachment } from "./attachments/tool-attachment.ts";
7
3
  import type { ReplyFile } from "./domain/conversation.ts";
8
4
  import { PluginError } from "./errors.ts";
9
- import type { HoldRule } from "./holds.ts";
5
+ import { type HoldRule, isPromise } from "./holds.ts";
6
+ import { messages } from "./i18n/index.ts";
10
7
  import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
11
- import { attachReplyFile } from "./reply-files.ts";
12
8
  import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
13
9
  import { toolError, toolText } from "./shared/tool-result.ts";
14
10
  import { type Speaker, TIERS, type Tier } from "./speakers.ts";
11
+ import { toolTurn } from "./tool-turn.ts";
15
12
 
16
13
  /** Tool names are lowercase words joined by underscores, like every tool the core ships. */
17
14
  const TOOL_NAME = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
@@ -64,8 +61,22 @@ export interface ToolSpec<Schema extends TObject> {
64
61
  minTier: Tier;
65
62
  /** Whether agents carry the tool in their turns; default true. */
66
63
  agent?: boolean;
67
- /** A short description of what the call would do when it must wait for approval; undefined lets it run. */
68
- hold?: (args: Static<Schema>) => string | undefined;
64
+ /**
65
+ * A short description of what the call would do when it must wait for approval; undefined lets it
66
+ * run. It may answer later, after looking something up as the turn's speaker, such as a name to
67
+ * show instead of an id: `turn` is the turn `run` would get, and its `signal` aborts when the
68
+ * turn stops or the wait ends. The owner's approval card waits for it up to
69
+ * `HOLD_DESCRIBE_TIMEOUT_MS`; a rejection or a timeout holds the call under a generic
70
+ * description, so a hold that fails never lets the call run. It must change nothing.
71
+ *
72
+ * A host that checks the hold with no turn, such as a precheck script's call, never calls a hold
73
+ * that declares `turn`; it sees the generic description. A hold that declares only `args` and
74
+ * answers at once is called there as it always was.
75
+ */
76
+ hold?: (
77
+ args: Static<Schema>,
78
+ turn: ToolTurn,
79
+ ) => string | undefined | Promise<string | undefined>;
69
80
  run(args: Static<Schema>, turn: ToolTurn): Promise<string> | string;
70
81
  }
71
82
 
@@ -124,29 +135,10 @@ export function defineTool<Schema extends TObject>(
124
135
  execute: async (_toolCallId, params, signal) => {
125
136
  try {
126
137
  return toolText(
127
- await spec.run(params as Static<Schema>, {
128
- speaker: context.speaker(),
129
- channel: context.turnChannel,
130
- agent: context.agent,
131
- signal,
132
- ...(context.workspace
133
- ? { workspace: context.workspace }
134
- : {}),
135
- attachFile: attachReplyFile,
136
- attachment: async (file) => {
137
- if (context.attachmentDir === undefined)
138
- throw new ToolRefusal(
139
- "this session keeps no attachments",
140
- );
141
- try {
142
- return await openAttachment(context.attachmentDir, file);
143
- } catch (error) {
144
- if (error instanceof AttachmentLookupError)
145
- throw new ToolRefusal(error.message);
146
- throw error;
147
- }
148
- },
149
- }),
138
+ await spec.run(
139
+ params as Static<Schema>,
140
+ toolTurn(context, signal),
141
+ ),
150
142
  );
151
143
  } catch (error) {
152
144
  if (error instanceof ToolRefusal) return toolError(error.message);
@@ -170,11 +162,38 @@ export function defineTool<Schema extends TObject>(
170
162
  hold: {
171
163
  name: `tool:${name}`,
172
164
  describe: (tool, input) =>
173
- tool === name ? hold(input as Static<Schema>) : undefined,
165
+ tool === name ? holdWithoutTurn(name, hold, input) : undefined,
166
+ describeInTurn: (tool, input, _context, turn) =>
167
+ tool === name ? hold(input as Static<Schema>, turn) : undefined,
174
168
  },
175
169
  };
176
170
  }
177
171
 
172
+ /**
173
+ * A tool's hold as a host with no turn sees it: called with the arguments alone, unless it declares
174
+ * the turn, and described generically when it needs the turn or answers later.
175
+ */
176
+ function holdWithoutTurn<Schema extends TObject>(
177
+ name: string,
178
+ hold: NonNullable<ToolSpec<Schema>["hold"]>,
179
+ input: Record<string, unknown>,
180
+ ): string | undefined {
181
+ if (hold.length >= 2) return messages().holdGeneric(name);
182
+ try {
183
+ const answer = (
184
+ hold as (
185
+ args: Static<Schema>,
186
+ ) => string | undefined | Promise<string | undefined>
187
+ )(input as Static<Schema>);
188
+ if (!isPromise(answer)) return answer;
189
+ // Its answer is not waited for here; a rejection must not surface on its own.
190
+ Promise.resolve(answer).catch(() => undefined);
191
+ } catch {
192
+ // A hold that needs the turn without declaring it, or that fails: it still counts as held.
193
+ }
194
+ return messages().holdGeneric(name);
195
+ }
196
+
178
197
  /**
179
198
  * Defines a plugin. It returns the plugin it was given, typed, after checking its name, so a
180
199
  * mistake shows where the plugin is written.
package/src/core/holds.ts CHANGED
@@ -1,6 +1,13 @@
1
+ import type { ToolTurn } from "./define.ts";
1
2
  import { PluginError } from "./errors.ts";
2
3
  import { type Tier, tierAtLeast } from "./speakers.ts";
3
4
 
5
+ /**
6
+ * How long the confirmation gate waits for a hold that looks things up before it describes the
7
+ * call generically and holds it anyway, in milliseconds.
8
+ */
9
+ export const HOLD_DESCRIBE_TIMEOUT_MS = 10_000;
10
+
4
11
  /** What a hold rule knows about the session making the call. */
5
12
  export interface HoldContext {
6
13
  /** The shared workspace of a session with a shell; calls reaching outside it may be held. */
@@ -27,6 +34,20 @@ export interface HoldRule {
27
34
  * `describe` with an empty input.
28
35
  */
29
36
  mayHold?(tool: string): boolean;
37
+ /**
38
+ * `describe` for a rule that must look something up as the turn's speaker before it can describe
39
+ * the call, which it may answer later. The confirmation gate asks it, instead of `describe`,
40
+ * when a turn makes the call, and waits for the answer up to `HOLD_DESCRIBE_TIMEOUT_MS`; a
41
+ * rejection or a timeout holds the call under a generic description. `turn.signal` aborts when the
42
+ * turn stops or the wait ends. `describe` stays what a host without a turn sees, such as a
43
+ * precheck script's call, and answers at once.
44
+ */
45
+ describeInTurn?(
46
+ tool: string,
47
+ input: Record<string, unknown>,
48
+ context: HoldContext,
49
+ turn: ToolTurn,
50
+ ): string | undefined | Promise<string | undefined>;
30
51
  /**
31
52
  * The lowest tier that may approve a call this rule holds, when it is higher than the tool's own:
32
53
  * for a call that stands for others, such as saving a script whose runs make held calls.
@@ -46,6 +67,17 @@ export type HoldCheck = ((
46
67
  ) => string | undefined) & {
47
68
  /** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
48
69
  mayHold?(tool: string): string | undefined;
70
+ /**
71
+ * The check as a turn runs it: each rule's `describeInTurn`, or its `describe` for a rule
72
+ * without one, asked in order until one describes the call. It answers at once when every rule
73
+ * asked did, and otherwise as a promise that rejects when a rule's does.
74
+ */
75
+ inTurn?(
76
+ tool: string,
77
+ input: Record<string, unknown>,
78
+ context: HoldContext,
79
+ turn: ToolTurn,
80
+ ): string | undefined | Promise<string | undefined>;
49
81
  /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
50
82
  approvalTier?(
51
83
  tool: string,
@@ -81,6 +113,26 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
81
113
  }
82
114
  return undefined;
83
115
  };
116
+ check.inTurn = (tool, input, context, turn) => {
117
+ const ask = (
118
+ from: number,
119
+ ): string | undefined | Promise<string | undefined> => {
120
+ for (let at = from; at < rules.length; at++) {
121
+ const rule = rules[at];
122
+ if (!rule) continue;
123
+ const description = rule.describeInTurn
124
+ ? rule.describeInTurn(tool, input, context, turn)
125
+ : rule.describe(tool, input, context);
126
+ if (isPromise(description))
127
+ return description.then((found) =>
128
+ found !== undefined ? found : ask(at + 1),
129
+ );
130
+ if (description !== undefined) return description;
131
+ }
132
+ return undefined;
133
+ };
134
+ return ask(0);
135
+ };
84
136
  check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
85
137
  check.approvalTier = (tool, input, context) =>
86
138
  rules.reduce<Tier | undefined>(
@@ -90,3 +142,12 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
90
142
  );
91
143
  return check;
92
144
  }
145
+
146
+ /** Whether a hold's answer comes later. */
147
+ export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
148
+ return (
149
+ typeof value === "object" &&
150
+ value !== null &&
151
+ typeof (value as { then?: unknown }).then === "function"
152
+ );
153
+ }
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
  }
@@ -28,6 +28,7 @@ export function conversationEn(ctx: CatalogContext) {
28
28
  answerReturned: (text: string) => `↩️ Reply:\n${text}`,
29
29
  thinkingAuto: "auto",
30
30
  confirmTitle: (asker: string) => `${asker} wants to run this action`,
31
+ holdGeneric: (tool: string) => `run ${tool}`,
31
32
  askTitle: (asker: string) => `${asker} has a question for you`,
32
33
  cardFile: (path: string, bytes: number | undefined) =>
33
34
  bytes === undefined
@@ -59,6 +60,7 @@ export function conversationZhTW(
59
60
  answerReturned: (text: string) => `↩️ 回覆:\n${text}`,
60
61
  thinkingAuto: "自動",
61
62
  confirmTitle: (asker: string) => `${asker} 要執行這個動作`,
63
+ holdGeneric: (tool: string) => `執行 ${tool}`,
62
64
  askTitle: (asker: string) => `${asker} 想問你`,
63
65
  cardFile: (path: string, bytes: number | undefined) =>
64
66
  bytes === undefined