pi-roundtable 0.9.2 → 0.9.5

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +36 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/attachments/attachment-dir.ts +51 -0
  8. package/src/core/attachments/attachment-fetcher.ts +30 -6
  9. package/src/core/attachments/attachment-name.ts +34 -0
  10. package/src/core/attachments/attachment-store.ts +169 -54
  11. package/src/core/attachments/prompt-block.ts +6 -2
  12. package/src/core/attachments/tool-attachment.ts +14 -5
  13. package/src/core/builtin/discord-admin.ts +6 -2
  14. package/src/core/builtin/discord.ts +10 -0
  15. package/src/core/config/adapters.ts +47 -0
  16. package/src/core/config/config.ts +9 -34
  17. package/src/core/contract/attachments.ts +23 -2
  18. package/src/core/define-roundtable.ts +2 -0
  19. package/src/core/define.ts +8 -0
  20. package/src/core/discord/channel-files.ts +107 -0
  21. package/src/core/discord/channel-operations.ts +100 -33
  22. package/src/core/discord/discord-surface.ts +21 -4
  23. package/src/core/discord/dispatch-thread-host.ts +11 -0
  24. package/src/core/discord/dispatch-threads.ts +14 -0
  25. package/src/core/discord/inbound-message.ts +34 -0
  26. package/src/core/discord/owner-card-rows.ts +98 -0
  27. package/src/core/discord/owner-cards-fixture.ts +3 -0
  28. package/src/core/discord/owner-cards.ts +137 -105
  29. package/src/core/host.ts +9 -1
  30. package/src/core/i18n/cards.ts +6 -8
  31. package/src/core/i18n/conversation.ts +15 -0
  32. package/src/core/interactions/prompts.ts +36 -7
  33. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  34. package/src/core/plugin.ts +5 -0
  35. package/src/core/runtime/conversation-sessions.ts +2 -0
  36. package/src/core/runtime/extensions/ask-user.ts +21 -4
  37. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  38. package/src/core/runtime/pi-agent-runtime.ts +5 -1
  39. package/src/core/runtime/prompt-slot.ts +6 -4
  40. package/src/core/runtime/runtime-types.ts +5 -0
  41. package/src/core/runtime/session-factory.ts +17 -0
  42. package/src/core/sessions.ts +6 -0
  43. package/src/core/shared/attach-file-tool.ts +72 -0
  44. package/src/core/shared/attachment-reader.ts +11 -4
  45. package/src/core/shared/workspace-files.ts +76 -0
  46. package/src/core/testing/thread-host.ts +14 -0
  47. package/src/index.ts +3 -0
  48. package/src/kit/index.ts +2 -0
  49. package/src/kit/worker.ts +2 -0
  50. package/src/testing.ts +7 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.5] - 2026-10-10
9
+
10
+ 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.
11
+
12
+ ### Added
13
+
14
+ - `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.
15
+ - `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.
16
+ - `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.
17
+ - `approvalCard(call, workspace?)` lists each file a held call sends by path with its size.
18
+ - `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.
19
+
20
+ - `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.
21
+ - `discord.cardGraceSeconds` (default 120): how long a turn waits on its owner card before it goes on without the answer.
22
+ - `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`).
23
+
24
+ ### Changed
25
+
26
+ - 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.
27
+ - 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".
28
+
29
+ ### Fixed
30
+
31
+ - 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.
32
+ - The webchat OIDC test of expired and not-yet-valid tokens no longer fails when the clock crosses a second between signing and verifying.
33
+
34
+ ## [0.9.3] - 2026-10-10
35
+
36
+ ### Fixed
37
+
38
+ - A long non-ASCII upload name no longer fails with `ENAMETOOLONG` on Linux: a stored file name is cut by UTF-8 bytes (150 at most, on a code point, keeping the end so the extension stays) instead of by UTF-16 units, so the file name and its `.json` record fit the 255 bytes a file system takes. A leading dot left by the cut is replaced too. The Discord download path shares the fix. `AttachmentPort.save` removes the data file when the record write fails, so a half-saved file no longer counts as waiting.
39
+ - Calls on one principal's attachments run one after another, so two messages that name overlapping files no longer leave the refused one's other files moved into the conversation: `turnAttachments` moves the whole set or none, rolls back a partial move, and answers a file a sweep or a message took meanwhile with `AttachmentRefusal` (`unknown_file`) instead of a raw `ENOENT`.
40
+ - The `## Attachments` block prints a name and a content type a prompt can trust: control characters, line and paragraph separators and bidi controls in a name become underscores and quotes become apostrophes, and a content type that is not a plain `type/subtype` prints as `application/octet-stream`.
41
+ - `ToolTurn.attachment()` and `read_attachment` refuse a name with a NUL byte as "not an attachment name"; a NUL name no longer surfaces a runtime error that quoted the host's attachment directory, and a file that cannot be read reports its name, not its path.
42
+ - `discardPending` removes the staging directories it emptied, so a conversation's uploads leave no empty directories behind.
43
+ - `deleteConversation` of the Pi runtime removes the conversation's attachment directory, the files saved for it that no turn used, and its share of the used-bytes tally, so a deleted conversation keeps no files (a conversation deleted in the instant a message is taking its files can leave that message's share of the tally behind).
44
+
45
+ ### Added
46
+
47
+ - `TurnAttachmentOptions` and `AttachmentPort.turnAttachments(channel, principalId, files, options?)`: `options.usedBytesLimit` caps the bytes one principal's turns keep across all their conversations. A call that would pass it moves nothing and is refused with the new `AttachmentRefusalCode` `quota_exceeded`; without it the core sets no limit. The core tallies the bytes per principal and conversation, and deleting a conversation gives its bytes back.
48
+
49
+ ### Documented
50
+
51
+ - The guide says what the port checks (a private conversation's record) and what it does not: a channel with no record, or any channel on a host with no conversation registry, is admitted for every principal, so the plugin that owns a channel checks that the caller may use it before every call.
52
+
8
53
  ## [0.9.2] - 2026-10-10
9
54
 
10
55
  ### Added
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,6 +61,7 @@ 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).
@@ -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), and `now()`; read the zone from here rather than from the process |
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 |
@@ -538,14 +539,16 @@ A plugin whose clients upload a file themselves, over HTTP for instance, has the
538
539
  | Call | What it does |
