pi-roundtable 0.9.5 → 0.9.7

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 CHANGED
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.7] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - `ApprovalDetails` and `ApprovalFile` (`{ action, tool, input, files? }`, files as `{ path, bytes? }`): a held call as data, with its whole input and the size of each file it sends by path. `Prompts.confirm` takes it as a new optional last argument, `details`, beside the card's text `message`; the confirmation gate passes it with every card, and a surface that shows text only ignores it (Discord's cards read as before). `approvalDetails` builds it (`approvalDetails(call, workspace?)`), exported from `pi-roundtable/kit` beside `approvalCard`.
13
+ - `Approval` has a new value, `unavailable`: no card could be shown (no one may approve the call, the speaker is below its tier, or the card could not be posted). `expired` now means a card was shown and went unanswered. The Discord and web chat surfaces answer `unavailable` for the first case; a surface that cannot tell the two may keep answering `expired` for both, and the surface contract accepts either.
14
+ - `TurnProgress`'s `tool_end` takes an optional `refused` (`HoldRefusal`: `declined`, `expired`, `pending` or `held`), set when a hold refused the call and absent for a success and for a real failure. A card that was shown and went unanswered is `expired`; one that could not be shown is `held`. It is set through `PromptSlot` (`refuse`, `refusalOf`, in `pi-roundtable/kit`); a turn stopped while its card was open reports none.
15
+ - `approvalCard(call, workspace?, limits?)` takes `CardLimits` (also exported from the kit), `{ valueChars?, totalChars? }`, to set what its card shows of the input.
16
+
17
+ ### Changed
18
+
19
+ - An approval card shows an input that fits `totalChars` (1,500 by default) whole, exactly as before. Only an input over it has its string values cut, the longest first and never below `valueChars` (200 by default), each marked `… [N chars]` with the original length in code points, until it fits; so the keys after one long value are no longer lost. If it still cannot fit, the rest is dropped and the card lists the top-level keys it no longer shows. A cut never splits a surrogate pair.
20
+
21
+ ## [0.9.6] - 2026-10-10
22
+
23
+ ### Fixed
24
+
25
+ - 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`.
26
+
8
27
  ## [0.9.5] - 2026-10-10
9
28
 
10
29
  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.
package/docs/plugins.md CHANGED
@@ -60,7 +60,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
60
60
  `ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
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
- - 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.
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`, `approvalDetails` and `canonicalJson` for the cards of held actions.
64
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.
65
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.
66
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.
@@ -68,7 +68,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
68
68
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
69
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`.
70
70
  The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
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()`), 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.
72
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.
73
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.
74
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/`).
@@ -648,7 +648,7 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
648
648
  Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
649
649
 
650
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.
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?, limits?)`) lists each file it sends by path with the file's size now, never its bytes, and shows an input within `limits.totalChars` (1,500) whole; a longer input has its string values cut, longest first and never below `limits.valueChars` (200), to their start plus `… [N chars]` (N in code points) until it fits, and if it still does not, the rest is dropped and the keys no longer shown are listed. `approvalDetails(call, workspace?)` gives the same call as data (`{ action, tool, input, files? }`, the whole input, files as `{ path, bytes? }`); the gate passes it to `Prompts.confirm` as `details`, for a surface that is not text.
652
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.
653
653
  Await work that produces files before returning from the tool.
654
654
  A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
@@ -2365,7 +2365,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2365
2365
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
2366
2366
  | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
2367
2367
  | `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 |
2368
- | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok }`. The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2368
+ | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2369
2369
 
2370
2370
  Each message a surface delivers says who wrote it in `actor`, the `ActorFacts` the identity service resolves: `provider` and `subject`, such as `discord` and the user id, `name`, `roles` written `<surface>:role:<name>` (left out where the surface does not know them, such as a Discord DM, which is not the same as none), and `legacyId`, the speaker id 0.8 gave the person, so someone carried over keeps their principal.
2371
2371
  A surface that leaves `actor` out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, and the host warns once that this goes away in 1.0.
@@ -2388,13 +2388,21 @@ A surface's prompts keep these rules:
2388
2388
 
2389
2389
  - The speaker's principal answers their own question on any of their identities the surface knows, and approves a call when their tier is at least its `minTier` (the owner tier when absent) and, for an owner-tier call, the principal is an owner; a surface that can check their tier again when they answer does.
2390
2390
  - Where the scope escalates to the owners, every owner may answer too, and an approval above the speaker's tier is the owners' alone.
2391
- - Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `expired` at once, shown to no one: the call stays held.
2391
+ - Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `unavailable` at once, shown to no one: the call stays held. (`expired` is for a card that was shown and went unanswered; a surface that cannot tell the two may answer `expired` for both.)
2392
2392
  - Anyone else is refused, even at the same tier as the speaker.
2393
2393
  - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2394
2394
 
2395
2395
  On Discord the owners are every owner with a Discord identity, and the primary owner always; a card in a thread mentions the identity that spoke when it is theirs and one of the principal's Discord identities, every Discord identity of the principal when the speaker is none of them, and the owners when it goes to them, so a single owner's cards read as they did in 0.8.
2396
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.
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.
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 comes back `unavailable` at once whatever the scope says.
2398
+
2399
+ #### An approval as data: `ApprovalDetails`
2400
+
2401
+ `confirm(title, message, signal?, minTier?, wait?, details?)` takes the approval twice: `message` is the card as markdown text, and the optional `details` (`ApprovalDetails`: `{ action, tool, input, files? }`) is the same held call as data, with its whole input (the web chat cuts it only past 256 KiB and says so with `truncated`), and each file it sends by path as `{ path, bytes? }`.
2402
+ The confirmation gate and a coding worker's cards pass both.
2403
+ A surface that shows text, such as Discord, uses `message` and ignores `details`; the web chat sends `details` to the client as the prompt frame's `approval`, beside the unchanged `title` and `message`.
2404
+ A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
2405
+ `confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
2398
2406
 
2399
2407
  #### A prompt that outlives its turn: `PromptWait`
2400
2408
 
