pi-roundtable 0.9.0 → 0.9.2
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 +20 -0
- package/docs/plugins.md +46 -10
- package/package.json +1 -1
- package/src/core/attachments/attachment-port.ts +34 -0
- package/src/core/attachments/attachment-store.ts +248 -0
- package/src/core/attachments/model-images.ts +39 -0
- package/src/core/attachments/tool-attachment.ts +53 -0
- package/src/core/attachments/turn-attachments.ts +4 -21
- package/src/core/builtin/modules.ts +10 -1
- package/src/core/config/config.ts +9 -0
- package/src/core/contract/attachments.ts +71 -0
- package/src/core/contract/channels.ts +5 -0
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/define.ts +25 -0
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +33 -60
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/modules/schedules/scheduler.ts +13 -2
- package/src/core/plugin.ts +6 -0
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +59 -7
- package/src/core/routing/conversation-turns.ts +7 -2
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/conversation-sessions.ts +15 -0
- package/src/core/runtime/pi-agent-runtime.ts +61 -78
- package/src/core/runtime/runtime-plugin.ts +1 -0
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/session-factory.ts +1 -0
- package/src/core/runtime/steerable-run.ts +12 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/core/sessions.ts +2 -0
- package/src/index.ts +8 -0
- package/src/testing.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.2] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `context.attachments` (`AttachmentPort`, with `AttachmentUpload`, `AttachmentRefusal` and `AttachmentRefusalCode`): a plugin keeps a file a person uploaded outside a turn until a turn uses it. `save` stages the file for its principal in a conversation, `turnAttachments` turns the named files into the turn's `TurnAttachments` (they move into the conversation's attachment directory, so `read_attachment` and the `## Attachments` block see them, and up to four images reach the model), `remove` and `discardPending` drop files no turn used, and `pendingBytes` counts what a principal has waiting. A staged file is usable only by the principal that saved it, in the conversation it was saved for; a conversation recorded private refuses every other principal (`AttachmentRefusal` with code `forbidden`). It needs the host's `dataDir`, a new `RoundtableOptions` field that `defineRoundtable` sets, and throws a `PluginError` without it.
|
|
13
|
+
- `ToolTurn.attachment(file)` (`ToolAttachment`): a tool opens a file attached to the conversation, by the name in the turn's `## Attachments` block, and reads its bytes with `bytes()` or passes it on. A path or a hidden name is refused with a `ToolRefusal`. `SessionContext.attachmentDir` names the directory it reads.
|
|
14
|
+
|
|
15
|
+
## [0.9.1] - 2026-10-10
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A SIGTERM no longer lets the host keep taking work until it was idle: in 0.9.0 the shutdown drain only waited, so messages, error and webhook reports, schedules, and plugin turns that arrived meanwhile were served and waited for too, and a host that kept getting them never became idle until the one-hour limit (a deploy waited for systemd to kill it). The drain now starts by closing the channel queue: no new turn starts from any source. A message that arrives is not run and its channel gets one short restarting notice (not for a bot's or an integration's message, which is only logged), including a message that waited behind a running turn, and a message is no longer steered into a running turn; a background turn (a schedule's, a delegated task's report, an error report) is skipped; `context.turns.run`, a task for `context.queue.run` that has not started, and a group's next member's turn are refused (`HostStoppingError`, new in `pi-roundtable`), and so are new worker tasks; a remote MCP run fails instead of starting. The scheduler claims nothing during the drain, so a due schedule stays due and fires after the next start if it is less than twelve hours late. Turns already running finish.
|
|
20
|
+
- The drain is bounded by three minutes by default instead of an hour: `drainSeconds` in the config, or `drain.limitMs` in `RoundtableOptions`. At the limit the host stops each running turn as the owner's stop does, gives it `drain.abortGraceMs` (ten seconds by default) to end, hands what is left to `aborted`, and goes on to stop the services and exit. Give the service manager a stop timeout a minute longer than the drain, such as systemd's `TimeoutStopSec=5min`.
|
|
21
|
+
- A turn or worker task whose model ignores the abort (a claude-bridge subprocess that stays alive after "turn timed out; aborting") no longer holds its channel, and the drain, for ever: a run still going `turnAbortGraceMs` (15 seconds by default, `PiAgentRuntimeOptions`) after its abort has its session disposed of and ends as failed, and the conversation's next turn opens a new session from its files. An owner's stop of such a turn is guarded the same way.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `QueuePort.closed` (`context.queue.closed`): whether the host is shutting down, for a plugin that starts work from its own timer or route.
|
|
26
|
+
- The `drainSeconds` configuration key.
|
|
27
|
+
|
|
8
28
|
## [0.9.0] - 2026-10-09
|
|
9
29
|
|
|
10
30
|
Principal and identity attribution replace implicit owner authority.
|
package/docs/plugins.md
CHANGED
|
@@ -135,6 +135,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
135
135
|
| `apiKey(provider)` | The credential the host's model login holds for a provider such as `openai-codex`, the same login the agents use. It resolves to `undefined` when the host has none for that provider and never throws for that. The value is a secret: keep it out of logs and error messages. Read it when you use it rather than keeping it, since a login may refresh its token |
|
|
136
136
|
| `queue` | The one channel queue that every conversation and channel operation shares |
|
|
137
137
|
| `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
|
|
138
|
+
| `attachments` | Files a plugin takes from a person outside a turn, such as an upload, kept for a conversation until a turn uses them: [`attachments`](#contextattachments-files-from-outside-a-turn) |
|
|
138
139
|
| `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
|
|
139
140
|
| `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
|
|
140
141
|
| `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
|
|
@@ -528,11 +529,35 @@ A plugin that adds raw session tools names their tiers with `toolTiers`; the cor
|
|
|
528
529
|
Each heading below is a key `setup` may return.
|
|
529
530
|
A key the contract does not have stops the start and names the closest one.
|
|
530
531
|
|
|
532
|
+
### `context.attachments`: files from outside a turn
|
|
533
|
+
|
|
534
|
+
A surface that takes a file as it arrives, such as Discord, hands the core a link, and the core downloads it into the conversation's attachment directory.
|
|
535
|
+
A plugin whose clients upload a file themselves, over HTTP for instance, has the file before any turn exists.
|
|
536
|
+
`context.attachments` keeps such a file for the conversation until a turn uses it:
|
|
537
|
+
|
|
538
|
+
| Call | What it does |
|
|
539
|
+
|---|---|
|
|
540
|
+
| `save(channel, principalId, { name, contentType, data })` | Keeps the file for that principal in that conversation and returns a `StoredAttachment`; its `file` is the name to hand back to the client. Refuses a file over 25 MiB (`too_large`) |
|
|
541
|
+
| `turnAttachments(channel, principalId, files)` | Takes the `file` names `save` returned and returns the turn's `TurnAttachments`; pass it as `attachments` to [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own). Each file moves into the conversation's attachment directory, up to four images are prepared for the model, and the turn's prompt lists every file under `## Attachments`. Refuses the whole set when one name is unknown or already used (`unknown_file`); then none moves |
|
|
542
|
+
| `remove(channel, principalId, file)` | Discards a saved file no turn used; `false` when there is none |
|
|
543
|
+
| `discardPending(olderThan)` | Discards every saved file no turn used that was saved before the date, and returns how many; call it from a service's timer so abandoned uploads do not pile up |
|
|
544
|
+
| `pendingBytes(principalId)` | The bytes the principal has saved that no turn used yet, for a quota of your own |
|
|
545
|
+
|
|
546
|
+
A saved file waits for its owner: only the principal that saved it can use it, and only in the conversation it was saved for.
|
|
547
|
+
The conversation's record decides who may save into it: a private conversation refuses every principal but its own with an `AttachmentRefusal` whose `code` is `forbidden`, and a shared one lets each principal save and use their own files.
|
|
548
|
+
A channel with no record yet, such as a conversation opened but not written in, is not checked; the plugin that owns the channel checks it, as the web chat does before it saves.
|
|
549
|
+
The calls reject with `AttachmentRefusal`, and throw a `PluginError` when the host has no `dataDir` (a host built by `defineRoundtable` always has one).
|
|
550
|
+
The core limits only the size; the types a plugin accepts, the number of files, and the rate are its own to limit.
|
|
551
|
+
|
|
552
|
+
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)).
|
|
553
|
+
|
|
531
554
|
### `tools`: what agents can call
|
|
532
555
|
|
|
533
556
|
`defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
|
|
534
|
-
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, and `
|
|
557
|
+
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `attachFile`, and `attachment`.
|
|
535
558
|
It returns the text the model reads.
|
|
559
|
+
`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.
|
|
560
|
+
Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
|
|
536
561
|
Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
|
|
537
562
|
|
|
538
563
|
<!-- example: examples/tools.ts -->
|
|
@@ -898,7 +923,7 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
|
|
|
898
923
|
|
|
899
924
|
A service has a name and optional `start`, `stop`, and `busy`.
|
|
900
925
|
Services start once everything is set up and stop in reverse order.
|
|
901
|
-
`busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most
|
|
926
|
+
`busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, for at most the drain limit (three minutes by default, `drainSeconds` in the config), so a deploy rarely cuts work short.
|
|
902
927
|
The host adds its own channel queue to that wait, so a surface that queues its turns through `context.queue` needs no `busy` for them.
|
|
903
928
|
|
|
904
929
|
A service may also have `startInBackground`.
|
|
@@ -2709,12 +2734,14 @@ The package README documents every frame, error code, close code, and limit.
|
|
|
2709
2734
|
The verifier is generic; what makes it safe is how it is pointed at your provider.
|
|
2710
2735
|
The README's [provider settings][webchat-provider-settings] explain, with Microsoft Entra ID as the example, why to name people by a claim that stays the same across app registrations (`subjectClaim`), pin the tenant in `check`, accept access tokens only, refuse app-only tokens, merge issuers of one tenant only (`speakerIssuer`), and mind the guests `everyone` admits.
|
|
2711
2736
|
|
|
2712
|
-
The web chat takes
|
|
2737
|
+
The web chat takes files people upload to their conversation and then name in a message (see the README's [attachments][webchat-attachments]), built on [`context.attachments`](#contextattachments-files-from-outside-a-turn).
|
|
2738
|
+
It takes no system error reports (`takesSystemReports: false`), so `ops.conversation` cannot name it.
|
|
2713
2739
|
It accepts `PERSONAL_TARGET` background turns only for the private conversation's principal; include `schedule_*`, `delegate_task`, and `notify` in a persona's selection only deliberately, adjusting their tiers if needed.
|
|
2714
2740
|
Leave out `web_search` and `fetch_content` unless people may make the host fetch any address, internal ones included.
|
|
2715
2741
|
|
|
2716
2742
|
[webchat-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md
|
|
2717
2743
|
[webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
|
|
2744
|
+
[webchat-attachments]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#attachments
|
|
2718
2745
|
|
|
2719
2746
|
### What plugins do not extend
|
|
2720
2747
|
|
|
@@ -2791,7 +2818,7 @@ The copied file begins with this warning.
|
|
|
2791
2818
|
`release-notice` listens to two [events](#events-hear-what-the-core-does).
|
|
2792
2819
|
When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
|
|
2793
2820
|
It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
|
|
2794
|
-
At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on
|
|
2821
|
+
At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on at its limit, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
|
|
2795
2822
|
A plain restart of the same release with nothing cut short posts nothing.
|
|
2796
2823
|
|
|
2797
2824
|
The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
|
|
@@ -2999,16 +3026,19 @@ If a startup step fails, the host stops its listeners, stops services in reverse
|
|
|
2999
3026
|
The process exits non-zero.
|
|
3000
3027
|
The same host may call `run()` again after that.
|
|
3001
3028
|
|
|
3002
|
-
On `SIGTERM` or `SIGINT` the bot
|
|
3029
|
+
On `SIGTERM` or `SIGINT` the bot takes no new work at once, finishes what runs, and stops serving last:
|
|
3003
3030
|
|
|
3004
|
-
1. It
|
|
3005
|
-
2.
|
|
3006
|
-
3.
|
|
3007
|
-
4.
|
|
3008
|
-
5.
|
|
3031
|
+
1. It stops starting work: the channel queue closes (`context.queue.closed` turns true), and from then on no new turn starts, whoever asks. A message that arrives is not run; its channel gets one short "restarting, try again shortly" reply, except a bot's or an integration's message, which is only logged. A background turn (a schedule's, a delegated task's report, an error report) is skipped, and so is a turn a plugin asks of `context.turns.run`, which rejects with `HostStoppingError`; a task for the queue that has not started, whether queued before or after, is refused with the same error. The scheduler claims nothing, so a due schedule stays due and fires after the next start if it is less than twelve hours late. A turn that is already running finishes. A plugin that starts turns from a timer or a route of its own should check `context.queue.closed` first.
|
|
3032
|
+
2. It waits until the channel queue is empty and no service reports `busy()` work, for at most three minutes (`drainSeconds` in the config, `drain.limitMs` in `RoundtableOptions`). If work is left, it stops each running turn as the owner's stop does, gives them ten seconds (`drain.abortGraceMs`) to end, and goes on; whatever is left is logged and given up on.
|
|
3033
|
+
3. Every plugin hears `shutdown(left)`, while every service is still running.
|
|
3034
|
+
4. The HTTP listener closes, so no request reaches a service that has stopped.
|
|
3035
|
+
5. Services stop in the reverse of the order they started.
|
|
3036
|
+
6. The database pool closes.
|
|
3009
3037
|
|
|
3010
3038
|
`shutdown()` returns exit code `0`, or `1` if a listener, service, or pool failed to stop.
|
|
3011
3039
|
Every call shares the same shutdown.
|
|
3040
|
+
Give the service manager a stop timeout longer than the drain limit plus about a minute for the steps after it, such as systemd's `TimeoutStopSec=5min` with the default three-minute drain; a shorter one has the manager kill the process in the middle of the drain.
|
|
3041
|
+
A turn whose model ignores its abort (a subprocess that does not exit) is given up on too: a turn that outlives its timeout, or is stopped, gets `turnAbortGraceMs` (15 seconds by default) to end, and then its session is disposed of and the turn is reported failed, so it cannot hold the queue.
|
|
3012
3042
|
`listen()`, called by the command line and a host's own entry point, exits the process with that code.
|
|
3013
3043
|
|
|
3014
3044
|
## Errors and their fixes
|
|
@@ -3216,7 +3246,11 @@ Import from the entries listed below; source area files are internal.
|
|
|
3216
3246
|
| `Approval` | `pi-roundtable` | type |
|
|
3217
3247
|
| `AskOption` | `pi-roundtable` | type |
|
|
3218
3248
|
| `AttachmentFailure` | `pi-roundtable` | type |
|
|
3249
|
+
| `AttachmentPort` | `pi-roundtable` | type |
|
|
3219
3250
|
| `AttachmentRef` | `pi-roundtable` | type |
|
|
3251
|
+
| `AttachmentRefusal` | `pi-roundtable` | value |
|
|
3252
|
+
| `AttachmentRefusalCode` | `pi-roundtable` | type |
|
|
3253
|
+
| `AttachmentUpload` | `pi-roundtable` | type |
|
|
3220
3254
|
| `AvatarMode` | `pi-roundtable` | type |
|
|
3221
3255
|
| `AvatarStudio` | `pi-roundtable` | type |
|
|
3222
3256
|
| `BACKGROUND_TURNS` | `pi-roundtable` | value |
|
|
@@ -3263,6 +3297,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3263
3297
|
| `HoldRule` | `pi-roundtable` | type |
|
|
3264
3298
|
| `HostEnv` | `pi-roundtable` | type |
|
|
3265
3299
|
| `HostEnvironment` | `pi-roundtable` | type |
|
|
3300
|
+
| `HostStoppingError` | `pi-roundtable` | value |
|
|
3266
3301
|
| `HttpRoute` | `pi-roundtable` | type |
|
|
3267
3302
|
| `IDENTITY` | `pi-roundtable` | value |
|
|
3268
3303
|
| `IdentityError` | `pi-roundtable` | value |
|
|
@@ -3384,6 +3419,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3384
3419
|
| `ThinkingSetting` | `pi-roundtable` | type |
|
|
3385
3420
|
| `Tier` | `pi-roundtable` | type |
|
|
3386
3421
|
| `TierConfig` | `pi-roundtable` | type |
|
|
3422
|
+
| `ToolAttachment` | `pi-roundtable` | type |
|
|
3387
3423
|
| `ToolContribution` | `pi-roundtable` | type |
|
|
3388
3424
|
| `ToolRefusal` | `pi-roundtable` | value |
|
|
3389
3425
|
| `ToolSelection` | `pi-roundtable` | type |
|
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttachmentPort } from "../contract/attachments.ts";
|
|
2
|
+
import { PluginError } from "../errors.ts";
|
|
3
|
+
import type { Logger } from "../log.ts";
|
|
4
|
+
import {
|
|
5
|
+
AttachmentStore,
|
|
6
|
+
type AttachmentStoreOptions,
|
|
7
|
+
} from "./attachment-store.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The attachment port a plugin gets as `context.attachments`: over the host's `dataDir`, or, when
|
|
11
|
+
* the host has none, a port whose every call throws a PluginError naming the option to set.
|
|
12
|
+
*/
|
|
13
|
+
export function attachmentPort(options: {
|
|
14
|
+
dataDir: string | undefined;
|
|
15
|
+
registry: AttachmentStoreOptions["registry"];
|
|
16
|
+
logger: Logger;
|
|
17
|
+
}): AttachmentPort {
|
|
18
|
+
const { dataDir, registry, logger } = options;
|
|
19
|
+
if (dataDir === undefined) {
|
|
20
|
+
const refuse = (): never => {
|
|
21
|
+
throw new PluginError(
|
|
22
|
+
"no dataDir is configured, so attachments cannot be kept. Set dataDir in the configuration, or the dataDir option of the host.",
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
save: refuse,
|
|
27
|
+
turnAttachments: refuse,
|
|
28
|
+
remove: refuse,
|
|
29
|
+
discardPending: refuse,
|
|
30
|
+
pendingBytes: refuse,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return new AttachmentStore({ dataDir, registry, logger });
|
|
34
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type AttachmentPort,
|
|
7
|
+
AttachmentRefusal,
|
|
8
|
+
type AttachmentUpload,
|
|
9
|
+
} from "../contract/attachments.ts";
|
|
10
|
+
import type { ConversationRegistry } from "../conversations/conversation-registry.ts";
|
|
11
|
+
import type {
|
|
12
|
+
StoredAttachment,
|
|
13
|
+
TurnAttachments,
|
|
14
|
+
} from "../domain/attachment.ts";
|
|
15
|
+
import type { Logger } from "../log.ts";
|
|
16
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
17
|
+
import { channelSegment, ownerAttachmentDir } from "./attachment-dir.ts";
|
|
18
|
+
import { MAX_ATTACHMENT_BYTES, safeFileName } from "./attachment-fetcher.ts";
|
|
19
|
+
import { modelImagesOf } from "./model-images.ts";
|
|
20
|
+
|
|
21
|
+
/** Where saved files wait, beside the conversations' attachment directories. */
|
|
22
|
+
const STAGING = "attachments-pending";
|
|
23
|
+
/** The record of a saved file, in a directory next to it that no file name can be. */
|
|
24
|
+
export const RECORDS = ".records";
|
|
25
|
+
|
|
26
|
+
export interface AttachmentStoreOptions {
|
|
27
|
+
dataDir: string;
|
|
28
|
+
/** Read when used, once the host linked its services; undefined when no registry records conversations. */
|
|
29
|
+
registry(): Pick<ConversationRegistry, "get"> | undefined;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StagedRecord {
|
|
34
|
+
name: string;
|
|
35
|
+
contentType: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isRecord = (value: unknown): value is StagedRecord =>
|
|
39
|
+
typeof value === "object" &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
typeof (value as StagedRecord).name === "string" &&
|
|
42
|
+
typeof (value as StagedRecord).contentType === "string";
|
|
43
|
+
|
|
44
|
+
/** A principal id as one path segment; hashed, so two ids never share a directory. */
|
|
45
|
+
function principalSegment(principalId: string): string {
|
|
46
|
+
return createHash("sha256").update(principalId).digest("hex").slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether `file` could be a name this store gave out: one path segment that is not hidden. */
|
|
50
|
+
function isFileName(file: string): boolean {
|
|
51
|
+
return (
|
|
52
|
+
file !== "" &&
|
|
53
|
+
basename(file) === file &&
|
|
54
|
+
!file.startsWith(".") &&
|
|
55
|
+
!file.includes("\0")
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function listDir(dir: string): Promise<string[]> {
|
|
60
|
+
try {
|
|
61
|
+
return await readdir(dir);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The files plugins take from people outside a turn. A saved file is staged under its principal and
|
|
70
|
+
* its conversation until a turn uses it, then moved into the conversation's attachment directory,
|
|
71
|
+
* the one `read_attachment` reads. The conversation's record decides who may stage: a private
|
|
72
|
+
* conversation takes only its principal's files.
|
|
73
|
+
*/
|
|
74
|
+
export class AttachmentStore implements AttachmentPort {
|
|
75
|
+
readonly #options: AttachmentStoreOptions;
|
|
76
|
+
|
|
77
|
+
constructor(options: AttachmentStoreOptions) {
|
|
78
|
+
this.#options = options;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#stagingDir(channel: ChannelKey, principalId: string): string {
|
|
82
|
+
return join(
|
|
83
|
+
this.#options.dataDir,
|
|
84
|
+
STAGING,
|
|
85
|
+
principalSegment(principalId),
|
|
86
|
+
channelSegment(channel),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Refuses a principal other than the one a private conversation belongs to. */
|
|
91
|
+
async #admit(channel: ChannelKey, principalId: string): Promise<void> {
|
|
92
|
+
const record = await this.#options.registry()?.get(channel);
|
|
93
|
+
if (record?.visibility === "private" && record.principalId !== principalId)
|
|
94
|
+
throw new AttachmentRefusal(
|
|
95
|
+
"forbidden",
|
|
96
|
+
`${channel} is private to someone else, so its files cannot be saved or used by ${principalId}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async save(
|
|
101
|
+
channel: ChannelKey,
|
|
102
|
+
principalId: string,
|
|
103
|
+
upload: AttachmentUpload,
|
|
104
|
+
): Promise<StoredAttachment> {
|
|
105
|
+
await this.#admit(channel, principalId);
|
|
106
|
+
const size = upload.data.byteLength;
|
|
107
|
+
if (size > MAX_ATTACHMENT_BYTES)
|
|
108
|
+
throw new AttachmentRefusal(
|
|
109
|
+
"too_large",
|
|
110
|
+
`a file may be at most 25 MiB; "${upload.name}" is ${size} bytes`,
|
|
111
|
+
);
|
|
112
|
+
const dir = this.#stagingDir(channel, principalId);
|
|
113
|
+
mkdirSync(join(dir, RECORDS), { recursive: true, mode: 0o700 });
|
|
114
|
+
const file = `${crypto.randomUUID()}-${safeFileName(upload.name)}`;
|
|
115
|
+
const path = join(dir, file);
|
|
116
|
+
await Bun.write(path, upload.data);
|
|
117
|
+
const record: StagedRecord = {
|
|
118
|
+
name: upload.name,
|
|
119
|
+
contentType: upload.contentType,
|
|
120
|
+
};
|
|
121
|
+
await Bun.write(join(dir, RECORDS, `${file}.json`), JSON.stringify(record));
|
|
122
|
+
return {
|
|
123
|
+
name: upload.name,
|
|
124
|
+
file,
|
|
125
|
+
path,
|
|
126
|
+
contentType: upload.contentType,
|
|
127
|
+
size,
|
|
128
|
+
fromReference: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async turnAttachments(
|
|
133
|
+
channel: ChannelKey,
|
|
134
|
+
principalId: string,
|
|
135
|
+
files: readonly string[],
|
|
136
|
+
): Promise<TurnAttachments> {
|
|
137
|
+
await this.#admit(channel, principalId);
|
|
138
|
+
const staging = this.#stagingDir(channel, principalId);
|
|
139
|
+
const target = ownerAttachmentDir(this.#options.dataDir, channel);
|
|
140
|
+
const found: StoredAttachment[] = [];
|
|
141
|
+
for (const file of new Set(files)) {
|
|
142
|
+
const record = await this.#recordOf(staging, file);
|
|
143
|
+
if (!record)
|
|
144
|
+
throw new AttachmentRefusal(
|
|
145
|
+
"unknown_file",
|
|
146
|
+
`no saved file "${file}" is waiting for this person in ${channel}`,
|
|
147
|
+
);
|
|
148
|
+
const from = join(staging, file);
|
|
149
|
+
found.push({
|
|
150
|
+
name: record.name,
|
|
151
|
+
file,
|
|
152
|
+
path: join(target, file),
|
|
153
|
+
contentType: record.contentType,
|
|
154
|
+
size: (await stat(from)).size,
|
|
155
|
+
fromReference: false,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
mkdirSync(join(target, RECORDS), { recursive: true, mode: 0o700 });
|
|
159
|
+
for (const stored of found) {
|
|
160
|
+
try {
|
|
161
|
+
await rename(join(staging, stored.file), stored.path);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Another turn used the file between the check and the move.
|
|
164
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
165
|
+
throw new AttachmentRefusal(
|
|
166
|
+
"unknown_file",
|
|
167
|
+
`the saved file "${stored.file}" was used already`,
|
|
168
|
+
);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
await rename(
|
|
172
|
+
join(staging, RECORDS, `${stored.file}.json`),
|
|
173
|
+
join(target, RECORDS, `${stored.file}.json`),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const { images, failures } = await modelImagesOf(
|
|
177
|
+
found,
|
|
178
|
+
this.#options.logger,
|
|
179
|
+
);
|
|
180
|
+
return { files: found, images, failures };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async remove(
|
|
184
|
+
channel: ChannelKey,
|
|
185
|
+
principalId: string,
|
|
186
|
+
file: string,
|
|
187
|
+
): Promise<boolean> {
|
|
188
|
+
await this.#admit(channel, principalId);
|
|
189
|
+
const staging = this.#stagingDir(channel, principalId);
|
|
190
|
+
if (!(await this.#recordOf(staging, file))) return false;
|
|
191
|
+
await rm(join(staging, file), { force: true });
|
|
192
|
+
await rm(join(staging, RECORDS, `${file}.json`), { force: true });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async discardPending(olderThan: Date): Promise<number> {
|
|
197
|
+
const root = join(this.#options.dataDir, STAGING);
|
|
198
|
+
let discarded = 0;
|
|
199
|
+
for (const principal of await listDir(root)) {
|
|
200
|
+
for (const channel of await listDir(join(root, principal))) {
|
|
201
|
+
const dir = join(root, principal, channel);
|
|
202
|
+
for (const entry of await listDir(dir)) {
|
|
203
|
+
if (entry === RECORDS) continue;
|
|
204
|
+
const info = await stat(join(dir, entry)).catch(() => undefined);
|
|
205
|
+
if (!info || info.mtimeMs >= olderThan.getTime()) continue;
|
|
206
|
+
await rm(join(dir, entry), { force: true });
|
|
207
|
+
await rm(join(dir, RECORDS, `${entry}.json`), { force: true });
|
|
208
|
+
discarded += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return discarded;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async pendingBytes(principalId: string): Promise<number> {
|
|
216
|
+
const root = join(
|
|
217
|
+
this.#options.dataDir,
|
|
218
|
+
STAGING,
|
|
219
|
+
principalSegment(principalId),
|
|
220
|
+
);
|
|
221
|
+
let total = 0;
|
|
222
|
+
for (const channel of await listDir(root)) {
|
|
223
|
+
const dir = join(root, channel);
|
|
224
|
+
for (const entry of await listDir(dir)) {
|
|
225
|
+
if (entry === RECORDS) continue;
|
|
226
|
+
total +=
|
|
227
|
+
(await stat(join(dir, entry)).catch(() => undefined))?.size ?? 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return total;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The record of a staged file, or undefined when it is not staged here. */
|
|
234
|
+
async #recordOf(
|
|
235
|
+
staging: string,
|
|
236
|
+
file: string,
|
|
237
|
+
): Promise<StagedRecord | undefined> {
|
|
238
|
+
if (!isFileName(file)) return undefined;
|
|
239
|
+
const stored = Bun.file(join(staging, RECORDS, `${file}.json`));
|
|
240
|
+
if (
|
|
241
|
+
!(await stored.exists()) ||
|
|
242
|
+
!(await Bun.file(join(staging, file)).exists())
|
|
243
|
+
)
|
|
244
|
+
return undefined;
|
|
245
|
+
const value: unknown = await stored.json();
|
|
246
|
+
return isRecord(value) ? value : undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AttachmentFailure,
|
|
3
|
+
ModelImage,
|
|
4
|
+
StoredAttachment,
|
|
5
|
+
} from "../domain/attachment.ts";
|
|
6
|
+
import type { Logger } from "../log.ts";
|
|
7
|
+
import {
|
|
8
|
+
isModelImage,
|
|
9
|
+
MAX_IMAGES_PER_TURN,
|
|
10
|
+
prepareImage,
|
|
11
|
+
} from "./image-prep.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The first four images among the files, prepared as the model accepts them. An image that cannot
|
|
15
|
+
* be decoded is reported as a failure and the turn goes on without it.
|
|
16
|
+
*/
|
|
17
|
+
export async function modelImagesOf(
|
|
18
|
+
files: readonly StoredAttachment[],
|
|
19
|
+
logger: Logger,
|
|
20
|
+
): Promise<{ images: ModelImage[]; failures: AttachmentFailure[] }> {
|
|
21
|
+
const images: ModelImage[] = [];
|
|
22
|
+
const failures: AttachmentFailure[] = [];
|
|
23
|
+
for (const file of files.filter(isModelImage).slice(0, MAX_IMAGES_PER_TURN)) {
|
|
24
|
+
try {
|
|
25
|
+
images.push(await prepareImage(file));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.warn(
|
|
28
|
+
{ file: file.file, err: error },
|
|
29
|
+
"image could not be prepared",
|
|
30
|
+
);
|
|
31
|
+
failures.push({
|
|
32
|
+
name: file.name,
|
|
33
|
+
reason: "the image could not be decoded",
|
|
34
|
+
fromReference: file.fromReference,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { images, failures };
|
|
39
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { RECORDS } from "./attachment-store.ts";
|
|
3
|
+
|
|
4
|
+
/** A file someone attached to the conversation, as a tool reads it. */
|
|
5
|
+
export interface ToolAttachment {
|
|
6
|
+
/** The file name inside the conversation's attachment store; what `## Attachments` lists. */
|
|
7
|
+
file: string;
|
|
8
|
+
/** The name the person gave it, when the core recorded one; else the file name. */
|
|
9
|
+
name: string;
|
|
10
|
+
contentType: string;
|
|
11
|
+
size: number;
|
|
12
|
+
/** The file's whole content. */
|
|
13
|
+
bytes(): Promise<Uint8Array>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A name that is no attachment of the conversation, or a file that is not there. */
|
|
17
|
+
export class AttachmentLookupError extends Error {
|
|
18
|
+
override name = "AttachmentLookupError";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Recorded {
|
|
22
|
+
name?: unknown;
|
|
23
|
+
contentType?: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Opens one attachment of a conversation's store for a tool. `file` must be a name in `dir`, as
|
|
28
|
+
* `read_attachment` takes it: a path or a hidden name is refused.
|
|
29
|
+
*/
|
|
30
|
+
export async function openAttachment(
|
|
31
|
+
dir: string,
|
|
32
|
+
file: string,
|
|
33
|
+
): Promise<ToolAttachment> {
|
|
34
|
+
if (file === "" || basename(file) !== file || file.startsWith("."))
|
|
35
|
+
throw new AttachmentLookupError(`"${file}" is not an attachment name`);
|
|
36
|
+
const handle = Bun.file(join(dir, file));
|
|
37
|
+
if (!(await handle.exists()))
|
|
38
|
+
throw new AttachmentLookupError(
|
|
39
|
+
`no attachment named "${file}" in this channel`,
|
|
40
|
+
);
|
|
41
|
+
const record = Bun.file(join(dir, RECORDS, `${file}.json`));
|
|
42
|
+
const recorded: Recorded = (await record.exists()) ? await record.json() : {};
|
|
43
|
+
return {
|
|
44
|
+
file,
|
|
45
|
+
name: typeof recorded.name === "string" ? recorded.name : file,
|
|
46
|
+
contentType:
|
|
47
|
+
typeof recorded.contentType === "string"
|
|
48
|
+
? recorded.contentType
|
|
49
|
+
: handle.type,
|
|
50
|
+
size: handle.size,
|
|
51
|
+
bytes: async () => new Uint8Array(await handle.arrayBuffer()),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -2,11 +2,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
|
|
|
2
2
|
import type { InboundMessage } from "../domain/conversation.ts";
|
|
3
3
|
import type { Logger } from "../log.ts";
|
|
4
4
|
import { fetchAttachments } from "./attachment-fetcher.ts";
|
|
5
|
-
import {
|
|
6
|
-
isModelImage,
|
|
7
|
-
MAX_IMAGES_PER_TURN,
|
|
8
|
-
prepareImage,
|
|
9
|
-
} from "./image-prep.ts";
|
|
5
|
+
import { modelImagesOf } from "./model-images.ts";
|
|
10
6
|
|
|
11
7
|
/** Saves the message's and its reference's files, and turns up to four images into model images. */
|
|
12
8
|
export async function collectAttachments(
|
|
@@ -35,22 +31,9 @@ export async function collectAttachments(
|
|
|
35
31
|
);
|
|
36
32
|
const files = [...own.files, ...referenced.files];
|
|
37
33
|
const failures = [...own.failures, ...referenced.failures];
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
images.push(await prepareImage(file));
|
|
42
|
-
} catch (error) {
|
|
43
|
-
logger.warn(
|
|
44
|
-
{ file: file.file, err: error },
|
|
45
|
-
"image could not be prepared",
|
|
46
|
-
);
|
|
47
|
-
failures.push({
|
|
48
|
-
name: file.name,
|
|
49
|
-
reason: "the image could not be decoded",
|
|
50
|
-
fromReference: file.fromReference,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
34
|
+
const prepared = await modelImagesOf(files, logger);
|
|
35
|
+
const images = prepared.images;
|
|
36
|
+
failures.push(...prepared.failures);
|
|
54
37
|
if (files.length > 0 || failures.length > 0) {
|
|
55
38
|
logger.info(
|
|
56
39
|
{
|
|
@@ -331,7 +331,15 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
|
|
|
331
331
|
export function schedulerPlugin(): RoundtablePlugin {
|
|
332
332
|
return {
|
|
333
333
|
name: "schedules",
|
|
334
|
-
setup: ({
|
|
334
|
+
setup: ({
|
|
335
|
+
conversations,
|
|
336
|
+
services,
|
|
337
|
+
env,
|
|
338
|
+
logger,
|
|
339
|
+
surfaces,
|
|
340
|
+
sessions,
|
|
341
|
+
queue,
|
|
342
|
+
}) => {
|
|
335
343
|
const schedules = services.get(SCHEDULES);
|
|
336
344
|
const prechecks = services.find(PRECHECKS);
|
|
337
345
|
const scheduler = new Scheduler({
|
|
@@ -339,6 +347,7 @@ export function schedulerPlugin(): RoundtablePlugin {
|
|
|
339
347
|
runner: services.get(BACKGROUND_TURNS),
|
|
340
348
|
...(prechecks ? { prechecks } : {}),
|
|
341
349
|
holds: () => sessions().holds,
|
|
350
|
+
draining: () => queue.closed,
|
|
342
351
|
// The bot's own message: the surface drops it, so it starts no turn.
|
|
343
352
|
notify: (schedule, note) =>
|
|
344
353
|
surfaces.sendReply(schedule.channel, {
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { AgentSeed } from "../agents/agent-rules.ts";
|
|
4
4
|
import { ConfigError } from "../domain/errors.ts";
|
|
5
5
|
import type { InterimTextMode } from "../domain/interim.ts";
|
|
6
|
+
import { DRAIN_LIMIT_MS } from "../drain.ts";
|
|
6
7
|
import { isLocale, type Locale } from "../i18n/index.ts";
|
|
7
8
|
import type { AccessRules } from "../identity/access-policy.ts";
|
|
8
9
|
import type { OwnerIdentity } from "../identity.ts";
|
|
@@ -164,6 +165,11 @@ export interface RoundtableConfig {
|
|
|
164
165
|
* conversation or no plugin's claim owns it; the web chat takes no error reports.
|
|
165
166
|
*/
|
|
166
167
|
ops?: { agent: string } | { conversation: string };
|
|
168
|
+
/**
|
|
169
|
+
* Seconds a shutdown waits for running turns before stopping them; default 180. Give the
|
|
170
|
+
* service manager a stop timeout a minute longer, such as systemd's `TimeoutStopSec`.
|
|
171
|
+
*/
|
|
172
|
+
drainSeconds?: number;
|
|
167
173
|
/**
|
|
168
174
|
* Whether a turn posts the text it writes before its final answer as it goes: long or
|
|
169
175
|
* structured text as ordinary messages, short narration and the tools called in one small
|
|
@@ -261,6 +267,7 @@ const schema = shape({
|
|
|
261
267
|
),
|
|
262
268
|
memory: optional(bool),
|
|
263
269
|
ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
|
|
270
|
+
drainSeconds: optional(integer(1, 3_600)),
|
|
264
271
|
interimText: optional(oneOf<InterimTextMode>("on", "off")),
|
|
265
272
|
interimPrimaryChars: optional(integer(1, 100_000)),
|
|
266
273
|
plugins: optional(
|
|
@@ -334,6 +341,7 @@ export interface ResolvedConfig {
|
|
|
334
341
|
skills: false | { builtinDir?: string; reposDir?: string };
|
|
335
342
|
memory: boolean;
|
|
336
343
|
ops?: { agent: string } | { conversation: ChannelKey };
|
|
344
|
+
drainMs: number;
|
|
337
345
|
interimText: InterimTextMode;
|
|
338
346
|
interimPrimaryChars: number;
|
|
339
347
|
plugins: RoundtablePlugin[];
|
|
@@ -483,6 +491,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
483
491
|
skills: config.skills ?? (discord ? {} : false),
|
|
484
492
|
memory: config.memory ?? true,
|
|
485
493
|
...(ops ? { ops } : {}),
|
|
494
|
+
drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
|
|
486
495
|
interimText: config.interimText ?? "on",
|
|
487
496
|
interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
|
|
488
497
|
plugins: config.plugins ?? [],
|