pi-roundtable 0.9.3 → 0.9.6
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 +32 -0
- package/README.md +4 -0
- package/README.zh-TW.md +4 -0
- package/docs/plugins.md +32 -6
- package/package.json +1 -1
- package/src/core/agents/agent-prompt.ts +1 -1
- package/src/core/builtin/discord-admin.ts +6 -2
- package/src/core/builtin/discord.ts +10 -0
- package/src/core/config/adapters.ts +47 -0
- package/src/core/config/config.ts +9 -34
- package/src/core/define-roundtable.ts +2 -0
- package/src/core/define.ts +8 -0
- package/src/core/discord/channel-files.ts +107 -0
- package/src/core/discord/channel-operations.ts +100 -33
- package/src/core/discord/discord-surface.ts +21 -4
- package/src/core/discord/dispatch-thread-host.ts +11 -0
- package/src/core/discord/dispatch-threads.ts +14 -0
- package/src/core/discord/inbound-message.ts +34 -0
- package/src/core/discord/owner-card-rows.ts +98 -0
- package/src/core/discord/owner-cards-fixture.ts +3 -0
- package/src/core/discord/owner-cards.ts +137 -105
- package/src/core/host.ts +9 -1
- package/src/core/i18n/cards.ts +6 -8
- package/src/core/i18n/conversation.ts +15 -0
- package/src/core/interactions/prompts.ts +36 -7
- package/src/core/modules/discord-admin/discord-admin.ts +16 -2
- package/src/core/plugin.ts +5 -0
- package/src/core/runtime/compaction-tiers.ts +61 -8
- package/src/core/runtime/conversation-sessions.ts +2 -0
- package/src/core/runtime/extensions/ask-user.ts +21 -4
- package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
- package/src/core/runtime/prompt-slot.ts +6 -4
- package/src/core/runtime/runtime-types.ts +5 -0
- package/src/core/runtime/session-factory.ts +22 -2
- package/src/core/sessions.ts +6 -0
- package/src/core/shared/attach-file-tool.ts +72 -0
- package/src/core/shared/workspace-files.ts +76 -0
- package/src/core/testing/thread-host.ts +14 -0
- package/src/index.ts +2 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/worker.ts +2 -0
- package/src/testing.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.9.6] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The compaction tiers measure the context a compaction left as a request sends it, system prompt and tool definitions included: the first request after the compaction reports it, and until then the conversation's estimate gets the fixed part the last request before it carried (what it sent less the estimate of its messages). They measured the conversation alone, so a session with a large fixed part (over 100k of tools) that compacted to about 270k kept its next compaction at 300k and compacted again every few tool results. `LatestCompaction` adds `measured`, and the `conversation compacted` log adds `contextAfter`, the measure the tiers use, beside `tokensAfter`.
|
|
13
|
+
|
|
14
|
+
## [0.9.5] - 2026-10-10
|
|
15
|
+
|
|
16
|
+
0.9.4 was tagged but never published (its release checks failed on the recorded prompt texts); everything listed under it is released in 0.9.5.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `attach_file({ path, filename? })`: a core tool, in sessions with a workspace (an agent's), that attaches a file from the workspace or the scratch dir to the turn's reply with `attachReplyFile`, under `REPLY_FILE_LIMITS`. Its tier defaults to the owner.
|
|
21
|
+
- `discord_send_message` and `discord_edit_message` take each file as exactly one of `path` or `dataBase64`: a path names a local file inside the session's workspace or scratch dir (resolved through symlinks), read when the call runs, with `filename` defaulting to its base name; the 8 MiB total still applies. A session without a workspace, and remote MCP, take only `dataBase64`. The descriptions tell the model to prefer `path` and keep base64 for small generated data.
|
|
22
|
+
- `SessionContext.workspace` and `ToolTurn.workspace` (`{ workspace, scratchDir? }`): the roots a session's tools may read and save files by path in; `testPlugin`'s `runTool` takes a `workspace` option to set it.
|
|
23
|
+
- `approvalCard(call, workspace?)` lists each file a held call sends by path with its size.
|
|
24
|
+
- `ThreadHost.send(threadId, text)` (optional) posts one editable message in a dispatch thread, and a `DispatchThread` whose host has it carries `interim`, the thread's `InterimPosts`; the Discord thread host and `FakeThreadHost` (which records edits in `edits`) implement it. The kit exports `InterimPoster` and `InterimPosterOptions`, so a worker of a plugin's own posts its interim text as the host's turns do.
|
|
25
|
+
|
|
26
|
+
- `PromptWait` and `LateAnswer`: `Prompts.confirm` and `Prompts.ask` take an optional last `wait`, whose `late(answer)` returns the text of a turn for an answer given after the turn stopped waiting. With it a prompt may resolve the new `Approval` `pending`, or `"pending"` from `ask`, and stay open. Prompts without it, such as a coding worker's, wait for their answer or their turn's stop.
|
|
27
|
+
- `discord.cardGraceSeconds` (default 120): how long a turn waits on its owner card before it goes on without the answer.
|
|
28
|
+
- `HostEnv.scratchDir`: the host's scratch dir, when it has one, so a plugin that runs a shell of its own gives it the same TMPDIR and hold roots as the agents' (`HostEnvironment.scratchDir`; `defineRoundtable` sets it from `scratchDir`).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- The agents' prompt tells them to show a file with `attach_file` or a `discord_send_message` file `path`, and never to read an image or base64 a file to send it.
|
|
33
|
+
- Owner cards no longer expire after 30 minutes, and say nothing of a countdown. A turn waits on its approval or `ask_user` card for the grace period; answered within it, the turn goes on as before. After it, `ask_user` returns that the person has not answered yet and the model ends its turn saying it is waiting, and a held action is blocked as awaiting approval (not held for a text confirmation). The card stays open for the life of the process, and answering it later starts a turn in the same conversation, as a message from whoever answered: a question's turn carries the question and the answer; an approval lets exactly the call shown on the card run once, under the same gate and tier rules, in the turn that tells the model to make it and report the result; a refusal's turn says so. A late card is edited and disabled as before, and a second press does nothing. A stopped turn cancels only a card it still waits on. A coding worker's cards keep the worker waiting, with no deadline. After a restart a card's press says it is no longer valid and to ask again. The `ask_user` description says so instead of "30 minutes".
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- An approval card in an owner conversation sizes a file sent by a relative path from the session's own directory, as the send reads it, instead of showing it as not found.
|
|
38
|
+
- The webchat OIDC test of expired and not-yet-valid tokens no longer fails when the clock crosses a second between signing and verifying.
|
|
39
|
+
|
|
8
40
|
## [0.9.3] - 2026-10-10
|
|
9
41
|
|
|
10
42
|
### Fixed
|
package/README.md
CHANGED
|
@@ -194,6 +194,10 @@ When a conversation is started over in a server channel or thread, the assistant
|
|
|
194
194
|
| text | `─── new conversation ───` | The divider's text |
|
|
195
195
|
| `false` | | Post no divider |
|
|
196
196
|
|
|
197
|
+
An approval card or an `ask_user` question holds its turn for `discord.cardGraceSeconds` (default `120`).
|
|
198
|
+
Answered within it, the turn goes on with the answer; otherwise the turn ends saying it is waiting, the card stays open with no deadline, and answering it later starts a new turn in that conversation with the answer, or with the result of the approved action.
|
|
199
|
+
A coding worker's cards keep the worker waiting instead.
|
|
200
|
+
|
|
197
201
|
## Threat model
|
|
198
202
|
|
|
199
203
|
pi-roundtable runs one assistant for one owner.
|
package/README.zh-TW.md
CHANGED
|
@@ -182,6 +182,10 @@ export default {
|
|
|
182
182
|
| `messageChars` | `500` | 訊息截斷的長度 |
|
|
183
183
|
| `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
|
|
184
184
|
|
|
185
|
+
核准卡和 `ask_user` 問題會讓這一輪等 `discord.cardGraceSeconds` 秒(預設 `120`)。
|
|
186
|
+
在這段時間內回答,這一輪就帶著答案繼續;沒回答的話,這一輪會說它在等你,然後結束,卡片繼續開著、沒有期限,之後再回答就會在同一個對話開新的一輪,帶上你的答案,或核准後那個動作的結果。
|
|
187
|
+
coding worker 的卡片則會讓 worker 一直等下去。
|
|
188
|
+
|
|
185
189
|
## 威脅模型
|
|
186
190
|
|
|
187
191
|
pi-roundtable 為一位擁有者執行一個助理。
|
package/docs/plugins.md
CHANGED
|
@@ -61,13 +61,14 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
61
61
|
Ordinary 48 MP images and thin 9000×1 images remain admitted.
|
|
62
62
|
This helper is not a downloader or filesystem validator.
|
|
63
63
|
- Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
|
|
64
|
+
`InterimPoster(posts, { logger, channel, primaryChars?, editMs? })` posts a run's [interim text](#interim-text-what-a-turn-writes-before-its-final-answer) to an `InterimPosts`, such as a dispatch thread's `interim`: feed it `messageEnd(message)` and `toolStart(name)` from the session's events, bind `() => poster.flush()` as the slot's `beforeCard`, and `await poster.flush()` before the final report.
|
|
64
65
|
- Diagnostics: `scrubDiagnostic(text, max = 600)` masks credentials (URL userinfo, token shapes, secret-named assignments and JSON fields, `Authorization`/`Cookie`/`x-api-key` headers, JWTs, PEM blocks), turns control characters other than tab and newline into spaces, and cuts the result at `max` characters.
|
|
65
66
|
It scans only the first `max * 4` characters (at least 4,096), in linear time, so pass it git, gh or provider error text before showing that text to a user.
|
|
66
67
|
- Shell: `SHELL_TOOLS`, and `shellHoldRule` and `shellHoldRuleFor(policy)`, the hold rule that keeps risky host-shell commands behind the owner's approval; see [the shell rule](#the-shell-rule).
|
|
67
68
|
- Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
|
|
68
69
|
- Mirroring a built-in tool in a worker that cannot reach the host: `SCHEDULE_TOOLS`, `scheduleToolSpecs({ locale, timeZone })`, `isScheduleTool`, `callScheduleTool`, `DELEGATE_TOOL` and `DELEGATE_TOOL_SPEC`.
|
|
69
70
|
The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
|
|
70
|
-
- Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass, prepare?)` to hold a compaction extension back past the ceiling and change what it is given first, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`. `summaryProjection(messages, history?)` is what a shared conversation's compaction may summarize, without anyone's private memory, prompt states, or reasoning, and `privateCompaction(preparation, additionalMessages?)` sets a `session_before_compact` preparation to it. Optional surrounding history pairs calls/results split across summary, prefix and kept context. Independent shared sessions use `memoryProjection(messages, view)` on requests and `hidesPrivateExchange(messages, view)` for raw replay guards (`MemoryView`: `shared` and `reader`; legacy attribution is internal to the bridge guard, not a projection option). `recordMemoryTurn(manager, reader, privateMemory)` appends `MEMORY_TURN_ENTRY` (`roundtable-memory-turn`) before prompting and returns a marker for memory first used later in that turn; `carriesMemory(message)` recognizes built-in memory calls/results and custom `privateTo` results, including immediate failures and unanswered calls. `bridgeHistoryHidesMemory(rawBranch, reader)` checks those records and raw exchanges across compaction; SYSTEM is a reader of its own there, and the core's SYSTEM-as-primary-owner attribution and its pre-scope legacy attribution stay inside the core.
|
|
71
|
+
- Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass, prepare?)` to hold a compaction extension back past the ceiling and change what it is given first, `latest()`: the latest compaction's `contextTokens`, the context it left as a request sends it, system prompt and tools included, `measured` once a request after it reported its usage), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`. `summaryProjection(messages, history?)` is what a shared conversation's compaction may summarize, without anyone's private memory, prompt states, or reasoning, and `privateCompaction(preparation, additionalMessages?)` sets a `session_before_compact` preparation to it. Optional surrounding history pairs calls/results split across summary, prefix and kept context. Independent shared sessions use `memoryProjection(messages, view)` on requests and `hidesPrivateExchange(messages, view)` for raw replay guards (`MemoryView`: `shared` and `reader`; legacy attribution is internal to the bridge guard, not a projection option). `recordMemoryTurn(manager, reader, privateMemory)` appends `MEMORY_TURN_ENTRY` (`roundtable-memory-turn`) before prompting and returns a marker for memory first used later in that turn; `carriesMemory(message)` recognizes built-in memory calls/results and custom `privateTo` results, including immediate failures and unanswered calls. `bridgeHistoryHidesMemory(rawBranch, reader)` checks those records and raw exchanges across compaction; SYSTEM is a reader of its own there, and the core's SYSTEM-as-primary-owner attribution and its pre-scope legacy attribution stay inside the core.
|
|
71
72
|
- Jev compaction: `jevCompact(input, options)` compacts through Jev (pi-jev-compaction 1.0.0) and returns `{ compaction }` or `{ skipped, detail? }` (a `JevSkipReason`: pi-jev-compaction's fallbacks `no_key`, `aborted`, `nothing_to_compact`, `no_candidates`, `cannot_fit`, `jev_error`, `reduction_too_small`, or `previous_summary_too_large`), for Pi's own summary to run instead.
|
|
72
73
|
Its summary carries the previous summary once, as the transcript's `[previous compaction]` message, with `estimatedTokensAfter` measured on the final text; a previous summary over `previousSummaryLimitTokens` (default `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`, 60,000) skips without calling Jev, so Pi's summary condenses the chain.
|
|
73
74
|
Jev judges with `goal` (default `JEV_GOAL`: keep the tool results that set rules still in force, drop stale lookups, listings and finished edits), and the summary ends with a `## Rules loaded before this compaction` section naming each rule load among the summarized messages (tool name and JSON arguments cut at 200 characters) for the agent to load again; `ruleLoad(tool, args)` picks them (default `isRuleLoad`: tools ending in `invoke-skill` or `get-system-prompt`, and tools ending in `read` whose `path` is a `SKILL.md`, an `AGENTS.md` or under `.agents/skills/`).
|
|
@@ -127,7 +128,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
127
128
|
| Field | What it is |
|
|
128
129
|
|---|---|
|
|
129
130
|
| `logger` | The host's logger with `plugin: <your name>` on every line; each line is JSON on stdout. Its type is `Logger` (`debug`, `info`, `warn`, `error`, `fatal`, `child(fields)`), which a pino logger satisfies |
|
|
130
|
-
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone),
|
|
131
|
+
| `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, and `scratchDir` when the host has one; read the zone from here rather than from the process, and give `scratchDir` to a shell the plugin runs itself |
|
|
131
132
|
| `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
|
|
132
133
|
| `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
|
|
133
134
|
| `events` | Where the core reports turns and team changes to every plugin's handlers |
|
|
@@ -556,7 +557,8 @@ Once a turn has used a file, the model reads it with `read_attachment`, and a to
|
|
|
556
557
|
### `tools`: what agents can call
|
|
557
558
|
|
|
558
559
|
`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.
|
|
559
|
-
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `attachFile`, and `attachment`.
|
|
560
|
+
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `workspace`, `attachFile`, and `attachment`.
|
|
561
|
+
`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.
|
|
560
562
|
It returns the text the model reads.
|
|
561
563
|
`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.
|
|
562
564
|
Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
|
|
@@ -644,6 +646,9 @@ Raw `sessionTools` and Pi package tools may import and call `attachReplyFile` du
|
|
|
644
646
|
It uses the current asynchronous turn, not a global channel queue, so simultaneous turns and nested conversation turns keep separate files.
|
|
645
647
|
A Pi package should use the host's peer dependency on `pi-roundtable`, not bundle another copy of it.
|
|
646
648
|
Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
|
|
649
|
+
|
|
650
|
+
The model attaches a file it already has on disk with the core's `attach_file({ path, filename? })`. A session with a workspace (`SessionContext.workspace`, an agent's) registers it; the path must resolve, symlinks followed, inside the workspace or the scratch dir, a relative path resolves in the workspace, and `filename` defaults to the path's base name. It queues the file with `attachReplyFile`, so `REPLY_FILE_LIMITS` apply and a surface without reply files refuses it as a tool error; its result tells the model the file will appear with its reply. It holds nothing, since it only posts in the channel the turn answers in, and its tier defaults to the owner, like any tool the core's table does not name.
|
|
651
|
+
The Discord tools that upload, `discord_send_message` and `discord_edit_message`, take each file as exactly one of `path` (a local file, read when the call runs, with the same roots; `filename` defaults to its base name) or `dataBase64` with a `filename`, at most 8 MiB in all. A session without a workspace takes only `dataBase64`, and so does remote MCP, whose schemas stay inline only. A path outside the roots, a missing file, or a directory is a tool error naming the path. A held call's card (`approvalCard(call, workspace?)`) lists each file it sends by path with the file's size now, never its bytes.
|
|
647
652
|
Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
|
|
648
653
|
Await work that produces files before returning from the tool.
|
|
649
654
|
A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
|
|
@@ -718,7 +723,7 @@ A rule whose held call stands for others can answer `approvalTier(tool, input, c
|
|
|
718
723
|
`shellHoldRule` in `pi-roundtable/kit` judges the agents' `bash`, `write` and `edit` calls; the agent server links it, and a session without a workspace has no shell.
|
|
719
724
|
Its scratch roots are the shared workspace (`HoldContext.workspace`) and the scratch dir (`HoldContext.scratchDir`).
|
|
720
725
|
The scratch dir is the config's `scratchDir`, by default `<os temp dir>/<discord.rootCommand>-scratch` (such as `/tmp/roundtable-scratch`); the agent server creates it with mode 0700 at startup and refuses one that is a symlink or another user's, and the agents' `bash` runs with `TMPDIR` pointing to it, so `mktemp` and tools write there.
|
|
721
|
-
`AgentSessions.scratchDir` carries it to a runtime, and the agents' prompt tells them to write temporary files there.
|
|
726
|
+
`AgentSessions.scratchDir` carries it to a runtime, and the agents' prompt tells them to write temporary files there, and to show a file with `attach_file` or a `discord_send_message` file `path` rather than reading an image or base64-encoding it.
|
|
722
727
|
|
|
723
728
|
- A `>`, `>>` or `tee` target, and a `write` or `edit` path, inside a scratch root run; anything else, the rest of `/tmp` included, is held.
|
|
724
729
|
- An `rm` runs when every operand resolves inside a scratch root and none is a root itself or `/`. Operands resolve after the variables assigned earlier in the same command line (`NAME=value` and `export NAME=value` with literal values; `$TMPDIR` is the scratch dir and `$HOME` the service user's home), from the directory of the last `cd`, through `..` and, for paths that exist, symlinks, so a link out of a root is held. A glob is judged by its directory part. A command substitution, an unknown variable, `~user`, an `rm` without operands, and `xargs rm` are held.
|
|
@@ -2391,6 +2396,22 @@ On Discord the owners are every owner with a Discord identity, and the primary o
|
|
|
2391
2396
|
A principal with no Discord identity, such as the reporter of a background report, answers nothing there, so their card goes to the owners; so does an owner-tier call of a turn whose tier defaulted to owner when its principal is not an owner.
|
|
2392
2397
|
The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier expires at once whatever the scope says.
|
|
2393
2398
|
|
|
2399
|
+
#### A prompt that outlives its turn: `PromptWait`
|
|
2400
|
+
|
|
2401
|
+
`confirm(title, message, signal?, minTier?, wait?)` and `ask(title, question, signal?, wait?)` take an optional `PromptWait`, whose `late(answer)` hears a `LateAnswer` (`{ kind: "approval", approved, by }` or `{ kind: "question", answer, by }`, `by` naming who answered) and returns the text of the turn that tells the conversation, or undefined for none.
|
|
2402
|
+
Given one, a surface may stop the turn's wait after a grace period: `confirm` then resolves `pending` and `ask` resolves `"pending"`, the prompt stays open, and when it is answered later the surface starts a turn in the prompt's conversation with that text, as a message from whoever answered, so the conversation's claim answers it as theirs.
|
|
2403
|
+
Without one, a prompt waits for its answer, or for its turn's stop; a surface that cannot start a turn ignores `wait` and waits too.
|
|
2404
|
+
A stop cancels a prompt only while its turn still waits on it; after that the prompt belongs to the conversation.
|
|
2405
|
+
|
|
2406
|
+
The runtime gives both of its prompts a `wait`.
|
|
2407
|
+
`ask_user` then tells the model the person has not answered yet and to end its turn saying it is waiting, without asking again, and the late answer's turn carries the question and the answer.
|
|
2408
|
+
A held action's call is blocked as awaiting the approval, and is not held for a later message; a late approval lets that exact call (same tool, same canonical input) run once in whichever turn makes it next, and its turn tells the model to make it now and report the result, so the call runs under the gate and tier rules every call does; a late refusal starts a turn that says so.
|
|
2409
|
+
A coding worker's cards give no `wait`, since nothing could resume a worker: it waits for the answer, and the waiting does not count against its time.
|
|
2410
|
+
|
|
2411
|
+
On Discord the grace period is `discord.cardGraceSeconds` (default 120), and the cards carry no countdown.
|
|
2412
|
+
A card answered late is edited to show the answer with its controls disabled, as one answered in time is; a second press finds it answered.
|
|
2413
|
+
Open cards live in memory: after a restart, pressing one replies, to the presser alone, that it is no longer valid and to ask again.
|
|
2414
|
+
|
|
2394
2415
|
#### Interim text: what a turn writes before its final answer
|
|
2395
2416
|
|
|
2396
2417
|
A model often writes text in an assistant message that then calls tools, such as a proposal before it asks `ask_user` "go with this version?".
|
|
@@ -2402,7 +2423,8 @@ On a surface that gives `interim`, and in the agent server's channels through th
|
|
|
2402
2423
|
Before any card, an `ask_user` question or an approval, the host posts the pending text and brings the progress message up to date, so what the model wrote before the card shows above it.
|
|
2403
2424
|
The final reply is posted at the end as before, with its thinking line; an intermediate message is never the final one, so nothing is posted twice, and a steered run keeps its final text.
|
|
2404
2425
|
A failed interim post or edit is logged and never fails the turn.
|
|
2405
|
-
Turns with nowhere to post, such as transient tasks
|
|
2426
|
+
Turns with nowhere to post, such as transient tasks and turns of a claim that passes its own `reply` to `context.turns.run`, post only their final reply.
|
|
2427
|
+
A coding worker posts the same way into its dispatch thread, under the name its report uses, when the thread's host can edit its posts (`ThreadHost.send`, which the Discord thread host has; a `DispatchThread` then carries `interim`): its approval cards come after the text it wrote before them, and its report is posted once, at the end.
|
|
2406
2428
|
A runtime that fills [the `runtime` slot](#the-runtime-slot-replace-pi) receives the place to post as `TurnRequest.interim` and may use it or not.
|
|
2407
2429
|
|
|
2408
2430
|
The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
|
|
@@ -2864,7 +2886,7 @@ It returns:
|
|
|
2864
2886
|
| `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
|
|
2865
2887
|
| `tools`, `tiers` | The tool names, and the table that says what tier each needs |
|
|
2866
2888
|
| `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined` |
|
|
2867
|
-
| `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
|
|
2889
|
+
| `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
|
|
2868
2890
|
| `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
|
|
2869
2891
|
| `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
|
|
2870
2892
|
| `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
|
|
@@ -3314,6 +3336,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3314
3336
|
| `Judge` | `pi-roundtable` | type |
|
|
3315
3337
|
| `JudgeError` | `pi-roundtable` | value |
|
|
3316
3338
|
| `JudgeModel` | `pi-roundtable` | type |
|
|
3339
|
+
| `LateAnswer` | `pi-roundtable` | type |
|
|
3317
3340
|
| `LinkedSessions` | `pi-roundtable` | type |
|
|
3318
3341
|
| `LinkSource` | `pi-roundtable` | type |
|
|
3319
3342
|
| `ListenerAddress` | `pi-roundtable` | type |
|
|
@@ -3353,6 +3376,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
3353
3376
|
| `PromptScope` | `pi-roundtable` | type |
|
|
3354
3377
|
| `PromptSection` | `pi-roundtable` | type |
|
|
3355
3378
|
| `PromptTurn` | `pi-roundtable` | type |
|
|
3379
|
+
| `PromptWait` | `pi-roundtable` | type |
|
|
3356
3380
|
| `Prompts` | `pi-roundtable` | type |
|
|
3357
3381
|
| `Pronouns` | `pi-roundtable` | type |
|
|
3358
3382
|
| `ProviderError` | `pi-roundtable` | value |
|
|
@@ -3587,6 +3611,8 @@ Import from the entries listed below; source area files are internal.
|
|
|
3587
3611
|
| `CompactionHistory` | `pi-roundtable/kit` | type |
|
|
3588
3612
|
| `LatestCompaction` | `pi-roundtable/kit` | type |
|
|
3589
3613
|
| `isRuleLoad` | `pi-roundtable/kit` | value |
|
|
3614
|
+
| `InterimPoster` | `pi-roundtable/kit` | value |
|
|
3615
|
+
| `InterimPosterOptions` | `pi-roundtable/kit` | type |
|
|
3590
3616
|
| `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
|
|
3591
3617
|
| `JEV_GOAL` | `pi-roundtable/kit` | value |
|
|
3592
3618
|
| `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
|
package/package.json
CHANGED
|
@@ -37,7 +37,7 @@ export function agentSystemPrompt(input: {
|
|
|
37
37
|
`You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
|
|
38
38
|
roleText(agent, coordinator, who),
|
|
39
39
|
`The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; ${input.avatars === false ? "" : "agent_avatar redraws an avatar; "}schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
|
|
40
|
-
`Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm.`,
|
|
40
|
+
`Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.`,
|
|
41
41
|
];
|
|
42
42
|
if (group) {
|
|
43
43
|
const others = group.members
|
|
@@ -30,8 +30,12 @@ export function discordAdminPlugin(
|
|
|
30
30
|
const { connection } = services.get(DISCORD);
|
|
31
31
|
return {
|
|
32
32
|
sessionTools: [
|
|
33
|
-
fixed("discord-admin", () =>
|
|
34
|
-
discordAdminExtension(
|
|
33
|
+
fixed("discord-admin", (session) =>
|
|
34
|
+
discordAdminExtension(
|
|
35
|
+
connection.ownerOperations(),
|
|
36
|
+
owner,
|
|
37
|
+
session.workspace,
|
|
38
|
+
),
|
|
35
39
|
),
|
|
36
40
|
],
|
|
37
41
|
// Administering the server stays with the owner until an operator lowers a tool.
|
|
@@ -67,6 +67,11 @@ export interface DiscordOptions {
|
|
|
67
67
|
channelContext?: ChannelContextOptions | false;
|
|
68
68
|
/** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
|
|
69
69
|
freshMarker?: string | false;
|
|
70
|
+
/**
|
|
71
|
+
* How long a turn waits on its owner card before it goes on without the answer, leaving the
|
|
72
|
+
* card open; a later answer starts a new turn. Default `CARD_GRACE_MS`, two minutes.
|
|
73
|
+
*/
|
|
74
|
+
cardGraceMs?: number;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/**
|
|
@@ -96,6 +101,10 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
96
101
|
...(identity ? { identity } : {}),
|
|
97
102
|
channel: (channelId) => connected.cardChannel(channelId),
|
|
98
103
|
logger,
|
|
104
|
+
...(options.cardGraceMs === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { graceMs: options.cardGraceMs }),
|
|
107
|
+
resume: (turn) => connected.resume(turn),
|
|
99
108
|
});
|
|
100
109
|
const connected = new DiscordSurface({
|
|
101
110
|
token: options.token,
|
|
@@ -124,6 +133,7 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
124
133
|
open: (parentId, name, line) =>
|
|
125
134
|
connected.threadHost().open(parentId, name, line),
|
|
126
135
|
post: (threadId, text) => connected.threadHost().post(threadId, text),
|
|
136
|
+
send: (threadId, text) => connected.threadHost().send(threadId, text),
|
|
127
137
|
close: (threadId) => connected.threadHost().close(threadId),
|
|
128
138
|
},
|
|
129
139
|
ledgerPath: join(options.dataDir, "dispatch-threads.json"),
|
|
@@ -50,6 +50,52 @@ export interface DiscordConfig {
|
|
|
50
50
|
* never in a direct message. Default `─── new conversation ───`; `false` posts none.
|
|
51
51
|
*/
|
|
52
52
|
freshMarker?: string | false;
|
|
53
|
+
/**
|
|
54
|
+
* Seconds a turn waits on an owner card (an approval or an `ask_user` question) before it goes
|
|
55
|
+
* on without the answer; the card stays open, and answering it later starts a new turn with
|
|
56
|
+
* the answer. Default 120.
|
|
57
|
+
*/
|
|
58
|
+
cardGraceSeconds?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The Discord settings with every default filled in. */
|
|
62
|
+
export interface ResolvedDiscord {
|
|
63
|
+
token: string;
|
|
64
|
+
guild: string;
|
|
65
|
+
entryChannel: string;
|
|
66
|
+
rootCommand: string;
|
|
67
|
+
admin: boolean;
|
|
68
|
+
refusalHint?: string;
|
|
69
|
+
agentMemory?: "everyone" | "owners";
|
|
70
|
+
/** As configured: `{}` when left out, so the defaults apply, or `false`. */
|
|
71
|
+
channelContext: NonNullable<DiscordConfig["channelContext"]>;
|
|
72
|
+
freshMarker?: string | false;
|
|
73
|
+
cardGraceSeconds: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The configured Discord settings, defaults filled in; `rootCommand` is the host's slug. */
|
|
77
|
+
export function resolvedDiscord(
|
|
78
|
+
discord: DiscordConfig,
|
|
79
|
+
rootCommand: string,
|
|
80
|
+
): ResolvedDiscord {
|
|
81
|
+
return {
|
|
82
|
+
token: discord.token,
|
|
83
|
+
guild: discord.guild,
|
|
84
|
+
entryChannel: discord.entryChannel,
|
|
85
|
+
rootCommand,
|
|
86
|
+
admin: discord.admin ?? true,
|
|
87
|
+
...(discord.refusalHint === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { refusalHint: discord.refusalHint }),
|
|
90
|
+
...(discord.agentMemory === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { agentMemory: discord.agentMemory }),
|
|
93
|
+
channelContext: discord.channelContext ?? {},
|
|
94
|
+
...(discord.freshMarker === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { freshMarker: discord.freshMarker }),
|
|
97
|
+
cardGraceSeconds: discord.cardGraceSeconds ?? 120,
|
|
98
|
+
};
|
|
53
99
|
}
|
|
54
100
|
|
|
55
101
|
/** What `discord.channelContext` takes besides `false`. */
|
|
@@ -72,6 +118,7 @@ export const discordShape = shape({
|
|
|
72
118
|
agentMemory: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
|
|
73
119
|
channelContext: optional(orOff(channelContextShape)),
|
|
74
120
|
freshMarker: optional(orOff(text)),
|
|
121
|
+
cardGraceSeconds: optional(integer(1, 86_400)),
|
|
75
122
|
});
|
|
76
123
|
|
|
77
124
|
/** The adapters `adapters` takes, by the name their factory gives them. */
|
|
@@ -19,7 +19,13 @@ import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
|
|
|
19
19
|
import type { ChannelKey } from "../sessions.ts";
|
|
20
20
|
import type { Tier, TierMembers } from "../speakers.ts";
|
|
21
21
|
import { type AccessConfig, accessOf, accessShape } from "./access.ts";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
type DiscordConfig,
|
|
24
|
+
discordShape,
|
|
25
|
+
liftAdapters,
|
|
26
|
+
type ResolvedDiscord,
|
|
27
|
+
resolvedDiscord,
|
|
28
|
+
} from "./adapters.ts";
|
|
23
29
|
import {
|
|
24
30
|
bool,
|
|
25
31
|
guarded,
|
|
@@ -297,18 +303,7 @@ export interface ResolvedConfig {
|
|
|
297
303
|
/** What the configuration wrote in a deprecated form, which the host logs once. */
|
|
298
304
|
deprecations: string[];
|
|
299
305
|
/** Undefined on a host without Discord. */
|
|
300
|
-
discord?:
|
|
301
|
-
token: string;
|
|
302
|
-
guild: string;
|
|
303
|
-
entryChannel: string;
|
|
304
|
-
rootCommand: string;
|
|
305
|
-
admin: boolean;
|
|
306
|
-
refusalHint?: string;
|
|
307
|
-
agentMemory?: "everyone" | "owners";
|
|
308
|
-
/** As configured: `{}` when left out, so the defaults apply, or `false`. */
|
|
309
|
-
channelContext: NonNullable<DiscordConfig["channelContext"]>;
|
|
310
|
-
freshMarker?: string | false;
|
|
311
|
-
};
|
|
306
|
+
discord?: ResolvedDiscord;
|
|
312
307
|
databaseUrl: string;
|
|
313
308
|
dataDir: string;
|
|
314
309
|
agentDir: string;
|
|
@@ -423,27 +418,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
|
|
|
423
418
|
name: primaryOwner.name,
|
|
424
419
|
pronouns: primaryOwner.pronouns,
|
|
425
420
|
},
|
|
426
|
-
...(discord
|
|
427
|
-
? {
|
|
428
|
-
discord: {
|
|
429
|
-
token: discord.token,
|
|
430
|
-
guild: discord.guild,
|
|
431
|
-
entryChannel: discord.entryChannel,
|
|
432
|
-
rootCommand: slug,
|
|
433
|
-
admin: discord.admin ?? true,
|
|
434
|
-
...(discord.refusalHint === undefined
|
|
435
|
-
? {}
|
|
436
|
-
: { refusalHint: discord.refusalHint }),
|
|
437
|
-
...(discord.agentMemory === undefined
|
|
438
|
-
? {}
|
|
439
|
-
: { agentMemory: discord.agentMemory }),
|
|
440
|
-
channelContext: discord.channelContext ?? {},
|
|
441
|
-
...(discord.freshMarker === undefined
|
|
442
|
-
? {}
|
|
443
|
-
: { freshMarker: discord.freshMarker }),
|
|
444
|
-
},
|
|
445
|
-
}
|
|
446
|
-
: {}),
|
|
421
|
+
...(discord ? { discord: resolvedDiscord(discord, slug) } : {}),
|
|
447
422
|
databaseUrl: config.database.url,
|
|
448
423
|
dataDir: config.dataDir,
|
|
449
424
|
agentDir: config.agentDir ?? `${config.dataDir}/pi`,
|
|
@@ -172,6 +172,7 @@ function discordAssembly(
|
|
|
172
172
|
...(discord.freshMarker === undefined
|
|
173
173
|
? {}
|
|
174
174
|
: { freshMarker: discord.freshMarker }),
|
|
175
|
+
cardGraceMs: discord.cardGraceSeconds * 1_000,
|
|
175
176
|
}),
|
|
176
177
|
admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
|
|
177
178
|
skills: config.skills
|
|
@@ -274,6 +275,7 @@ export async function defineRoundtable(
|
|
|
274
275
|
assistant: name,
|
|
275
276
|
rootCommand: config.slug,
|
|
276
277
|
agentDir: config.agentDir,
|
|
278
|
+
scratchDir: config.scratchDir,
|
|
277
279
|
},
|
|
278
280
|
database: { url: config.databaseUrl },
|
|
279
281
|
dataDir: config.dataDir,
|
package/src/core/define.ts
CHANGED
|
@@ -29,6 +29,11 @@ export interface ToolTurn {
|
|
|
29
29
|
/** The agent whose turn it is. */
|
|
30
30
|
agent: AgentTurnScope | undefined;
|
|
31
31
|
signal: AbortSignal | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The session's shared workspace and scratch dir, where a tool may save a file for the model to
|
|
34
|
+
* pass on by path; undefined for a session without a workspace.
|
|
35
|
+
*/
|
|
36
|
+
workspace?: { workspace: string; scratchDir?: string };
|
|
32
37
|
/** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
|
|
33
38
|
attachFile(file: ReplyFile): void;
|
|
34
39
|
/**
|
|
@@ -124,6 +129,9 @@ export function defineTool<Schema extends TObject>(
|
|
|
124
129
|
channel: context.turnChannel,
|
|
125
130
|
agent: context.agent,
|
|
126
131
|
signal,
|
|
132
|
+
...(context.workspace
|
|
133
|
+
? { workspace: context.workspace }
|
|
134
|
+
: {}),
|
|
127
135
|
attachFile: attachReplyFile,
|
|
128
136
|
attachment: async (file) => {
|
|
129
137
|
if (context.attachmentDir === undefined)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findWorkspaceFile,
|
|
3
|
+
readWorkspaceFile,
|
|
4
|
+
type WorkspaceFile,
|
|
5
|
+
WorkspaceFileError,
|
|
6
|
+
type WorkspaceRoots,
|
|
7
|
+
} from "../shared/workspace-files.ts";
|
|
8
|
+
import {
|
|
9
|
+
CHANNEL_UPLOAD_MAX_BYTES,
|
|
10
|
+
ChannelToolError,
|
|
11
|
+
FILE_CHANNEL_TOOLS,
|
|
12
|
+
} from "./channel-operations.ts";
|
|
13
|
+
|
|
14
|
+
interface SessionFile {
|
|
15
|
+
path?: string;
|
|
16
|
+
filename?: string;
|
|
17
|
+
dataBase64?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const refuse = (code: string, detail: string) =>
|
|
22
|
+
new ChannelToolError(`${code}: ${detail}`);
|
|
23
|
+
|
|
24
|
+
/** The bytes a base64 string carries, before it is checked; `parseChannelTool` checks it. */
|
|
25
|
+
const base64Bytes = (data: string) =>
|
|
26
|
+
Math.floor((data.length * 3) / 4) -
|
|
27
|
+
(data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A session's call to a file tool in the inline form `parseChannelTool` takes: each file names
|
|
31
|
+
* exactly one of `path` or `dataBase64`, and a path is read from the session's workspace or scratch
|
|
32
|
+
* dir. Without `roots` only inline files are accepted. Other tools' arguments pass unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveChannelFiles(
|
|
35
|
+
tool: string,
|
|
36
|
+
args: Record<string, unknown>,
|
|
37
|
+
roots: WorkspaceRoots | undefined,
|
|
38
|
+
): Promise<Record<string, unknown>> {
|
|
39
|
+
if (!FILE_CHANNEL_TOOLS.includes(tool) || !Array.isArray(args.files))
|
|
40
|
+
return args;
|
|
41
|
+
const entries = args.files as SessionFile[];
|
|
42
|
+
const found: (WorkspaceFile | undefined)[] = [];
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const hasPath = typeof entry?.path === "string";
|
|
46
|
+
const hasData = typeof entry?.dataBase64 === "string";
|
|
47
|
+
if (hasPath === hasData)
|
|
48
|
+
throw refuse(
|
|
49
|
+
"INVALID_CHANNEL_FILE",
|
|
50
|
+
"give each file exactly one of path or dataBase64",
|
|
51
|
+
);
|
|
52
|
+
if (hasData) {
|
|
53
|
+
if (typeof entry.filename !== "string")
|
|
54
|
+
throw refuse(
|
|
55
|
+
"INVALID_CHANNEL_FILE",
|
|
56
|
+
"a file given as dataBase64 needs a filename",
|
|
57
|
+
);
|
|
58
|
+
bytes += base64Bytes(entry.dataBase64 as string);
|
|
59
|
+
found.push(undefined);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!roots)
|
|
63
|
+
throw refuse(
|
|
64
|
+
"INVALID_CHANNEL_FILE_PATH",
|
|
65
|
+
"this session has no workspace, so it can send files only as dataBase64",
|
|
66
|
+
);
|
|
67
|
+
const file = await find(entry.path as string, roots);
|
|
68
|
+
bytes += file.size;
|
|
69
|
+
found.push(file);
|
|
70
|
+
}
|
|
71
|
+
if (bytes > CHANNEL_UPLOAD_MAX_BYTES)
|
|
72
|
+
throw new ChannelToolError("INVALID_CHANNEL_UPLOAD_SIZE");
|
|
73
|
+
const files = await Promise.all(
|
|
74
|
+
entries.map(async (entry, index) => {
|
|
75
|
+
const file = found[index];
|
|
76
|
+
if (!file) return entry;
|
|
77
|
+
const data = await read(file);
|
|
78
|
+
const { path: _path, ...rest } = entry;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
filename: entry.filename ?? file.name,
|
|
82
|
+
dataBase64: Buffer.from(data).toString("base64"),
|
|
83
|
+
};
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
return { ...args, files };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function find(path: string, roots: WorkspaceRoots) {
|
|
90
|
+
try {
|
|
91
|
+
return await findWorkspaceFile(path, roots);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof WorkspaceFileError)
|
|
94
|
+
throw refuse("INVALID_CHANNEL_FILE_PATH", error.message);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function read(file: WorkspaceFile) {
|
|
100
|
+
try {
|
|
101
|
+
return await readWorkspaceFile(file, CHANNEL_UPLOAD_MAX_BYTES);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof WorkspaceFileError)
|
|
104
|
+
throw new ChannelToolError("INVALID_CHANNEL_UPLOAD_SIZE");
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|