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 +25 -0
- package/docs/plugins.md +68 -2
- package/package.json +1 -1
- package/src/core/attachments/attachment-dir.ts +3 -0
- package/src/core/attachments/attachment-expiry.ts +170 -0
- package/src/core/attachments/attachment-port.ts +1 -0
- package/src/core/attachments/attachment-retention.ts +116 -0
- package/src/core/attachments/attachment-store.ts +32 -35
- package/src/core/attachments/attachment-tally.ts +49 -0
- package/src/core/attachments/tool-attachment.ts +14 -2
- package/src/core/config/attachments.ts +28 -0
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/attachments.ts +33 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/define.ts +52 -33
- package/src/core/holds.ts +61 -0
- package/src/core/host.ts +24 -6
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +154 -9
- package/src/core/runtime/session-factory.ts +4 -1
- package/src/core/shared/attachment-reader.ts +4 -0
- package/src/core/tool-turn.ts +37 -0
- package/src/index.ts +4 -0
- package/src/kit/holds.ts +3 -2
- package/src/kit/index.ts +1 -1
- package/src/testing.ts +1 -1
|
@@ -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/define.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
68
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 ?
|
|
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
|
|
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
|