pi-roundtable 0.7.15 → 0.7.17

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 (36) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/docs/plugins.md +94 -4
  3. package/package.json +2 -1
  4. package/src/core/agents/agent-ports.ts +9 -0
  5. package/src/core/agents/agent-prompt.ts +3 -1
  6. package/src/core/agents/agent-team.ts +2 -0
  7. package/src/core/agents/team-options.ts +2 -0
  8. package/src/core/agents/team-turn-types.ts +1 -1
  9. package/src/core/agents/team-turns.ts +24 -0
  10. package/src/core/builtin/agent-server.ts +37 -0
  11. package/src/core/config/config.ts +30 -3
  12. package/src/core/contract/runtime.ts +5 -0
  13. package/src/core/contract/surface.ts +8 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/agent-discord.ts +33 -0
  16. package/src/core/discord/discord-surface.ts +20 -0
  17. package/src/core/domain/interim.ts +16 -0
  18. package/src/core/domain/ports.ts +6 -0
  19. package/src/core/holds.ts +2 -0
  20. package/src/core/modules/host-shell/git-push.ts +112 -0
  21. package/src/core/modules/host-shell/shell-paths.ts +160 -0
  22. package/src/core/modules/host-shell/shell-policy.ts +111 -48
  23. package/src/core/routing/conversation-turns.ts +3 -0
  24. package/src/core/routing/surface-port.ts +1 -0
  25. package/src/core/runtime/conversation-sessions.ts +7 -2
  26. package/src/core/runtime/extensions/ask-user.ts +1 -1
  27. package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
  28. package/src/core/runtime/interim-text.ts +207 -0
  29. package/src/core/runtime/pi-agent-runtime.ts +13 -2
  30. package/src/core/runtime/prompt-slot.ts +13 -2
  31. package/src/core/runtime/runtime-types.ts +8 -0
  32. package/src/core/runtime/session-factory.ts +16 -0
  33. package/src/index.ts +5 -0
  34. package/src/kit/index.ts +20 -0
  35. package/src/kit/jev.ts +357 -0
  36. package/src/kit/shell.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.17] - 2026-10-04
9
+
10
+ ### Added
11
+
12
+ - A turn posts the text it writes before its final answer as it goes, so a proposal written before `ask_user` shows above its card instead of never reaching the channel. Primary text (400 characters or more, or with a Markdown heading, list, table or code fence) of a tool-calling assistant message is posted as ordinary messages when the message ends; shorter narration and the tools called (`-# bash ×3 · read`, names only) share one small-text progress message per run of tool calls, edited at most every 1.5 s, bounded to 2000 characters by dropping its oldest lines. Before any card the pending text is posted first. The final reply, its thinking line, and steered runs are unchanged; a failed interim post is logged and never fails the turn. See [interim text](docs/plugins.md#interim-text-what-a-turn-writes-before-its-final-answer).
13
+ - Config `interimText: "on" | "off"` (default `"on"`) and `interimPrimaryChars` (default 400), also on `PiAgentRuntimeOptions` and the agent server's options.
14
+ - `TurnRequest.interim`, `ChatSurface.interim(channel)` and `SurfacePort.interim(channel)` (Discord's surface implements it), optional `AgentChannels.interim(channelId, as)` (the agents' webhooks), and `PromptSlot.bind`'s optional `beforeCard`.
15
+ - Types: `InterimMessage`, `InterimPosts`, `InterimTextMode`.
16
+
17
+ ### Changed
18
+
19
+ - `ask_user`'s description asks for a question that reads on its own, with the context it needs.
20
+
21
+ ## [0.7.16] - 2026-10-04
22
+
23
+ ### Added
24
+
25
+ - `pi-roundtable/kit` owns a Jev compaction engine on pi-jev-compaction 1.0.0 (now a dependency, pinned exactly): `jevCompact(input, options)` and two adapters, `jevCompactionExtension({ logger })` for the `compaction` session tool (placed through `session.compaction.wrap`, engine `JEV_COMPACTION_ENGINE`, `pi-jev-compaction`) and `jevCompactor({ logger })` for pi-roundtable-sandbox's `compaction` option. Both log each fallback to Pi's summary with its reason and `tokensBefore`, except a missing API key, which they log once (`Jev is not configured, so compaction uses Pi's summary`) and then compact through Pi's summary quietly: the key is optional.
26
+ - Values: `jevCompact`, `jevCompactionExtension`, `jevCompactor`, `isRuleLoad`, `JEV_COMPACTION_ENGINE`, `JEV_GOAL`, `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`; types: `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions`, `JevSkipReason`.
27
+ - Unlike pi-jev-compaction's own `compactPiSession`, a summary carries the previous summary once, not twice, so a chain of compactions no longer doubles (one session's grew from 8k to 303k characters over five compactions).
28
+ - A previous summary over 60,000 tokens (`previousSummaryLimitTokens`) skips Jev, so Pi's summary condenses the chain.
29
+ - Jev judges with a goal (`JEV_GOAL`, option `goal`) that keeps the tool results that set rules still in force, which it dropped without one, and the summary ends with the rule loads it summarized (skills, notebook system prompts, reads of SKILL.md, AGENTS.md and `.agents/skills/`; option `ruleLoad`, default `isRuleLoad`) for the agent to load again.
30
+
31
+ ### Changed
32
+
33
+ - The shell hold rule lets more of the agents' own work run.
34
+ - The agents get a scratch dir, the config's new `scratchDir` (default `<os temp dir>/<discord.rootCommand>-scratch`, created with mode 0700 at startup; a symlink or another user's dir is refused), and their `bash` runs with `TMPDIR` pointing to it. Redirects, `tee`, and `write` and `edit` inside it run as inside the workspace; the rest of `/tmp` stays held. `AgentSessions.scratchDir` and `HoldContext.scratchDir` carry it, and the agents' prompt names it.
35
+ - `rm` is no longer always held: it runs when every operand resolves, after the line's literal variable assignments, `cd`, `..` and symlinks, inside the workspace or the scratch dir without being one of them. A command substitution, an unknown variable, a root itself, `/`, or no operand keeps it held.
36
+ - `git push` stays held by `shellHoldRule`; the new `shellHoldRuleFor({ ownPushOwners, heldPushRepos })` (type `PushPolicy`) lets a plain push to a GitHub repository of a listed owner run, unless it forces, deletes, pushes tags or a mirror, or the repository is in `heldPushRepos` or its remote cannot be read.
37
+ - A relative write target or `rm` operand resolves from the line's last `cd`, not always from the workspace.
38
+ - Value: `shellHoldRuleFor`; type: `PushPolicy`.
39
+
40
+ ### Fixed
41
+
42
+ - pi-roundtable-coding frees a finished job's repository before delivering its report, so the turn that receives the report can ship it or start the next worker there instead of being refused with "still using".
43
+
8
44
  ## [0.7.15] - 2026-10-03