@@ -2518,7 +2526,7 @@ export class FakeSurface implements ChatSurface {
2518
2526
  /**
2519
2527
  * How a turn asks to approve a held action or answers a question. The scope names who may
2520
2528
  * answer: its speaker when their tier holds the call, and the owners unless the conversation
2521
- * is private, where a call above the speaker's tier goes to no one and expires at once.
2529
+ * is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
2522
2530
  */
2523
2531
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
2524
2532
  return {
@@ -2527,7 +2535,7 @@ export class FakeSurface implements ChatSurface {
2527
2535
  scope !== undefined &&
2528
2536
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
2529
2537
  const owners = scope?.escalate !== "none";
2530
- if (!theirs && !owners) return "expired";
2538
+ if (!theirs && !owners) return "unavailable";
2531
2539
  return this.#ask(channel, title, signal, {
2532
2540
  ...(theirs ? { principalId: scope.principalId } : {}),
2533
2541
  owners,
@@ -3014,7 +3022,7 @@ It returns:
3014
3022
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
3015
3023
  `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s.
3016
3024
  The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
3017
- It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `expired` at once and is not shown.
3025
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `unavailable` (or `expired`, as older surfaces answer) at once and is not shown.
3018
3026
  A check for a part the surface does not offer passes.
3019
3027
  `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
3020
3028
 
@@ -3268,6 +3276,8 @@ Import from the entries listed below; source area files are internal.
3268
3276
  | `AgentTeam` | `pi-roundtable` | type |
3269
3277
  | `AgentTurnScope` | `pi-roundtable` | type |
3270
3278
  | `Approval` | `pi-roundtable` | type |
3279
+ | `ApprovalDetails` | `pi-roundtable` | type |
3280
+ | `ApprovalFile` | `pi-roundtable` | type |
3271
3281
  | `AskOption` | `pi-roundtable` | type |
3272
3282
  | `AttachmentFailure` | `pi-roundtable` | type |
3273
3283
  | `AttachmentPort` | `pi-roundtable` | type |
@@ -3318,6 +3328,7 @@ Import from the entries listed below; source area files are internal.
3318
3328
  | `HeldCall` | `pi-roundtable` | type |
3319
3329
  | `HoldCheck` | `pi-roundtable` | type |
3320
3330
  | `HoldContext` | `pi-roundtable` | type |
3331
+ | `HoldRefusal` | `pi-roundtable` | type |
3321
3332
  | `HoldRule` | `pi-roundtable` | type |
3322
3333
  | `HostEnv` | `pi-roundtable` | type |
3323
3334
  | `HostEnvironment` | `pi-roundtable` | type |
@@ -3534,6 +3545,7 @@ Import from the entries listed below; source area files are internal.
3534
3545
  | `AgentTurnRunner` | `pi-roundtable/kit` | type |
3535
3546
  | `AssistantLike` | `pi-roundtable/kit` | type |
3536
3547
  | `Backlog` | `pi-roundtable/kit` | type |
3548
+ | `CardLimits` | `pi-roundtable/kit` | type |
3537
3549
  | `CategoryLayout` | `pi-roundtable/kit` | type |
3538
3550
  | `ChannelMessage` | `pi-roundtable/kit` | type |
3539
3551
  | `ChannelQueue` | `pi-roundtable/kit` | type |
@@ -3581,6 +3593,7 @@ Import from the entries listed below; source area files are internal.
3581
3593
  | `YesNoQuestion` | `pi-roundtable/kit` | type |
3582
3594
  | `activeToolsExtension` | `pi-roundtable/kit` | value |
3583
3595
  | `approvalCard` | `pi-roundtable/kit` | value |
3596
+ | `approvalDetails` | `pi-roundtable/kit` | value |
3584
3597
  | `archiveSessions` | `pi-roundtable/kit` | value |
3585
3598
  | `attachmentsOf` | `pi-roundtable/kit` | value |
3586
3599
  | `callScheduleTool` | `pi-roundtable/kit` | value |
@@ -80,7 +80,7 @@ export class FakeSurface implements ChatSurface {
80
80
  /**
81
81
  * How a turn asks to approve a held action or answers a question. The scope names who may
82
82
  * answer: its speaker when their tier holds the call, and the owners unless the conversation
83
- * is private, where a call above the speaker's tier goes to no one and expires at once.
83
+ * is private, where a call above the speaker's tier goes to no one and comes back `unavailable` at once.
84
84
  */
85
85
  prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
86
86
  return {
@@ -89,7 +89,7 @@ export class FakeSurface implements ChatSurface {
89
89
  scope !== undefined &&
90
90
  TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
91
91
  const owners = scope?.escalate !== "none";
92
- if (!theirs && !owners) return "expired";
92
+ if (!theirs && !owners) return "unavailable";
93
93
  return this.#ask(channel, title, signal, {
94
94
  ...(theirs ? { principalId: scope.principalId } : {}),
95
95
  owners,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
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",
@@ -167,42 +167,48 @@ export class OwnerCards implements InteractionModule {
167
167
  confirm: async (title, message, signal, minTier = "owner", wait) => {
168
168
  const audience = await this.#audiences.approval(scope, minTier);
169
169
  // A private conversation's call above its person's tier is no one's to approve.
170
- if (!audience) return "expired";
171
- return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
172
- audience,
173
- ...(wait
174
- ? {
175
- wait: {
176
- pending: "pending",
177
- late: (value, by) =>
178
- wait.late({
179
- kind: "approval",
180
- approved: value === "approved",
181
- by,
182
- }),
183
- },
184
- }
185
- : {}),
186
- title,
187
- sections: [message],
188
- rows: (id, disabled) => [
189
- new ActionRowBuilder<ButtonBuilder>().addComponents(
190
- new ButtonBuilder()
191
- .setCustomId(`${CARD_PREFIX}${id}:yes`)
192
- .setLabel(messages().cardRunLabel)
193
- .setEmoji("✅")
194
- .setStyle(ButtonStyle.Success)
195
- .setDisabled(disabled),
196
- new ButtonBuilder()
197
- .setCustomId(`${CARD_PREFIX}${id}:no`)
198
- .setLabel(messages().cardCancelLabel)
199
- .setEmoji("❌")
200
- .setStyle(ButtonStyle.Secondary)
201
- .setDisabled(disabled),
202
- ),
203
- ],
204
- footer: messages().cardApprovalFooter,
205
- });
170
+ if (!audience) return "unavailable";
171
+ return this.#post<Approval>(
172
+ channelId,
173
+ signal,
174
+ "unavailable",
175
+ "cancelled",
176
+ {
177
+ audience,
178
+ ...(wait
179
+ ? {
180
+ wait: {
181
+ pending: "pending",
182
+ late: (value, by) =>
183
+ wait.late({
184
+ kind: "approval",
185
+ approved: value === "approved",
186
+ by,
187
+ }),
188
+ },
189
+ }
190
+ : {}),
191
+ title,
192
+ sections: [message],
193
+ rows: (id, disabled) => [
194
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
195
+ new ButtonBuilder()
196
+ .setCustomId(`${CARD_PREFIX}${id}:yes`)
197
+ .setLabel(messages().cardRunLabel)
198
+ .setEmoji("✅")
199
+ .setStyle(ButtonStyle.Success)
200
+ .setDisabled(disabled),
201
+ new ButtonBuilder()
202
+ .setCustomId(`${CARD_PREFIX}${id}:no`)
203
+ .setLabel(messages().cardCancelLabel)
204
+ .setEmoji("❌")
205
+ .setStyle(ButtonStyle.Secondary)
206
+ .setDisabled(disabled),
207
+ ),
208
+ ],
209
+ footer: messages().cardApprovalFooter,
210
+ },
211
+ );
206
212
  },
207
213
  ask: async (title, question, signal, wait) => {
208
214
  const audience = await this.#audiences.question(scope);
@@ -239,13 +245,13 @@ export class OwnerCards implements InteractionModule {
239
245
  }
240
246
 
241
247
  /**
242
- * Posts a card and resolves with its answer, `expired` when it could not be posted, or
248
+ * Posts a card and resolves with its answer, `unposted` when it could not be posted, or
243
249
  * `cancelled`; with `wait`, and a way to resume, `wait.pending` once the grace period passes.
244
250
  */
245
251
  #post<T>(
246
252
  channelId: string,
247
253
  signal: AbortSignal | undefined,
248
- expired: T,
254
+ unposted: T,
249
255
  cancelled: T,
250
256
  card: {
251
257
  audience: Audience;
@@ -350,7 +356,7 @@ export class OwnerCards implements InteractionModule {
350
356
  })
351
357
  .catch((error: unknown) => {
352
358
  logger.warn({ channelId, err: error }, "card not posted");
353
- settle(expired, undefined);
359
+ settle(unposted, undefined);
354
360
  });
355
361
  });
356
362
  }
@@ -4,7 +4,24 @@
4
4
  * arguments, never their full text, and ends with whether it succeeded. The final reply still
5
5
  * arrives through `sendReply`.
6
6
  */
7
+
8
+ /**
9
+ * Why a hold refused a tool call, as a tool's end reports it, so a surface need not tell a call
10
+ * the owner turned down from a call that failed: `declined` on its approval card, `expired` when
11
+ * a card was shown and went unanswered (the call is held for the owner's next message),
12
+ * `pending` while the card stays open, and `held` when no card could be shown (no one may approve
13
+ * it, or it could not be posted; the call waits for the owner's next message). A turn that was stopped is none of these.
14
+ */
15
+ export type HoldRefusal = "declined" | "expired" | "pending" | "held";
16
+
7
17
  export type TurnProgress =
8
18
  | { type: "text"; delta: string }
9
19
  | { type: "tool_start"; id: string; tool: string; preview?: string }
10
- | { type: "tool_end"; id: string; tool: string; ok: boolean };
20
+ | {
21
+ type: "tool_end";
22
+ id: string;
23
+ tool: string;
24
+ ok: boolean;
25
+ /** Set only on a failed end that a hold refused; absent for a success and for a real failure. */
26
+ refused?: HoldRefusal;
27
+ };
@@ -33,6 +33,8 @@ export function conversationEn(ctx: CatalogContext) {
33
33
  bytes === undefined
34
34
  ? `File \`${path}\` (not found now)`
35
35
  : `File \`${path}\` (${formatBytes(bytes)})`,
36
+ cardHiddenKeys: (keys: readonly string[]) =>
37
+ `… not shown: ${keys.join(", ")}`,
36
38
  };
37
39
  }