539
540
  |---|---|
540
541
  | `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
+ | `turnAttachments(channel, principalId, files, options?)` | 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`), or when `options.usedBytesLimit` would be passed (`quota_exceeded`); then none moves, even when other calls for the same principal run at once |
542
543
  | `remove(channel, principalId, file)` | Discards a saved file no turn used; `false` when there is none |
543
544
  | `discardPending(olderThan)` | Discards every saved file no turn used that was saved before the date, and returns how many; call it from a service's timer so abandoned uploads do not pile up |
544
545
  | `pendingBytes(principalId)` | The bytes the principal has saved that no turn used yet, for a quota of your own |
545
546
 
546
547
  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
548
  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 plugin that owns a channel checks that the caller may use it, before every call.** The port knows only the conversation's record: a channel with no record yet, such as a conversation opened but not written in, is admitted for every principal, and so is every channel on a host with no conversation registry. A plugin that takes the channel key from a person (a URL, a frame) and passes it on unchecked lets that person stage files into, and move files into the attachment directory of, a conversation that is not theirs. Check first that the channel is the person's, as the web chat does before it saves or uses a file.
550
+ `usedBytesLimit` caps what one principal's turns keep, across all their conversations; the core sets no limit of its own, and a deleted conversation gives its bytes back.
551
+ Deleting a conversation (`runtime.deleteConversation`) also removes its attachment directory, the files saved for it that no turn used, and its share of the tally.
549
552
  The calls reject with `AttachmentRefusal`, and throw a `PluginError` when the host has no `dataDir` (a host built by `defineRoundtable` always has one).
550
553
  The core limits only the size; the types a plugin accepts, the number of files, and the rate are its own to limit.
551
554
 
@@ -554,7 +557,8 @@ Once a turn has used a file, the model reads it with `read_attachment`, and a to
554
557
  ### `tools`: what agents can call
555
558
 