9
45
 
10
46
  ### Changed
package/docs/plugins.md CHANGED
@@ -63,11 +63,19 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
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
64
  - 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
65
  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
- - Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
66
+ - 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
67
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
68
68
  - 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
69
  The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
70
70
  - Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass)` to hold a compaction extension back past the ceiling, `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`.
71
+ - 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
+ 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
+ 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/`).
74
+ `config` passes pi-jev-compaction's settings (`apiKey`, `model`, thresholds) over its config file and environment; `asker` replaces Jev's service, for tests.
75
+ The API key is optional: without one, `jevCompact` skips with `no_key` without calling Jev, and compaction is Pi's own summary.
76
+ Two adapters log each skip as `Jev leaves the compaction to Pi's summary` with `reason`, `detail` and `tokensBefore` through the `logger` they take: `jevCompactionExtension({ logger, ...options })`, the `compaction` session tool's extension, placed as `session.compaction.wrap(jevCompactionExtension({ logger }))` under the engine `JEV_COMPACTION_ENGINE` (`pi-jev-compaction`), and `jevCompactor({ logger, ...options })`, a `JevCompactor` for pi-roundtable-sandbox's `compaction` option that returns the compaction or `undefined` and logs with the `channel`.
77
+ A `no_key` skip is logged once per adapter, as `Jev is not configured, so compaction uses Pi's summary`, and then not again; every other skip is logged each time.
78
+ The types are `JevCompactInput`, `JevCompactOptions`, `JevCompactOutcome`, `JevCompactor`, `JevCompactRequest`, `JevExtensionOptions` and `JevSkipReason`.
71
79
  - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
72
80
  - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
73
81
 