38
40
 
@@ -62,5 +64,6 @@ export function conversationZhTW(
62
64
  bytes === undefined
63
65
  ? `檔案 \`${path}\`(目前找不到)`
64
66
  : `檔案 \`${path}\`(${formatBytes(bytes)})`,
67
+ cardHiddenKeys: (keys: readonly string[]) => `… 未顯示:${keys.join("、")}`,
65
68
  };
66
69
  }
@@ -3,17 +3,40 @@ import type { TurnRequest } from "../domain/ports.ts";
3
3
  import type { Speaker, Tier } from "../speakers.ts";
4
4
 
5
5
  /**
6
- * How a prompt ended: answered yes or no; `expired` when no one could answer it (no one's to
7
- * approve, or the card could not be posted); `cancelled` when its turn stopped; or `pending` when
8
- * the turn stopped waiting while the card stays open, its answer to come through `PromptWait`.
6
+ * How a prompt ended: answered yes or no; `expired` when the card was shown and no one answered
7
+ * it in time; `unavailable` when no card could be shown (no one may approve the call, the speaker
8
+ * is below its tier, or the card could not be posted); `cancelled` when its turn stopped; or
9
+ * `pending` when the turn stopped waiting while the card stays open, its answer to come through
10
+ * `PromptWait`. A surface that cannot tell the two may answer `expired` for both.
9
11
  */
10
12
  export type Approval =
11
13
  | "approved"
12
14
  | "declined"
13
15
  | "expired"
16
+ | "unavailable"
14
17
  | "cancelled"
15
18
  | "pending";
16
19
 
20
+ /** A file a held call sends by path; `bytes` is its size when it was read, absent when it could not be. */
21
+ export interface ApprovalFile {
22
+ path: string;
23
+ bytes?: number;
24
+ }
25
+
26
+ /**
27
+ * What an approval is for, as data: the same facts a card's text shows, so a surface that is not
28
+ * text (a web client) need not read them back out of the markdown. `input` is the call's whole
29
+ * input, never cut.
30
+ */
31
+ export interface ApprovalDetails {
32
+ /** What the call would do, in plain words. */
33
+ action: string;
34
+ tool: string;
35
+ input: Record<string, unknown>;
36
+ /** The files the call sends by path; absent when it sends none. */
37
+ files?: ApprovalFile[];
38
+ }
39
+
17
40
  /** An answer given on a prompt after its turn stopped waiting; `by` names who gave it. */