556
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.
557
- `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.
558
562
  It returns the text the model reads.
559
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.
560
564
  Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads.
@@ -642,6 +646,9 @@ Raw `sessionTools` and Pi package tools may import and call `attachReplyFile` du
642
646
  It uses the current asynchronous turn, not a global channel queue, so simultaneous turns and nested conversation turns keep separate files.
643
647
  A Pi package should use the host's peer dependency on `pi-roundtable`, not bundle another copy of it.
644
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.
645
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.
646
653
  Await work that produces files before returning from the tool.
647
654
  A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
@@ -716,7 +723,7 @@ A rule whose held call stands for others can answer `approvalTier(tool, input, c
716
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.
717
724
  Its scratch roots are the shared workspace (`HoldContext.workspace`) and the scratch dir (`HoldContext.scratchDir`).
718
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.
719
- `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.
720
727
 
721
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.
722
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.
@@ -2389,6 +2396,22 @@ On Discord the owners are every owner with a Discord identity, and the primary o
2389
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.
2390
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.
2391
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
+
2392
2415
  #### Interim text: what a turn writes before its final answer
2393
2416
 
2394
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?".
@@ -2400,7 +2423,8 @@ On a surface that gives `interim`, and in the agent server's channels through th
2400
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.
2401
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.
2402
2425
  A failed interim post or edit is logged and never fails the turn.
2403
- Turns with nowhere to post, such as transient tasks, coding workers, and turns of a claim that passes its own `reply` to `context.turns.run`, post only their final reply.
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.
2404
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.
2405
2429
 
2406
2430
  The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
@@ -2862,7 +2886,7 @@ It returns:
2862
2886
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2863
2887
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2864
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` |
2865
- | `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` |
2866
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 |
2867
2891
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
2868
2892
  | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
@@ -3312,6 +3336,7 @@ Import from the entries listed below; source area files are internal.
3312
3336
  | `Judge` | `pi-roundtable` | type |
3313
3337
  | `JudgeError` | `pi-roundtable` | value |
3314
3338
  | `JudgeModel` | `pi-roundtable` | type |
3339
+ | `LateAnswer` | `pi-roundtable` | type |
3315
3340
  | `LinkedSessions` | `pi-roundtable` | type |
3316
3341
  | `LinkSource` | `pi-roundtable` | type |
3317
3342
  | `ListenerAddress` | `pi-roundtable` | type |
@@ -3351,6 +3376,7 @@ Import from the entries listed below; source area files are internal.
3351
3376
  | `PromptScope` | `pi-roundtable` | type |
3352
3377
  | `PromptSection` | `pi-roundtable` | type |
3353
3378
  | `PromptTurn` | `pi-roundtable` | type |
3379
+ | `PromptWait` | `pi-roundtable` | type |
3354
3380
  | `Prompts` | `pi-roundtable` | type |
3355
3381
  | `Pronouns` | `pi-roundtable` | type |
3356
3382
  | `ProviderError` | `pi-roundtable` | value |
@@ -3429,6 +3455,7 @@ Import from the entries listed below; source area files are internal.
3429
3455
  | `ToolTurn` | `pi-roundtable` | type |
3430
3456
  | `TranscriptEntry` | `pi-roundtable` | type |
3431
3457
  | `TransientTask` | `pi-roundtable` | type |
3458
+ | `TurnAttachmentOptions` | `pi-roundtable` | type |
3432
3459
  | `TurnAttachments` | `pi-roundtable` | type |
3433
3460
  | `TurnConversation` | `pi-roundtable` | type |
3434
3461
  | `TurnEndEvent` | `pi-roundtable` | type |
@@ -3584,6 +3611,8 @@ Import from the entries listed below; source area files are internal.
3584
3611
  | `CompactionHistory` | `pi-roundtable/kit` | type |
3585
3612
  | `LatestCompaction` | `pi-roundtable/kit` | type |
3586
3613
  | `isRuleLoad` | `pi-roundtable/kit` | value |
3614
+ | `InterimPoster` | `pi-roundtable/kit` | value |
3615
+ | `InterimPosterOptions` | `pi-roundtable/kit` | type |
3587
3616
  | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
3588
3617
  | `JEV_GOAL` | `pi-roundtable/kit` | value |
3589
3618
  | `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.2",
3
+ "version": "0.9.5",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -1,6 +1,12 @@
1
+ import { readdir, rm, rmdir } from "node:fs/promises";
1
2
  import { join } from "node:path";
2
3
  import type { ChannelKey } from "../domain/conversation.ts";
3
4
 
5
+ /** Where saved files wait for a turn, beside the conversations' attachment directories. */
6
+ export const STAGING = "attachments-pending";
7
+ /** Where each principal's tally of used bytes per conversation is kept. */
8
+ export const USAGE = "attachments-used";
9
+
4
10
  /** A channel key as a single path segment. */
5
11
  export function channelSegment(channel: ChannelKey): string {
6
12
  return channel.replace(/[^A-Za-z0-9_-]/g, "_");
@@ -13,3 +19,48 @@ export function ownerAttachmentDir(
13
19
  ): string {
14
20
  return join(dataDir, "attachments", channelSegment(channel));
15
21
  }
22
+
23
+ /** The entries of `dir`; none when it is not there. */
24
+ export async function listDir(dir: string): Promise<string[]> {
25
+ try {
26
+ return await readdir(dir);
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ /** Removes `dir` when it is empty; a directory that has entries, or is gone, stays as it is. */
34
+ export async function removeIfEmpty(dir: string): Promise<void> {
35
+ try {
36
+ await rmdir(dir);
37
+ } catch (error) {
38
+ const code = (error as NodeJS.ErrnoException).code;
39
+ if (code !== "ENOTEMPTY" && code !== "EEXIST" && code !== "ENOENT")
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Removes everything the core keeps for a deleted conversation: the files its turns used, the
46
+ * files people saved for it and no turn used, and the tally of what each principal used in it.
47
+ */
48
+ export async function discardConversationFiles(
49
+ dataDir: string,
50
+ channel: ChannelKey,
51
+ ): Promise<void> {
52
+ const segment = channelSegment(channel);
53
+ await rm(ownerAttachmentDir(dataDir, channel), {
54
+ recursive: true,
55
+ force: true,
56
+ });
57
+ for (const area of [STAGING, USAGE]) {
58
+ const root = join(dataDir, area);
59
+ for (const principal of await listDir(root)) {
60
+ const dir = join(root, principal);
61
+ await rm(join(dir, segment), { recursive: true, force: true });
62
+ await rm(join(dir, `${segment}.tmp`), { force: true });
63
+ await removeIfEmpty(dir);
64
+ }
65
+ }
66
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  AttachmentRef,
6
6
  StoredAttachment,
7
7
  } from "../domain/attachment.ts";
8
+ import { plainText } from "./attachment-name.ts";
8
9
 
9
10
  export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
10
11
 
@@ -22,13 +23,36 @@ export interface FetchResult {
22
23
  failures: AttachmentFailure[];
23
24
  }
24
25
 
25
- /** Keeps a file name readable while removing path separators and control characters. */
26
+ /** The most UTF-8 bytes a stored name keeps; a file system takes 255 bytes for the whole file name. */
27
+ const MAX_NAME_BYTES = 150;
28
+
29
+ const utf8Length = (codePoint: number): number =>
30
+ codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4;
31
+
32
+ /** The end of `text` that fits `max` UTF-8 bytes, cut on a code point. */
33
+ function lastBytes(text: string, max: number): string {
34
+ const points = [...text];
35
+ let bytes = 0;
36
+ let start = points.length;
37
+ while (start > 0) {
38
+ const size = utf8Length(points[start - 1]?.codePointAt(0) ?? 0);
39
+ if (bytes + size > max) break;
40
+ bytes += size;
41
+ start -= 1;
42
+ }
43
+ return points.slice(start).join("");
44
+ }
45
+
46
+ /**
47
+ * Keeps a file name readable while removing path separators and control characters, and keeps the
48
+ * end of a long name (its extension) within 150 UTF-8 bytes so a prefix and a record suffix still
49
+ * fit the 255 bytes a file system takes.
50
+ */
26
51
  export function safeFileName(name: string): string {
27
- const cleaned = name
28
- .normalize("NFC")
29
- .replace(/[/\\\p{Cc}]/gu, "_")
30
- .replace(/^\.+/, "_")
31
- .slice(-120);
52
+ const cleaned = lastBytes(
53
+ plainText(name.toWellFormed().normalize("NFC").replace(/[/\\]/g, "_")),
54
+ MAX_NAME_BYTES,
55
+ ).replace(/^\.+/, "_");
32
56
  return cleaned || "file";
33
57
  }
34
58
 
@@ -0,0 +1,34 @@
1
+ import { basename } from "node:path";
2
+
3
+ /**
4
+ * Characters that must not reach a prompt or a file name as they are: controls, the line and
5
+ * paragraph separators, and the bidi controls that reorder or hide the text around them.
6
+ */
7
+ const UNSAFE_CHARS =
8
+ /[\p{Cc}\p{Zl}\p{Zp}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
9
+
10
+ /** `value` with each unsafe character replaced by an underscore. */
11
+ export function plainText(value: string): string {
12
+ return value.replace(UNSAFE_CHARS, "_");
13
+ }
14
+
15
+ /** Whether `file` could be a name the attachment stores give out: one path segment, not hidden, no NUL. */
16
+ export function isAttachmentName(file: string): boolean {
17
+ return (
18
+ file !== "" &&
19
+ basename(file) === file &&
20
+ !file.startsWith(".") &&
21
+ !file.includes("\0")
22
+ );
23
+ }
24
+
25
+ const TYPE_GRAMMAR = /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i;
26
+
27
+ /**
28
+ * The type a prompt may print: `type` without its parameters (`text/plain; charset=utf-8` is
29
+ * `text/plain`) when that is a plain type/subtype, else the generic binary type.
30
+ */
31
+ export function plainContentType(type: string): string {
32
+ const base = type.split(";")[0]?.trim().toLowerCase() ?? "";
33
+ return TYPE_GRAMMAR.test(base) ? base : "application/octet-stream";
34
+ }