@@ -505,6 +513,36 @@ A rule whose verdict depends on the input, such as one that holds only a `delete
505
513
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
506
514
  A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
507
515
 
516
+ #### The shell rule
517
+
518
+ `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.
519
+ Its scratch roots are the shared workspace (`HoldContext.workspace`) and the scratch dir (`HoldContext.scratchDir`).
520
+ 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.
521
+ `AgentSessions.scratchDir` carries it to a runtime, and the agents' prompt tells them to write temporary files there.
522
+
523
+ - A `>`, `>>` or `tee` target, and a `write` or `edit` path, inside a scratch root run; anything else, the rest of `/tmp` included, is held.
524
+ - 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.
525
+ - `sudo`, `kill`, `dd`, `mkfs` and the other programs held whatever their arguments stay held, as do service, container, firewall and package changes, `git reset --hard`, and `gh` writes.
526
+ - `git push` is held, `force-pushes` for a force push and otherwise as a push to GitHub.
527
+
528
+ `shellHoldRuleFor({ ownPushOwners?, heldPushRepos? })` (its options are the type `PushPolicy`) is the same rule with plain pushes to the owner's own repositories let through; `shellHoldRule` is `shellHoldRuleFor()`, which holds every push.
529
+ A push runs without a hold when all of these hold:
530
+
531
+ - it does not force (`-f`, `--force*`, a `+` refspec), delete (`--delete`, `-d`, a `:ref` refspec), or push tags or more than its refs (`--tags`, `--follow-tags`, `--mirror`, `--all`, `--prune`), takes no other option than `-u`, `-q`, `-v`, `-n`, `--no-verify`, `--atomic`, `--porcelain`, `--progress` and `-o`, and names no `refs/tags/…` ref or local tag;
532
+ - the repository directory is known: `git -C <dir>`, else the last `cd` in the command line, else the workspace; a line with a subshell or `||` does not follow its `cd`;
533
+ - every push URL of the remote (the named one, default `origin`, read with `git remote get-url --push --all` with a short timeout and no shell; or a URL given in its place) is a GitHub repository, https or ssh, whose owner is in `ownPushOwners` and whose `owner/repo` is not in `heldPushRepos`.
534
+
535
+ Git's `-c`, `--git-dir` and `--work-tree`, a `GIT_*` variable before `git`, and any failure to read the remote keep the push held.
536
+
537
+ ```ts
538
+ import { shellHoldRuleFor } from "pi-roundtable/kit";
539
+
540
+ const shell = shellHoldRuleFor({
541
+ ownPushOwners: ["octocat"],
542
+ heldPushRepos: ["octocat/deployed-app"],
543
+ });
544
+ ```
545
+
508
546
  ### `prompt`: text added to every agent turn
509
547
 
510
548
  Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
@@ -1001,7 +1039,7 @@ The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called on
1001
1039
  `deps` provides `logger`, `env`, `owner`, `toolTiers`, and the host's `judge`.
1002
1040
  Its `sessions()` gives you linked hold rules, packages, session tools, and personas from preflight onwards; call it in a turn, when those parts are available.
1003
1041
  `prompts(conversation, speaker)` gives you the owner's approval and question cards on the conversation's surface, or `undefined`.
1004
- `agents` holds the agent server's per-agent settings: `workDir`, `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1042
+ `agents` holds the agent server's per-agent settings: `workDir`, `scratchDir` (the agents' shell's `TMPDIR`), `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1005
1043
  `confirmations` stores held actions across restarts.
1006
1044
 
1007
1045
  An `AgentRuntime` has these methods:
@@ -1018,7 +1056,7 @@ An `AgentRuntime` has these methods:
1018
1056
  | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
1019
1057
  | `dispose?()` | Runs when the host stops the agent server's runtime service |
1020
1058
 
1021
- A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, and flags (`steerable`, `interactive`, `confirmed`).
1059
+ A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
1022
1060
  An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
1023
1061
  Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
1024
1062
 
@@ -1489,6 +1527,22 @@ Use it for tools that `defineTool` cannot express, such as a set that changes wh
1489
1527
  Each extension needs a unique name; the core reserves `read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, and `active-tools`.
1490
1528
  A runtime of your own pins the active tools the way the core does: `activeToolsExtension(() => tools)` from `pi-roundtable/kit` is the extension the core places last, so its handler runs after every other extension's.
1491
1529
  At most one plugin may add a `compaction` extension, and it must name the `engine` its compactions record.
1530
+ The core's Jev compactor is one such extension:
1531
+
1532
+ ```ts
1533
+ sessionTools: [
1534
+ {
1535
+ name: "jev-compaction",
1536
+ phase: "compaction",
1537
+ engine: JEV_COMPACTION_ENGINE,
1538
+ snapshot: () => ({
1539
+ revision: 0,
1540
+ factory: (session) =>
1541
+ session.compaction.wrap(jevCompactionExtension({ logger })),
1542
+ }),
1543
+ },
1544
+ ],
1545
+ ```
1492
1546
 
1493
1547
  <!-- example: examples/session-tools.ts -->
1494
1548
  ```ts
@@ -1828,6 +1882,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
1828
1882
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
1829
1883
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
1830
1884
  | `prompts(channel, speaker?)` | The owner's way to approve a held action or answer `ask_user` inside a running turn, as `OwnerPrompts`: `confirm` and `ask` | the action is held until the owner's next message |