18
41
  export type LateAnswer = { by: string } & (
19
42
  | { kind: "approval"; approved: boolean }
@@ -80,7 +103,7 @@ export interface PromptScope {
80
103
  /**
81
104
  * Who else may answer: `owners` in a shared conversation, where an approval above the
82
105
  * speaker's tier goes to the owners; `none` in a private one, which no one else sees, so such
83
- * an approval expires at once.
106
+ * an approval comes back `unavailable` at once.
84
107
  */
85
108
  escalate: "owners" | "none";
86
109
  }
@@ -95,7 +118,8 @@ export interface Prompts {
95
118
  * Asks to approve an action; `signal` cancels the prompt when the turn stops. `minTier` is the
96
119
  * tier the scope's speaker needs to approve it themselves, `owner` when absent; below it the
97
120
  * prompt goes to the owners, or, when the scope escalates to no one, resolves `expired` at once.
98
- * `pending` comes only with a `wait`.
121
+ * `pending` comes only with a `wait`. `details` is the approval as data beside `message`, its
122
+ * text; a surface that shows text only ignores it.
99
123
  */
100
124
  confirm(
101
125
  title: string,
@@ -103,6 +127,7 @@ export interface Prompts {
103
127
  signal?: AbortSignal,
104
128
  minTier?: Tier,
105
129
  wait?: PromptWait,
130
+ details?: ApprovalDetails,
106
131
  ): Promise<Approval>;
107
132
  /**
108
133
  * Asks a question; undefined when no one answered it or it was cancelled, and `pending`, only
@@ -1,9 +1,12 @@
1
1
  import {
2
+ buildSessionProjection,
2
3
  type ExtensionAPI,
3
4
  type ExtensionFactory,
4
5
  estimateTokens,
5
6
  type SessionBeforeCompactEvent,
7
+ type SessionEntry,
6
8
  type SessionManager,
9
+ type SessionProjection,
7
10
  SettingsManager,
8
11
  } from "@earendil-works/pi-coding-agent";
9
12
 
@@ -29,8 +32,14 @@ export type CompactionHistory = Pick<
29
32
  export interface LatestCompaction {
30
33
  id: string;
31
34
  engine: CompactionEngine;
32
- /** Estimated context the compaction left: its summary and the entries it kept. */
35
+ /**
36
+ * The context the compaction left, as a request sends it: system prompt and tools included.
37
+ * The first request after it reports this; until then, its summary and kept entries plus the
38
+ * fixed part the last request before it carried.
39
+ */
33
40
  contextTokens: number;
41
+ /** Whether a request after the compaction reported `contextTokens`, or it is still estimated. */
42
+ measured: boolean;
34
43
  }
35
44
 
36
45
  /** The extension's compactions record its `engine` in their details; Pi's own do not. */
@@ -44,6 +53,41 @@ export function compactionEngine(
44
53
  : "pi";
45
54
  }
46
55
 
56
+ /** What a request sent, as its reply's usage reports it: undefined for a reply without usable usage. */
57
+ function sentTokens(entry: SessionEntry | undefined): number | undefined {
58
+ if (entry?.type !== "message") return undefined;
59
+ const message = entry.message;
60
+ if (message.role !== "assistant") return undefined;
61
+ if (message.stopReason === "aborted" || message.stopReason === "error")
62
+ return undefined;
63
+ const { input, cacheRead, cacheWrite } = message.usage;
64
+ const sent = input + cacheRead + cacheWrite;
65
+ return sent > 0 ? sent : undefined;
66
+ }
67
+
68
+ function estimated(messages: SessionProjection["messages"]): number {
69
+ let tokens = 0;
70
+ for (const message of messages) tokens += estimateTokens(message);
71
+ return tokens;
72
+ }
73
+
74
+ /**
75
+ * The part of a request that is not the conversation, system prompt and tool definitions, as the
76
+ * last reply before `index` reported it: what that request sent less the estimate of its messages.
77
+ */
78
+ function fixedTokens(branch: readonly SessionEntry[], index: number): number {
79
+ for (let at = index - 1; at >= 0; at--) {
80
+ const sent = sentTokens(branch[at]);
81
+ if (sent === undefined) continue;
82
+ const messages = buildSessionProjection(
83
+ branch.slice(0, at),
84
+ branch[at - 1]?.id ?? null,
85
+ ).messages;
86
+ return Math.max(0, sent - estimated(messages));
87
+ }
88
+ return 0;
89
+ }
90
+
47
91
  /** The branch's latest compaction, measured from the session as it stands now. */
48
92
  export function latestCompaction(
49
93
  history: CompactionHistory,
@@ -56,16 +100,22 @@ export function latestCompaction(
56
100
  const upToCompaction = new Set(
57
101
  branch.slice(0, index + 1).map((each) => each.id),
58
102
  );
59
- let contextTokens = 0;
103
+ const engine = compactionEngine(entry.details, extensionEngine);
104
+ for (const after of branch.slice(index + 1)) {
105
+ const sent = sentTokens(after);
106
+ if (sent !== undefined)
107
+ return { id: entry.id, engine, contextTokens: sent, measured: true };
108
+ }
109
+ let messages = 0;
60
110
  for (const projected of history.buildSessionProjection().entries) {
61
111
  if (!upToCompaction.has(projected.sourceEntry.id)) continue;
62
- for (const message of projected.messages)
63
- contextTokens += estimateTokens(message);
112
+ messages += estimated(projected.messages);
64
113
  }
65
114
  return {
66
115
  id: entry.id,
67
- engine: compactionEngine(entry.details, extensionEngine),
68
- contextTokens,
116
+ engine,
117
+ contextTokens: messages + fixedTokens(branch, index),
118
+ measured: false,
69
119
  };
70
120
  }
71
121
 
@@ -124,12 +174,15 @@ export class CompactionTiers {
124
174
  this.#extensionEngine = extensionEngine;
125
175
  }
126
176
 
127
- /** The latest compaction; measured once per compaction, since pi checks before every request. */
177
+ /**
178
+ * The latest compaction; pi checks before every request, so it is measured once per compaction
179
+ * after a request reports it, and estimated again until one does.
180
+ */
128
181
  latest(): LatestCompaction | undefined {
129
182
  const branch = this.#history.getBranch();
130
183
  const entry = branch.findLast((each) => each.type === "compaction");
131
184
  if (!entry) return undefined;
132
- if (this.#cached?.id !== entry.id) {
185
+ if (this.#cached?.id !== entry.id || !this.#cached.latest.measured) {
133
186
  const latest = latestCompaction(this.#history, this.#extensionEngine);
134
187
  if (!latest) return undefined;
135
188
  this.#cached = { id: entry.id, latest };
@@ -0,0 +1,210 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { HeldCall } from "../../domain/conversation.ts";
4
+ import { messages } from "../../i18n/index.ts";
5
+ import type { ApprovalDetails } from "../../interactions/prompts.ts";
6
+
7
+ /** The fewest characters of a long string value in a held call's input its approval card keeps, when the input does not fit whole. */
8
+ const CARD_VALUE_CHARS = 200;
9
+
10
+ /** How many characters of the whole input its approval card shows, at most. */
11
+ const CARD_INPUT_CHARS = 1_500;
12
+
13
+ /** How many hidden key names a card lists, and how long each may be. */
14
+ const CARD_HIDDEN_KEYS = 20;
15
+ const CARD_KEY_CHARS = 40;
16
+
17
+ /** What an approval card shows of a call's input; see `approvalCard`. */
18
+ export interface CardLimits {
19
+ /**
20
+ * The fewest characters of a long string value (at any depth) kept when the input is over
21
+ * `totalChars`; a value is cut only as far as the input needs, never below this. Counted in code points.
22
+ */
23
+ valueChars?: number;
24
+ /** Longest input shown. An input within it is shown whole; a longer one has its values cut, then the rest dropped. */
25
+ totalChars?: number;
26
+ }
27
+
28
+ /**
29
+ * What an approval card says: the held action's words, the exact call, and each file it sends by
30
+ * path with the file's size now; relative paths resolve against `workspace`. An input within
31
+ * `limits.totalChars` is shown whole. A longer one has its string values cut, the longest first
32
+ * and never below `limits.valueChars`, each marked `… [N chars]` with its original length in code
33
+ * points, until it fits, so one long value does not push the keys after it out of the card. If it
34
+ * still does not fit, the rest is dropped and the keys that are no longer shown are listed.
35
+ */
36
+ export function approvalCard(
37
+ call: HeldCall,
38
+ workspace?: string,
39
+ limits: CardLimits = {},
40
+ ): string {
41
+ const input = shownInput(
42
+ call.input,
43
+ limits.valueChars ?? CARD_VALUE_CHARS,
44
+ limits.totalChars ?? CARD_INPUT_CHARS,
45
+ );
46
+ const files = pathFiles(call.input).map(
47
+ (path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
48
+ );
49
+ return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
50
+ }
51
+
52
+ /** The input as a card shows it within `total` characters; see `approvalCard`. */
53
+ function shownInput(input: string, valueChars: number, total: number): string {
54
+ if (input.length <= total) return input;
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(input);
58
+ } catch {
59
+ return `${prefix(input, total)}…`;
60
+ }
61
+ const longest = longestString(parsed);
62
+ const cutAt = (limit: number) => cutValues(parsed, limit);
63
+ let limit = valueChars;
64
+ if (cutAt(limit).length <= total) {
65
+ // The largest limit that still fits: the longest values give up characters first.
66
+ let low = limit;
67
+ let high = Math.max(low, longest - 1);
68
+ while (low < high) {
69
+ const mid = Math.ceil((low + high) / 2);
70
+ if (cutAt(mid).length <= total) low = mid;
71
+ else high = mid - 1;
72
+ }
73
+ limit = low;
74
+ return cutAt(limit);
75
+ }
76
+ const shown = cutAt(limit);
77
+ const kept = prefix(shown, total);
78
+ return `${kept}…${hiddenKeys(parsed, limit, kept.length)}`;
79
+ }
80
+
81
+ /** The longest string value in `value`, at any depth, in code points; 0 when there is none. */
82
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
83
+ function longestString(value: unknown): number {
84
+ let longest = 0;
85
+ JSON.stringify(value, (_key, v: unknown) => {
86
+ if (typeof v === "string" && v.length > longest)
87
+ longest = Math.max(longest, countPoints(v));
88
+ return v;
89
+ });
90
+ return longest;
91
+ }
92
+
93
+ /** `value` as JSON with each string longer than `limit` code points cut and marked `… [N chars]`. */
94
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
95
+ function cutValues(value: unknown, limit: number): string {
96
+ return JSON.stringify(value, (_key, v: unknown) => {
97
+ if (typeof v !== "string" || v.length <= limit) return v;
98
+ const points = countPoints(v);
99
+ if (points <= limit) return v;
100
+ return `${firstPoints(v, limit)}… [${points} chars]`;
101
+ });
102
+ }
103
+
104
+ /** The top-level keys, as a line for the card, whose names fall after the first `shown` characters of the cut input. */
105
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
106
+ function hiddenKeys(parsed: unknown, limit: number, shown: number): string {
107
+ if (!isPlainObject(parsed)) return "";
108
+ const hidden: string[] = [];
109
+ // Entries are laid out as `{"k":v,"k2":v2}`; an entry starts after the `{` and each comma.
110
+ let offset = 1;
111
+ for (const [key, value] of Object.entries(parsed)) {
112
+ if (offset >= shown) hidden.push(key);
113
+ offset += `${JSON.stringify(key)}:${cutValues(value, limit)},`.length;
114
+ }
115
+ if (hidden.length === 0) return "";
116
+ const names = hidden
117
+ .slice(0, CARD_HIDDEN_KEYS)
118
+ .map((key) => JSON.stringify(firstPoints(key, CARD_KEY_CHARS)));
119
+ if (hidden.length > CARD_HIDDEN_KEYS) names.push("…");
120
+ return `\n${messages().cardHiddenKeys(names)}`;
121
+ }
122
+
123
+ /** How many code points `text` has. */
124
+ function countPoints(text: string): number {
125
+ let points = 0;
126
+ for (let i = 0; i < text.length; i++) {
127
+ const code = text.charCodeAt(i);
128
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) i++;
129
+ points++;
130
+ }
131
+ return points;
132
+ }
133
+
134
+ /** The first `count` code points of `text`. */
135
+ function firstPoints(text: string, count: number): string {
136
+ let end = 0;
137
+ for (let points = 0; points < count && end < text.length; points++) {
138
+ const code = text.charCodeAt(end);
139
+ end += code >= 0xd800 && code <= 0xdbff && end + 1 < text.length ? 2 : 1;
140
+ }
141
+ return text.slice(0, end);
142
+ }
143
+
144
+ /** The first `max` characters of `text`, backing off a cut that would split a surrogate pair. */
145
+ function prefix(text: string, max: number): string {
146
+ if (text.length <= max) return text;
147
+ const code = text.charCodeAt(max - 1);
148
+ return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
149
+ }
150
+
151
+ /**
152
+ * A held call as data for a card that is not text: the action, the tool, the whole input, and
153
+ * each file it sends by path with its size now (absent when unreadable). Unlike the text card,
154
+ * nothing is cut and nothing is worded for a locale.
155
+ */
156
+ export function approvalDetails(
157
+ call: HeldCall,
158
+ workspace?: string,
159
+ ): ApprovalDetails {
160
+ const files = pathFiles(call.input).map((path) => {
161
+ const bytes = fileSize(path, workspace);
162
+ return bytes === undefined ? { path } : { path, bytes };
163
+ });
164
+ return {
165
+ action: call.action,
166
+ tool: call.tool,
167
+ input: parsedInput(call.input),
168
+ ...(files.length > 0 ? { files } : {}),
169
+ };
170
+ }
171
+
172
+ /** A call's input object; `{}` for input that is not one (a held call's always is). */
173
+ function parsedInput(input: string): Record<string, unknown> {
174
+ try {
175
+ const parsed: unknown = JSON.parse(input);
176
+ return isPlainObject(parsed) ? parsed : {};
177
+ } catch {
178
+ return {};
179
+ }
180
+ }
181
+
182
+ /** The paths of a call's `files` entries that name one. */
183
+ function pathFiles(input: string): string[] {
184
+ let parsed: unknown;
185
+ try {
186
+ parsed = JSON.parse(input);
187
+ } catch {
188
+ return [];
189
+ }
190
+ const files = isPlainObject(parsed) ? parsed.files : undefined;
191
+ if (!Array.isArray(files)) return [];
192
+ return files.flatMap((file: unknown) =>
193
+ isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
194
+ );
195
+ }
196
+
197
+ /** A file's size in bytes, or undefined when it cannot be read. */
198
+ function fileSize(path: string, workspace: string | undefined) {
199
+ try {
200
+ const info = statSync(resolve(workspace ?? "/", path));
201
+ return info.isFile() ? info.size : undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ // pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
208
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
209
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
210
+ }
@@ -1,10 +1,9 @@
1
- import { statSync } from "node:fs";
2
- import { resolve } from "node:path";
3
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
4
2
  import type {
5
3
  HeldCall,
6
4
  PendingConfirmation,
7
5
  } from "../../domain/conversation.ts";
6
+ import type { HoldRefusal } from "../../domain/progress.ts";
8
7
  import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
9
8
  import { messages } from "../../i18n/index.ts";
10
9
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
@@ -12,9 +11,7 @@ import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
12
11
  import type { Speaker } from "../../speakers.ts";
13
12
  import type { ToolTiers } from "../../tool-tiers.ts";
14
13
  import type { PromptSlot } from "../prompt-slot.ts";
15
-
16
- /** How much of a held call's input its approval card shows. */
17
- const CARD_INPUT_CHARS = 1_500;
14
+ import { approvalCard, approvalDetails } from "./approval-card.ts";
18
15
 
19
16
  /** Held actions older than this cannot be approved any more. */
20
17
  export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
@@ -153,38 +150,50 @@ export class ConfirmationGate {
153
150
 
154
151
  /**
155
152
  * `review`, plus whether a card's approval released the call, so its result can say so:
156
- * the card's own message is shown to the person, never to the model.
153
+ * the card's own message is shown to the person, never to the model. When a hold refuses the
154
+ * call, `refused` says how, for the turn's progress to report beside the failed result.
157
155
  */
158
156
  async decide(
159
157
  tool: string,
160
158
  input: Record<string, unknown>,
161
159
  ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
162
- ): Promise<{ reason?: string; approvedOnCard?: true }> {
160
+ ): Promise<{
161
+ reason?: string;
162
+ approvedOnCard?: true;
163
+ /** Why a hold refused the call, for a surface that tells that from a failure; absent when it ran or the turn stopped. */
164
+ refused?: HoldRefusal;
165
+ }> {
163
166
  const call = this.#needing(tool, input);
164
167
  if (!call) return {};
165
- if (!ask) return { reason: this.#record(call) };
168
+ if (!ask) return { reason: this.#record(call), refused: "held" };
166
169
  const answer = await ask.prompts.confirm(
167
170
  messages().confirmTitle(ask.asker),
168
171
  approvalCard(call, this.#fileRoot),
169
172
  ask.signal,
170
173
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
171
174
  { late: (late) => this.#late(call, late) },
175
+ approvalDetails(call, this.#fileRoot),
172
176
  );
173
177
  if (answer === "approved") return { approvedOnCard: true };
174
178
  if (answer === "pending") {
175
179
  const o = ownerWords(this.#addressee);
176
180
  return {
177
181
  reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
182
+ refused: "pending",
178
183
  };
179
184
  }
180
185
  if (answer === "declined") {
181
186
  const o = ownerWords(this.#addressee);
182
187
  return {
183
188
  reason: `${o.name} declined this on its approval card: it would ${call.action}. Do not retry it; go on without it, or ask ${o.him} what ${o.he} wants instead.`,
189
+ refused: "declined",
184
190
  };
185
191
  }
186
192
  if (answer === "cancelled") return { reason: "The turn was stopped." };
187
- return { reason: this.#record(call) };
193
+ // No card was shown: no one may approve it, or it could not be posted.
194
+ if (answer === "unavailable")
195
+ return { reason: this.#record(call), refused: "held" };
196
+ return { reason: this.#record(call), refused: "expired" };
188
197
  }
189
198
 
190
199
  /**
@@ -260,46 +269,6 @@ export function confirmedTurnText(
260
269
  return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
261
270
  }
262
271
 
263
- /**
264
- * What an approval card says: the held action's words, the exact call, and each file it sends by
265
- * path with the file's size now; relative paths resolve against `workspace`.
266
- */
267
- export function approvalCard(call: HeldCall, workspace?: string): string {
268
- const input =
269
- call.input.length > CARD_INPUT_CHARS
270
- ? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
271
- : call.input;
272
- const files = pathFiles(call.input).map(
273
- (path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
274
- );
275
- return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
276
- }
277
-
278
- /** The paths of a call's `files` entries that name one. */
279
- function pathFiles(input: string): string[] {
280
- let parsed: unknown;
281
- try {
282
- parsed = JSON.parse(input);
283
- } catch {
284
- return [];
285
- }
286
- const files = isPlainObject(parsed) ? parsed.files : undefined;
287
- if (!Array.isArray(files)) return [];
288
- return files.flatMap((file: unknown) =>
289
- isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
290
- );
291
- }
292
-
293
- /** A file's size in bytes, or undefined when it cannot be read. */
294
- function fileSize(path: string, workspace: string | undefined) {
295
- try {
296
- const info = statSync(resolve(workspace ?? "/", path));
297
- return info.isFile() ? info.size : undefined;
298
- } catch {
299
- return undefined;
300
- }
301
- }
302
-
303
272
  /** Without a slot, or with an empty one, every call that needs confirmation is held. */
304
273
  export function confirmationGateExtension(
305
274
  gate: ConfirmationGate,
@@ -324,12 +293,13 @@ export function confirmationGateExtension(
324
293
  ask = { prompts, asker: slot.asker };
325
294
  if (ctx.signal) ask.signal = ctx.signal;
326
295
  }
327
- const { reason, approvedOnCard } = await gate.decide(
296
+ const { reason, approvedOnCard, refused } = await gate.decide(
328
297
  event.toolName,
329
298
  event.input as Record<string, unknown>,
330
299
  ask,
331
300
  );
332
301
  if (approvedOnCard) approved.add(event.toolCallId);
302
+ if (refused) slot?.refuse(event.toolCallId, refused);
333
303
  return reason ? { block: true, reason } : undefined;
334
304
  });
335
305
  };
@@ -228,8 +228,11 @@ export class PiAgentRuntime implements AgentRuntime {
228
228
  // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
229
229
  const interim = interimPoster(request, this.#options);
230
230
  // The caller's live view: the text as it streams and each tool, never the thinking.
231
+ const slot = this.#sessions.slot(key);
231
232
  const progress = request.progress
232
- ? progressReporter(request.progress)
233
+ ? progressReporter(request.progress, {
234
+ refusalOf: (id) => slot.refusalOf(id),
235
+ })
233
236
  : undefined;
234
237
  const unsubscribe = session.subscribe((event) => {
235
238
  progress?.observe(event);
@@ -243,7 +246,6 @@ export class PiAgentRuntime implements AgentRuntime {
243
246
  interim?.messageEnd(event.message);
244
247
  }
245
248
  });
246
- const slot = this.#sessions.slot(key);
247
249
  slot.bind(
248
250
  request.interactive
249
251
  ? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
@@ -1,3 +1,4 @@
1
+ import type { HoldRefusal } from "../domain/progress.ts";
1
2
  import { assistantName } from "../i18n/index.ts";
2
3
  import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
4
 
@@ -15,6 +16,7 @@ export class PromptSlot {
15
16
  #open = 0;
16
17
  #openSince = 0;
17
18
  #waited = 0;
19
+ #refused = new Map<string, HoldRefusal>();
18
20
 
19
21
  /**
20
22
  * Binds the turn's prompts; `asker` names who asks, the assistant or an agent. `beforeCard`
@@ -30,6 +32,19 @@ export class PromptSlot {
30
32
  this.#asker = asker;
31
33
  this.#open = 0;
32
34
  this.#waited = 0;
35
+ this.#refused.clear();
36
+ }
37
+
38
+ /** Notes that a hold refused a tool call, for the progress of the turn to report once. */
39
+ refuse(toolCallId: string, refusal: HoldRefusal): void {
40
+ this.#refused.set(toolCallId, refusal);
41
+ }
42
+
43
+ /** Why a hold refused a tool call, once; undefined when it did not. */
44
+ refusalOf(toolCallId: string): HoldRefusal | undefined {
45
+ const refusal = this.#refused.get(toolCallId);
46
+ this.#refused.delete(toolCallId);
47
+ return refusal;
33
48
  }
34
49
 
35
50
  unbind(): void {
@@ -46,9 +61,9 @@ export class PromptSlot {
46
61
  const prompts = this.#prompts;
47
62
  if (!prompts) return undefined;
48
63
  return {
49
- confirm: (title, message, signal, minTier, wait) =>
64
+ confirm: (title, message, signal, minTier, wait, details) =>
50
65
  this.#waiting(() =>
51
- prompts.confirm(title, message, signal, minTier, wait),
66
+ prompts.confirm(title, message, signal, minTier, wait, details),
52
67
  ),
53
68
  ask: (title, question: OwnerQuestion, signal, wait) =>
54
69
  this.#waiting(() => prompts.ask(title, question, signal, wait)),
@@ -207,12 +207,14 @@ export class SessionFactory {
207
207
 
208
208
  /**
209
209
  * Logs who compacted a conversation, the compaction extension or Pi's summary, by how much, and the context size
210
- * that compacts it next, so a move to the hard ceiling shows.
210
+ * that compacts it next, so a move to the hard ceiling shows. `contextAfter` is the context the tiers measure,
211
+ * system prompt and tools included, which `tokensAfter`, the conversation alone, leaves out.
211
212
  */
212
213
  #logCompaction(
213
214
  channel: ChannelKey,
214
215
  event: CompactionEnd,
215
216
  session: AgentSession,
217
+ tiers: CompactionTiers,
216
218
  ): void {
217
219
  const { logger } = this.#options;
218
220
  const trigger = event.reason === "manual" ? "self" : event.reason;
@@ -239,6 +241,7 @@ export class SessionFactory {
239
241
  ),
240
242
  tokensBefore: event.result.tokensBefore,
241
243
  tokensAfter: event.result.estimatedTokensAfter,
244
+ contextAfter: tiers.latest()?.contextTokens,
242
245
  nextCompactionAt:
243
246
  model &&
244
247
  model.contextWindow -
@@ -423,7 +426,7 @@ export class SessionFactory {
423
426
  await this.#awaitTools(session, awaited);
424
427
  session.subscribe((event) => {
425
428
  if (event.type === "compaction_end")
426
- this.#logCompaction(channel, event, session);
429
+ this.#logCompaction(channel, event, session, tiers);
427
430
  });
428
431
  return Object.assign(state, { session });
429
432
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
- import type { TurnProgress } from "../domain/progress.ts";
2
+ import type { HoldRefusal, TurnProgress } from "../domain/progress.ts";
3
3
 
4
4
  /** How long text is joined before it is sent, so a fast stream costs a few events a second. */
5
5
  export const PROGRESS_INTERVAL_MS = 250;
@@ -19,6 +19,11 @@ export interface ProgressReporter {
19
19
 
20
20
  export interface ProgressReporterOptions {
21
21
  intervalMs?: number;
22
+ /**
23
+ * Why a hold refused a tool's call, asked when a failed tool ends and read once; the gate that
24
+ * refused it is how a surface tells that from a failure.
25
+ */
26
+ refusalOf?: (toolCallId: string) => HoldRefusal | undefined;
22
27
  /** Runs `run` after `ms`; returns a cancel. Replaceable in tests. */
23
28
  setTimer?: (run: () => void, ms: number) => () => void;
24
29
  }
@@ -104,7 +109,14 @@ export function progressReporter(
104
109
  toolEnd(id, tool, isError) {
105
110
  if (closed) return;
106
111
  flush();
107
- send({ type: "tool_end", id, tool, ok: !isError });
112
+ const refused = isError ? options.refusalOf?.(id) : undefined;
113
+ send({
114
+ type: "tool_end",
115
+ id,
116
+ tool,
117
+ ok: !isError,
118
+ ...(refused ? { refused } : {}),
119
+ });
108
120
  },
109
121
  close() {
110
122
  if (closed) return;
@@ -332,7 +332,7 @@ const CHECKS: readonly Check[] = [
332
332
  answeredOnlyByThem(running, running.subject.owner, "private"),
333
333
  },
334
334
  {
335
- name: "a private conversation's approval above the person's tier goes to no one: it expires at once",
335
+ name: "a private conversation's approval above the person's tier goes to no one: it comes back at once, unshown",
336
336
  applies: (subject) =>
337
337
  subject.surface.prompts !== undefined &&
338
338
  speakerOf(subject).tier !== "owner",
@@ -349,7 +349,8 @@ const CHECKS: readonly Check[] = [
349
349
  Bun.sleep(1000).then(() => "still open" as const),
350
350
  ]);
351
351
  expectThat(
352
- answer === "expired",
352
+ // `expired` is what surfaces answered before `unavailable` existed.
353
+ answer === "unavailable" || answer === "expired",
353
354
  `an approval no one may answer came back ${answer}`,
354
355
  );
355
356
  expectThat(
@@ -444,7 +445,7 @@ export async function checkSurfaceContract(
444
445
  * where it offers them, delivers files, ends typing and stop controls idempotently, shows
445
446
  * progress, resolves approvals as the person answers or as a stopped turn cancels them, ignores
446
447
  * another person's answer to the person's approval, and, in a private conversation, an owner's
447
- * too, and lets an approval above the person's tier there expire at once, shown to no one. Each
448
+ * too, and lets an approval above the person's tier there come back at once, shown to no one. Each
448
449
  * check gets a fresh subject from `make`; a check for an optional part the surface lacks passes.
449
450
  */
450
451
  export function describeSurfaceContract(
package/src/index.ts CHANGED
@@ -118,7 +118,7 @@ export type {
118
118
  InterimTextMode,
119
119
  } from "./core/domain/interim.ts";
120
120
  export type { TurnConversation, TurnRequest } from "./core/domain/ports.ts";
121
- export type { TurnProgress } from "./core/domain/progress.ts";
121
+ export type { HoldRefusal, TurnProgress } from "./core/domain/progress.ts";
122
122
  export type { DrainOptions } from "./core/drain.ts";
123
123
  export {
124
124
  HostStoppingError,
@@ -161,6 +161,8 @@ export type {
161
161
  export { SYSTEM_PRINCIPAL } from "./core/identity/principal-store.ts";
162
162
  export type {
163
163
  Approval,
164
+ ApprovalDetails,
165
+ ApprovalFile,
164
166
  AskOption,
165
167
  LateAnswer,
166
168
  OwnerAnswer,
package/src/kit/index.ts CHANGED
@@ -158,6 +158,7 @@ export {
158
158
  toolText,
159
159
  } from "./tools.ts";
160
160
  export type {
161
+ CardLimits,
161
162
  InterimPosterOptions,
162
163
  McpEndpoint,
163
164
  PromptSlot,
@@ -165,6 +166,7 @@ export type {
165
166
  } from "./worker.ts";
166
167
  export {
167
168
  approvalCard,
169
+ approvalDetails,
168
170
  archiveSessions,
169
171
  canonicalJson,
170
172
  InterimPoster,
package/src/kit/worker.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // Plugin helpers, versioned like the main entry; see the plugin guide.
2
2
  // Running a Pi session of your own: MCP servers for it, its work timeout, its held-action cards.
3
3
 
4
+ export type { CardLimits } from "../core/runtime/extensions/approval-card.ts";
4
5
  export {
5
6
  approvalCard,
6
- canonicalJson,
7
- } from "../core/runtime/extensions/confirmation-gate.ts";
7
+ approvalDetails,
8
+ } from "../core/runtime/extensions/approval-card.ts";
9
+ export { canonicalJson } from "../core/runtime/extensions/confirmation-gate.ts";
8
10
  export type { InterimPosterOptions } from "../core/runtime/interim-text.ts";
9
11
  export { InterimPoster } from "../core/runtime/interim-text.ts";
10
12
  export type { VirtualServer } from "../core/runtime/mcp.ts";