1885
+ | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
1831
1886
 
1832
1887
  The host starts each surface as `surface:<prefix>` at the contributing plugin's place in the order, before that plugin's own services.
1833
1888
  It stops surfaces in reverse order, like other services.
@@ -1839,6 +1894,22 @@ If `prompts` is unavailable, it returns `undefined` and held actions wait for th
1839
1894
  `of(channel)` returns the surface, or `undefined`.
1840
1895
  The agent server asks the owner for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
1841
1896
 
1897
+ #### Interim text: what a turn writes before its final answer
1898
+
1899
+ 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?".
1900
+ On a surface that gives `interim`, and in the agent server's channels through the agents' webhooks, the host posts that text as the turn goes, sorted in two:
1901
+
1902
+ - **Primary** text is posted as ordinary messages as soon as its assistant message ends: text of 400 characters or more, or written with a Markdown heading, list, table or code fence.
1903
+ - **Secondary** text, short narration between tool calls, goes to one progress message per run of tool-calling messages, in Discord's small text (`-# ` per line): each text as a line, then the tools called in the run, such as `-# bash ×3 · read · web_search` (names only). It is edited in place at most every 1.5 seconds, its last state always lands, and it stays inside 2000 characters by dropping its oldest lines behind `-# …`. A primary post or a card starts a new progress message.
1904
+
1905
+ 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.
1906
+ 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.
1907
+ A failed interim post or edit is logged and never fails the turn.
1908
+ 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.
1909
+ 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.
1910
+
1911
+ The config's `interimText: "off"` posts only the final reply (default `"on"`), and `interimPrimaryChars` sets the length of primary text (default 400).
1912
+
1842
1913
  The Discord plugin collects [slash commands](#slash-commands-commandsadd); the host doesn't compose them or pass them to surfaces on other networks.
1843
1914
 
1844
1915
  This in-memory chat surface records everything the host asks of it.
@@ -2075,7 +2146,7 @@ It returns:
2075
2146
  |---|---|
2076
2147
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2077
2148
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2078
- | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
2149
+ | `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` |
2079
2150
  | `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 |
2080
2151
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2081
2152
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
@@ -2471,6 +2542,9 @@ Import from the entries listed below; source area files are internal.
2471
2542
  | `HttpRoute` | `pi-roundtable` | type |
2472
2543
  | `ImageDrawer` | `pi-roundtable` | type |
2473
2544
  | `InboundMessage` | `pi-roundtable` | type |
2545
+ | `InterimMessage` | `pi-roundtable` | type |
2546
+ | `InterimPosts` | `pi-roundtable` | type |
2547
+ | `InterimTextMode` | `pi-roundtable` | type |
2474
2548
  | `Judge` | `pi-roundtable` | type |
2475
2549
  | `JudgeError` | `pi-roundtable` | value |
2476
2550
  | `JudgeModel` | `pi-roundtable` | type |
@@ -2666,6 +2740,7 @@ Import from the entries listed below; source area files are internal.
2666
2740
  | `OwnerNotifier` | `pi-roundtable/kit` | type |
2667
2741
  | `PreviousTurn` | `pi-roundtable/kit` | type |
2668
2742
  | `PromptSlot` | `pi-roundtable/kit` | type |
2743
+ | `PushPolicy` | `pi-roundtable/kit` | type |
2669
2744
  | `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
2670
2745
  | `SHELL_TOOLS` | `pi-roundtable/kit` | value |
2671
2746
  | `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
@@ -2707,6 +2782,20 @@ Import from the entries listed below; source area files are internal.
2707
2782
  | `CompactionEngine` | `pi-roundtable/kit` | type |
2708
2783
  | `CompactionHistory` | `pi-roundtable/kit` | type |
2709
2784
  | `LatestCompaction` | `pi-roundtable/kit` | type |
2785
+ | `isRuleLoad` | `pi-roundtable/kit` | value |
2786
+ | `JEV_COMPACTION_ENGINE` | `pi-roundtable/kit` | value |
2787
+ | `JEV_GOAL` | `pi-roundtable/kit` | value |
2788
+ | `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS` | `pi-roundtable/kit` | value |
2789
+ | `jevCompact` | `pi-roundtable/kit` | value |
2790
+ | `jevCompactionExtension` | `pi-roundtable/kit` | value |
2791
+ | `jevCompactor` | `pi-roundtable/kit` | value |
2792
+ | `JevCompactInput` | `pi-roundtable/kit` | type |
2793
+ | `JevCompactOptions` | `pi-roundtable/kit` | type |
2794
+ | `JevCompactOutcome` | `pi-roundtable/kit` | type |
2795
+ | `JevCompactor` | `pi-roundtable/kit` | type |
2796
+ | `JevCompactRequest` | `pi-roundtable/kit` | type |
2797
+ | `JevExtensionOptions` | `pi-roundtable/kit` | type |
2798
+ | `JevSkipReason` | `pi-roundtable/kit` | type |
2710
2799
  | `isScheduleTool` | `pi-roundtable/kit` | value |
2711
2800
  | `lastAssistant` | `pi-roundtable/kit` | value |
2712
2801
  | `mcpAdapterExtension` | `pi-roundtable/kit` | value |
@@ -2723,6 +2812,7 @@ Import from the entries listed below; source area files are internal.
2723
2812
  | `searchTerms` | `pi-roundtable/kit` | value |
2724
2813
  | `settleTurn` | `pi-roundtable/kit` | value |
2725
2814
  | `shellHoldRule` | `pi-roundtable/kit` | value |
2815
+ | `shellHoldRuleFor` | `pi-roundtable/kit` | value |
2726
2816
  | `skillListExtension` | `pi-roundtable/kit` | value |
2727
2817
  | `splitReply` | `pi-roundtable/kit` | value |
2728
2818
  | `stringList` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "description": "A plugin-driven Pi agent server for Discord",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "@earendil-works/pi-coding-agent": ">=0.99.2 <2",
62
62
  "canvas": "3.2.3",
63
63
  "discord.js": "14.27.0",
64
+ "pi-jev-compaction": "1.0.0",
64
65
  "pi-mcp-adapter": "5.0.0",
65
66
  "pino": "10.3.1",
66
67
  "typebox": "1.3.34",
@@ -1,4 +1,5 @@
1
1
  import type { AgentRuntime, ContextUse } from "../contract/runtime.ts";
2
+ import type { InterimPosts } from "../domain/interim.ts";
2
3
  import type { ThinkingSetting } from "../models.ts";
3
4
 
4
5
  export type { ContextUse };
@@ -47,6 +48,14 @@ export interface ChannelMessage {
47
48
  /** The agent server's channels as the agent team needs them. */
48
49
  export interface AgentChannels {
49
50
  post(channelId: string, post: AgentPost): Promise<void>;
51
+ /**
52
+ * Where a running turn posts the text it writes before its final answer, under the same name
53
+ * and avatar as `post`; absent = only the final reply is posted.
54
+ */
55
+ interim?(
56
+ channelId: string,
57
+ as: Omit<AgentPost, "thinking" | "chunks" | "files">,
58
+ ): InterimPosts;
50
59
  /** Creates a text channel under the category, made when missing; returns its id. */
51
60
  createChannel(
52
61
  name: string,
@@ -18,6 +18,8 @@ export function agentSystemPrompt(input: {
18
18
  owner: OwnerIdentity;
19
19
  /** The host account the shell and file tools run as. */
20
20
  shellUser: string;
21
+ /** The scratch dir the shell's TMPDIR points to, for temporary files. */
22
+ scratchDir?: string;
21
23
  group?: { group: AgentGroup; members: readonly Agent[] };
22
24
  /** Who the turn is for; without one, or the owner, the prompt speaks to the owner. */
23
25
  speaker?: Speaker;
@@ -35,7 +37,7 @@ export function agentSystemPrompt(input: {
35
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.`,
36
38
  roleText(agent, coordinator, who),
37
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.`,
38
- `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}. Commands that are destructive or change the system, and writes outside the workspace, 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.`,
39
41
  ];
40
42
  if (group) {
41
43
  const others = group.members
@@ -131,6 +131,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
131
131
  entryChannelId,
132
132
  owner,
133
133
  shellUser,
134
+ scratchDir,
134
135
  } = this.#options;
135
136
  const agent = store.agent(scope.name);
136
137
  if (!agent) throw new AgentError(`no agent ${scope.name}`);
@@ -146,6 +147,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
146
147
  workDir,
147
148
  owner,
148
149
  shellUser,
150
+ ...(scratchDir ? { scratchDir } : {}),
149
151
  avatars: this.#avatars(),
150
152
  ...(group
151
153
  ? {
@@ -35,6 +35,8 @@ export interface AgentTeamOptions
35
35
  workDir: string;
36
36
  /** The host account the agents' shell and file tools run as. */
37
37
  shellUser: string;
38
+ /** The scratch dir the agents' shell writes temporary files to. */
39
+ scratchDir?: string;
38
40
  /** The prompt every persona shares, the assistant's included; each agent's starts with it. */
39
41
  sharedPrompt: string;
40
42
  /** The shared prompt for speakers other than the owner, when `sharedPrompt` speaks to the owner. */
@@ -44,7 +44,7 @@ export interface TeamTurnsOptions {
44
44
  /** Who the agents work for, as their prompts and group history name them. */
45
45
  owner: OwnerIdentity;
46
46
  store: PgAgentStore;
47
- channels: Pick<AgentChannels, "post">;
47
+ channels: Pick<AgentChannels, "post" | "interim">;
48
48
  studio: Pick<AvatarStudio, "url">;
49
49
  /** Set once the runtime exists, which itself needs the team's tools. */
50
50
  runtime: () => AgentTurnRunner;
@@ -6,6 +6,7 @@ import type {
6
6
  TurnResult,
7
7
  } from "../domain/conversation.ts";
8
8
  import { AgentError } from "../domain/errors.ts";
9
+ import type { InterimPosts } from "../domain/interim.ts";
9
10
  import type { AgentTurnScope } from "../domain/ports.ts";
10
11
  import { messages } from "../i18n/index.ts";
11
12
  import { splitReply } from "../presentation/reply-splitter.ts";
@@ -185,6 +186,7 @@ export class TeamTurns {
185
186
  ...(scope.group ? { group: scope.group } : {}),
186
187
  };
187
188
  this.#options.events?.turnStarted(turn);
189
+ const interim = this.#interim(postTo, agent);
188
190
  let result: TurnResult;
189
191
  try {
190
192
  result = await settleTurn(
@@ -200,6 +202,7 @@ export class TeamTurns {
200
202
  ...(extra.interactive ? { interactive: true } : {}),
201
203
  agent: scope,
202
204
  speaker: chain.speaker,
205
+ ...(interim ? { interim } : {}),
203
206
  }),
204
207
  ),
205
208
  "agent turn",
@@ -250,6 +253,27 @@ export class TeamTurns {
250
253
  return this.#options.store.agent(agent.name) ?? agent;
251
254
  }
252
255
 
256
+ /**
257
+ * The turn's interim posts under the agent's name, read as each goes out so a name changed
258
+ * during the turn shows; none in a channel no agent or group owns any more.
259
+ */
260
+ #interim(channel: ChannelKey, agent: Agent): InterimPosts | undefined {
261
+ const { store, channels, studio } = this.#options;
262
+ if (!channels.interim) return undefined;
263
+ const interim = channels.interim.bind(channels);
264
+ return {
265
+ post: async (text) => {
266
+ if (!channelOwner(store, channel))
267
+ throw new AgentError(`${channel} is archived`);
268
+ const as = this.#current(agent);
269
+ return interim(channelIdOf(channel), {
270
+ name: as.displayName,
271
+ avatarUrl: studio.url(as.avatarHash),
272
+ }).post(text);
273
+ },
274
+ };
275
+ }
276
+
253
277
  /**
254
278
  * Posts under the agent's name. A channel no agent or group owns any more, archived while a
255
279
  * turn ran, gets nothing, so its webhook is not made again.
@@ -1,3 +1,4 @@
1
+ import { chmodSync, lstatSync, mkdirSync } from "node:fs";
1
2
  import { join } from "node:path";
2
3
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
4
  import type { SQL } from "bun";
@@ -12,6 +13,8 @@ import { discordKey } from "../agents/team-keys.ts";
12
13
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
13
14
  import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
14
15
  import type { ChannelKey } from "../domain/conversation.ts";
16
+ import { ConfigError } from "../domain/errors.ts";
17
+ import type { InterimTextMode } from "../domain/interim.ts";
15
18
  import type { OwnerIdentity } from "../identity.ts";
16
19
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
17
20
  import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
@@ -53,6 +56,11 @@ export interface AgentServerOptions {
53
56
  judgeThreshold: number;
54
57
  /** The shell's shared working directory. */
55
58
  workDir: string;
59
+ /**
60
+ * The agents' scratch dir, created with mode 0700 when the agent server starts: their shell's
61
+ * TMPDIR, where writes and removals run without a hold.
62
+ */
63
+ scratchDir?: string;
56
64
  /** The host account the agents' shell and file tools run as. */
57
65
  shellUser: string;
58
66
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
@@ -65,6 +73,10 @@ export interface AgentServerOptions {
65
73
  avatarReference: string;
66
74
  /** Reports the process's own errors to an agent; without one nothing is reported. */
67
75
  errorReporter?: ErrorReporter;
76
+ /** Whether turns post the text they write before their final answer as they go; default "on". */
77
+ interimText?: InterimTextMode;
78
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
79
+ interimPrimaryChars?: number;
68
80
  }
69
81
 
70
82
  /** The name of the agent server's plugin, as `serviceStarted` events name it. */
@@ -178,6 +190,7 @@ export function agentServerPlugin(
178
190
  owner: options.owner,
179
191
  toolTiers: context.toolTiers,
180
192
  shellUser: options.shellUser,
193
+ ...(options.scratchDir ? { scratchDir: options.scratchDir } : {}),
181
194
  store,
182
195
  channels: connection.agentChannels(options.guildId),
183
196
  studio,
@@ -227,6 +240,9 @@ export function agentServerPlugin(
227
240
  });
228
241
  const agentSessions: AgentSessions = {
229
242
  workDir: options.workDir,
243
+ ...(options.scratchDir
244
+ ? { scratchDir: ensureScratchDir(options.scratchDir) }
245
+ : {}),
230
246
  modelOf: (name) => built.modelOf(name),
231
247
  skills: (name) => built.skillsOf(name),
232
248
  turnChannel: (scope) => built.turnChannel(scope),
@@ -267,6 +283,12 @@ export function agentServerPlugin(
267
283
  agents: agentSessions,
268
284
  prompts,
269
285
  logger,
286
+ ...(options.interimText
287
+ ? { interimText: options.interimText }
288
+ : {}),
289
+ ...(options.interimPrimaryChars
290
+ ? { interimPrimaryChars: options.interimPrimaryChars }
291
+ : {}),
270
292
  });
271
293
  runtime = running;
272
294
  context.services.provide(AGENTS, {
@@ -339,3 +361,18 @@ export function agentServerPlugin(
339
361
  },
340
362
  };
341
363
  }
364
+
365
+ /**
366
+ * Creates the scratch dir for the service user alone. A shared temp dir lets anyone create the
367
+ * path first, so a symlink or a dir the service user does not own is refused.
368
+ */
369
+ export function ensureScratchDir(dir: string): string {
370
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
371
+ const stat = lstatSync(dir);
372
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.())
373
+ throw new ConfigError(
374
+ `the scratch dir ${dir} is not a directory of this service's user. Remove it or set scratchDir to another path.`,
375
+ );
376
+ chmodSync(dir, 0o700);
377
+ return dir;
378
+ }
@@ -1,5 +1,8 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
1
3
  import type { AgentSeed } from "../agents/agent-rules.ts";
2
4
  import { ConfigError } from "../domain/errors.ts";
5
+ import type { InterimTextMode } from "../domain/interim.ts";
3
6
  import { isLocale, type Locale } from "../i18n/index.ts";
4
7
  import type { OwnerIdentity } from "../identity.ts";
5
8
  import {
@@ -9,6 +12,7 @@ import {
9
12
  type ThinkingLevel,
10
13
  } from "../models.ts";
11
14
  import type { RoundtablePlugin } from "../plugin.ts";
15
+ import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
12
16
  import type { Tier, TierMembers } from "../speakers.ts";
13
17
  import {
14
18
  bool,
@@ -103,6 +107,11 @@ export interface RoundtableConfig {
103
107
  avatar?: string;
104
108
  /** The agents' shared working directory; default `<dataDir>/work`. */
105
109
  workDir?: string;
110
+ /**
111
+ * The agents' scratch dir: their shell runs with TMPDIR pointing to it, and writes and removals
112
+ * inside it run without a hold. Default `<os temp dir>/<discord.rootCommand>-scratch`.
113
+ */
114
+ scratchDir?: string;
106
115
  /**
107
116
  * Where the skill registry reads built-in skills and keeps linked repositories. `false` leaves
108
117
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
@@ -116,6 +125,14 @@ export interface RoundtableConfig {
116
125
  memory?: boolean;
117
126
  /** The agent that investigates the process's own errors, by name. */
118
127
  ops?: { agent: string };
128
+ /**
129
+ * Whether a turn posts the text it writes before its final answer as it goes: long or
130
+ * structured text as ordinary messages, short narration and the tools called in one small
131
+ * progress message. Default "on"; "off" posts only the final reply.
132
+ */
133
+ interimText?: InterimTextMode;
134
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
135
+ interimPrimaryChars?: number;
119
136
  plugins?: RoundtablePlugin[];
120
137
  }
121
138
 
@@ -191,11 +208,14 @@ const schema = shape({
191
208
  }),
192
209
  avatar: optional(text),
193
210
  workDir: optional(text),
211
+ scratchDir: optional(text),
194
212
  skills: optional(
195
213
  orOff(shape({ builtinDir: optional(text), reposDir: optional(text) })),
196
214
  ),
197
215
  memory: optional(bool),
198
216
  ops: optional(shape({ agent: text })),
217
+ interimText: optional(oneOf<InterimTextMode>("on", "off")),
218
+ interimPrimaryChars: optional(integer(1, 100_000)),
199
219
  plugins: optional(
200
220
  list(
201
221
  guarded("a plugin, an object with a name and a setup function", isPlugin),
@@ -243,10 +263,13 @@ export interface ResolvedConfig {
243
263
  };
244
264
  avatar?: string;
245
265
  workDir: string;
266
+ scratchDir: string;
246
267
  /** `false` when the skills addon is off. */
247
268
  skills: false | { builtinDir?: string; reposDir?: string };
248
269
  memory: boolean;
249
270
  ops?: { agent: string };
271
+ interimText: InterimTextMode;
272
+ interimPrimaryChars: number;
250
273
  plugins: RoundtablePlugin[];
251
274
  }
252
275
 
@@ -265,6 +288,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
265
288
  const name = config.name ?? "Roundtable";
266
289
  const model = modelOf(config.model, "model");
267
290
  const thinking = config.thinking ?? "medium";
291
+ const rootCommand =
292
+ config.discord.rootCommand ??
293
+ name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
268
294
  return {
269
295
  name,
270
296
  owner: {
@@ -276,9 +302,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
276
302
  token: config.discord.token,
277
303
  guild: config.discord.guild,
278
304
  entryChannel: config.discord.entryChannel,
279
- rootCommand:
280
- config.discord.rootCommand ??
281
- name.toLowerCase().replace(/[^a-z0-9-]+/g, "-"),
305
+ rootCommand,
282
306
  admin: config.discord.admin ?? true,
283
307
  ...(config.discord.refusalHint === undefined
284
308
  ? {}
@@ -321,9 +345,12 @@ export function resolveConfig(input: unknown): ResolvedConfig {
321
345
  },
322
346
  ...(config.avatar ? { avatar: config.avatar } : {}),
323
347
  workDir: config.workDir ?? `${config.dataDir}/work`,
348
+ scratchDir: config.scratchDir ?? join(tmpdir(), `${rootCommand}-scratch`),
324
349
  skills: config.skills ?? {},
325
350
  memory: config.memory ?? true,
326
351
  ...(config.ops ? { ops: config.ops } : {}),
352
+ interimText: config.interimText ?? "on",
353
+ interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
327
354
  plugins: config.plugins ?? [],
328
355
  };
329
356
  }
@@ -82,6 +82,11 @@ export interface LoadedSkill {
82
82
  export interface AgentSessions {
83
83
  /** The shell's working directory, shared by every agent; writes outside it are held. */
84
84
  workDir: string;
85
+ /**
86
+ * The host's scratch dir: the agents' shell runs with TMPDIR pointing to it, and writes and
87
+ * removals inside it run without a hold, like the workspace's.
88
+ */
89
+ scratchDir?: string;
85
90
  /**
86
91
  * The skills the agent carries, read at the start of every run; a change rebuilds its
87
92
  * sessions, keeping their history.
@@ -1,4 +1,5 @@
1
1
  import type { OutboundReply } from "../domain/conversation.ts";
2
+ import type { InterimPosts } from "../domain/interim.ts";
2
3
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
3
4
  import { PluginError } from "../errors.ts";
4
5
  import type { ChannelKey } from "../sessions.ts";
@@ -68,6 +69,11 @@ export interface ChatSurface {
68
69
  * absent, = the action is held until the owner's next message.
69
70
  */
70
71
  prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
72
+ /**
73
+ * Where a running turn posts the text it writes before its final answer: ordinary messages it
74
+ * may edit in place. Absent, or undefined, = only the final reply is posted.
75
+ */
76
+ interim?(channel: ChannelKey): InterimPosts | undefined;
71
77
  }
72
78
 
73
79
  /**
@@ -87,4 +93,6 @@ export interface SurfacePort {
87
93
  unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
88
94
  /** The owner's prompts in the channel; undefined when its surface has none. */
89
95
  prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
96
+ /** The channel's interim posts; undefined when its surface has none. */
97
+ interim(channel: ChannelKey): InterimPosts | undefined;
90
98
  }
@@ -211,11 +211,14 @@ export async function defineRoundtable(
211
211
  thinking: config.thinking,
212
212
  judgeThreshold: config.judge.threshold,
213
213
  workDir: config.workDir,
214
+ scratchDir: config.scratchDir,
214
215
  shellUser: userInfo().username,
215
216
  prompts: { shared, guest },
216
217
  avatarListener: "public",
217
218
  avatarUrl: config.http.publicUrl,
218
219
  avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
220
+ interimText: config.interimText,
221
+ interimPrimaryChars: config.interimPrimaryChars,
219
222
  ...(errorReporter ? { errorReporter } : {}),
220
223
  }),
221
224
  seedsPlugin(config.agents),