@ian-pascoe/pi-advisor 0.5.0 → 0.7.0

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@ian-pascoe/pi-advisor` reviews a Pi agent's completed work and surfaces concise, attributed findings for material problems and worthwhile low-risk cleanup.
4
4
 
5
- Requires Node `>=22.19.0` and Pi `>=0.99.0`.
5
+ Requires Node `>=22.19.0` and Pi `>=1.1.0`.
6
6
 
7
7
  ## Install
8
8
 
@@ -26,13 +26,17 @@ Advisor is **disabled by default**. Configuration precedence is session, trusted
26
26
  /advisor set includeSubagents true
27
27
  /advisor set model "provider/model-id"
28
28
  /advisor set maxFindingsPerReview 4
29
+ /advisor set maxNitsPerRequest 3
30
+ /advisor set seedBudgetTokens 40000
31
+ /advisor set reviewEvery "request"
32
+ /advisor set maxSessionTokens 150000
29
33
  ```
30
34
 
31
35
  Argument autocomplete suggests command names, settings keys, and valid trailing scope flags.
32
36
 
33
37
  In the interactive TUI, `/advisor` alone opens a settings menu built from Pi's native settings list, like `/settings`. A Scope row chooses where edits are written (session, trusted project, or global), and each setting shows its effective value with any non-default source. `enabled`, `includeSubagents`, and `thinkingLevel` cycle the selected scope's own value in place (`inherit → on → off`, or through each thinking level); while that scope has none, the row shows what it inherits, such as `inherit (on · project)`, and a value overridden by a higher-precedence scope is marked. Model, tools, numbers, and the prompt open submenus, and the prompt uses Pi's own editor component. Every edit is validated and applied immediately; a paused Advisor shows its reason and a Resume row. Closing the menu records one status entry listing the changes it applied, or nothing if none were. Without the TUI, `/advisor` records the status entry instead.
34
38
 
35
- `/advisor status` and the other subcommands each record a status entry in the transcript. Collapsed, it shows state, model, backlog, usage and cost, unavailable tools, and the last error; expanded, it lists every setting with its source and each watched Child Agent. A mutating command's entry starts with the changes it applied. While enabled, the footer shows `advisor` when idle, `advisor: reviewing`/`consulting` with any `backlog N`, or `advisor: paused`, plus Child Agents that are reviewing or paused. `advisor_ask` renders its question and a Markdown answer preview, and Child Agent findings carry a `↳ <agentId>` label.
39
+ `/advisor status` and the other subcommands each record a status entry in the transcript. Collapsed, it shows state, model, backlog, usage and cost, the number of Reviews with their running cost and the last Review's cost, unavailable tools, and the last error; expanded, it lists every setting with its source and each watched Child Agent. A mutating command's entry starts with the changes it applied. While enabled, the footer shows `● advisor on` when idle, `● advisor reviewing`/`consulting` with any `backlog N`, or `! advisor paused`, plus Child Agents that are reviewing or paused. Interventions and status entries use Pi's custom-message box with a bold `Advisor` label; an Intervention's severity is a coloured word (`nit`, `concern`, `blocker`), and Child Agent findings add the agent ID. `advisor_ask` renders as Pi's built-in tools do: a lowercase `advisor_ask` header with the question, a Markdown answer collapsed to ten lines with Pi's expand hint, and Pi's `Elapsed`/`Took` footer. Warnings and errors read `Advisor: ...`.
36
40
 
37
41
  Interventions render with their severity and Advisor attribution. Long Nits collapse until expanded; Concerns and Blockers always show in full.
38
42
 
@@ -46,22 +50,26 @@ The tool is absent while Advisor is disabled. A paused Advisor keeps it visible
46
50
 
47
51
  Consultation authorizes analysis and investigation, not implementation or other side effects. The configured Advisor Prompt remains authoritative. Tool Grants still expose each granted tool's full native interface, so exclude mutating tools when a prompt-level boundary is insufficient.
48
52
 
49
- `prompt` opens Pi's native editor and replaces the whole Advisor Prompt. `inherit` removes an override at the selected scope. Invalid keys and values are rejected. Lists, including `allowedTools`, replace the inherited list rather than merge. `catchUpThreshold` accepts any positive safe integer or `"off"`; `reviewTimeoutMs` accepts 1–2,147,483,647 milliseconds (the native timer range); `maxFindingsPerReview` accepts an integer from 1 through 32.
53
+ `prompt` opens Pi's native editor and replaces the whole Advisor Prompt. `inherit` removes an override at the selected scope. Invalid keys and values are rejected. Lists, including `allowedTools`, replace the inherited list rather than merge. `catchUpThreshold` accepts any positive safe integer or `"off"`; `reviewTimeoutMs` accepts 1–2,147,483,647 milliseconds (the native timer range); `maxFindingsPerReview` accepts an integer from 1 through 32; `maxNitsPerRequest` accepts any non-negative safe integer, where `0` delivers no Nits; `seedBudgetTokens` and `maxSessionTokens` accept any positive safe integer or `"auto"`; `reviewEvery` accepts `"turn"`, `"request"`, or any positive safe integer number of turns.
50
54
 
51
55
  `advisor_ask` declares MCP-style `annotations` (read-only, non-destructive, idempotent, closed-world) that Pi reports through `pi.getAllTools()` for permission extensions; Pi does not send them to model providers. They describe the consultation itself, not the tools granted to the Advisor. The Advisor Session's internal `advisor_report` tool, which extensions inherited by that session can see, declares non-destructive, closed-world annotations and is not read-only, because it records the Review's findings.
52
56
 
53
57
  ## Defaults and access
54
58
 
55
- | Option | Default |
56
- | ------------------------------ | ---------------------------- |
57
- | Enabled | `false` |
58
- | Watched sessions | Main only |
59
- | Allowed tools | `read`, `grep`, `find`, `ls` |
60
- | Catch-up threshold | `3` |
61
- | Review deadline | 120 seconds |
62
- | Investigative calls per Review | 8 |
63
- | Findings per Review | 4 |
64
- | Automatic Corrective Turns | 1 per request/task |
59
+ | Option | Default |
60
+ | ------------------------------ | -------------------------------------------------------------- |
61
+ | Enabled | `false` |
62
+ | Watched sessions | Main only |
63
+ | Allowed tools | `read`, `grep`, `find`, `ls` |
64
+ | Catch-up threshold | `3` |
65
+ | Review deadline | 120 seconds |
66
+ | Investigative calls per Review | 8 |
67
+ | Findings per Review | 4 |
68
+ | Nits per request | 3 |
69
+ | Context Seed budget | `auto` (¼ of the Advisor model's context window, at most 100k) |
70
+ | Review Cadence (`reviewEvery`) | `turn` |
71
+ | Advisor Session size | `auto` (½ of the Advisor model's context window, at most 200k) |
72
+ | Automatic Corrective Turns | 1 per request/task |
65
73
 
66
74
  A Tool Grant names tools; it does not sandbox their full native interfaces. Explicitly granting `bash`, for example, permits any command, including mutations. Unavailable names are ignored and reported; status reflects the Advisor Session at that moment, so a granted tool that registers later, such as an MCP tool after its server connects, is reported until it does. Advisor never autogrants missing tools. Granted tools with `codemode` or `deferred` exposure (such as tools from Pi's built-in MCP) are not declared to the Advisor model; they stay callable from granted `codemode` scripts, and a granted `tool_search` can declare them, as in the observed agent. Granting `codemode` lets the Advisor script its other granted tools; it inherits the observed `codemode.mode`, so under `"only"` the Advisor calls `advisor_report` from a script. It also inherits whether scripts get the `models` API: Minimal Subagents Child Agents have none, so neither do their Advisors.
67
75
 
@@ -69,16 +77,44 @@ The Advisor inherits each observed agent's model and thinking level independentl
69
77
 
70
78
  ## Sessions and Context Management
71
79
 
72
- Each watched agent has a private, persisted native Advisor Session with fresh, session-bound extension resources. Reviews reuse that session until context or configuration changes require rebuilding. Pi's enabled `builtin:<name>` extensions are recreated in their observed order: `codemode`, `tool-search`, and `mcp` from Pi's exported factories, and `llama.cpp` from the installed Pi package's shipped extension file, retaining their metadata and fresh provider state. Built-ins disabled with `-builtin:<name>` stay absent. This is a built-in recreation recipe, not general inline-factory support; a host factory registered under a built-in name is rejected. Other opaque inline or custom extension resources that cannot be safely recreated are reported as unsupported and pause the Advisor; they are not silently reused or omitted. The Advisor's session state remains separate from the observed agent's state.
80
+ Each watched agent has a private, persisted native Advisor Session with fresh, session-bound extension resources. Reviews reuse that session until context or configuration changes require rebuilding. Pi's enabled `builtin:<name>` extensions are recreated in their observed order: `codemode`, `tool-search`, and `mcp` from Pi's exported factories, and `llama.cpp` from the installed Pi package's shipped extension file, retaining their metadata and fresh provider state. Built-ins disabled with `-builtin:<name>` stay absent. This is a built-in recreation recipe, not general inline-factory support; a host factory registered under a built-in name is rejected. Other opaque inline or custom extension resources that cannot be safely recreated are reported as unsupported and pause the Advisor; they are not silently reused or omitted. The Advisor's session state remains separate from the observed agent's state. The Advisor's own system prompt carries no skills catalogue, so skill changes in the Observed Agent's session no longer churn it.
81
+
82
+ Reviews and Consultations receive Review Evidence: what the observed model saw, not Pi's stored records. Messages keep their role, text, reasoning text, tool calls with arguments, tool-result text with error status, and image attachments; replay signatures, display-only tool `details`, provider metadata, and native IDs are omitted. Each tool call and its result share a short reference. Redacted reasoning and responses that errored, aborted, or hit the output limit are marked. The first Review or Consultation in an Advisor Session carries a Context Seed: the observed system prompt and each observed tool's name and one-line summary (not its full description or schema), plus the current conversation. Later Reviews and Consultations add only new messages.
83
+
84
+ The current conversation is already bounded by the observed session's native compaction, but can still be very large, so the Context Seed is fitted to `seedBudgetTokens`: Pi's chars/4 estimate of the seed JSON the Advisor receives, plus Pi's per-image estimate. The first Review or Consultation always sees the Observed Setup; the original request; and the newest turn with the request that prompted it. The original request is the first message the user typed into the current context; after compaction it is the compaction summary, which records the earlier goal, plus the first message the user typed after it. Compaction summaries, delivered Advisor findings, other extension messages, and `!` command output are never taken for a request. The rest of the budget holds the newest turns that fit, oldest first. A turn is one message, or an assistant message with all of its tool results, so a tool call is never separated from its result. Text that alone exceeds the remaining budget is shortened with a marker. When messages are omitted or shortened, the seed lists the positions, in the observed context, of the messages it keeps and names the observed session file, so granted tools such as `read` or `grep` can inspect the full messages. The Advisor's own `context_history` covers only its private session. Later Reviews still add only messages newer than the seed; omitted messages are not sent later. `auto`, the default, uses a quarter of the Advisor model's context window (Pi's 128k fallback when a model declares none), at most 100k tokens: the seed is resent with every inference of the first Review and stays in the Advisor Session, so the rest is left for the Advisor Prompt, investigation, and later Reviews, and an `auto` seed always fits within half the `auto` `maxSessionTokens`. Models with windows above 400k tokens get the 100k ceiling rather than a quarter of their window. An explicit budget is capped at the Advisor model's context window.
73
85
 
74
86
  Context Management is optional. If loaded and its tools are granted, `context_notes`, `context_history`, and `context_rollover` operate on the Advisor's private context. If any of those three tools is excluded, the Advisor pauses before reviewing. There is no autogrant or hook bypass.
75
87
 
88
+ ## Review Cadence and cost
89
+
90
+ Each Review is a new prompt in the same private Advisor Session, so its input is the whole Advisor Session so far: mostly prompt-cache reads, but billed on every Review. Two settings bound that cost.
91
+
92
+ `reviewEvery` sets the Review Cadence: when Reviews run. Whatever the cadence, a Review covers every turn not yet reviewed, and only messages the Advisor has not yet seen are added. When those messages would exceed `seedBudgetTokens`, as a coarse cadence can gather or, under any cadence, a single very large turn, Advisor instead rebuilds the Advisor Session from a Context Seed within that budget. Like any rebuild, this withdraws earlier Concerns and Blockers that were queued to steer the observed agent but not yet consumed; findings already in its context stay.
93
+
94
+ - `"turn"`, the default, reviews after every turn (one model response and its tool batch). Findings arrive soonest, and Concerns and Blockers can steer the agent while it still runs; cost grows with the number of turns.
95
+ - `N` reviews after every `N` turns and again when the request completes, for any turns left over.
96
+ - `"request"` reviews once when the request completes: the agent's run has ended, including its steering and follow-up messages, and Pi will not retry it automatically. This is the cheapest cadence, but findings arrive only after the agent has finished, so a Concern waits for the next continuation and a Blocker can only prompt a Corrective Turn.
97
+
98
+ Under `N` or `"request"`, a turn whose tool call failed still starts a Review at once. A Catch-up Wait waits only for a running Review: turns waiting for the next cadence point count toward the backlog but never start a Catch-up Wait themselves, and the wait ends when that Review finishes. Headless final drains and Minimal Subagents task completion review whatever turns remain, waiting up to one Review deadline (`reviewTimeoutMs`, 120 seconds by default) rather than the 30-second Catch-up Wait ceiling, so a Review covering a whole request can finish. A Review already running when the observed session compacts still finishes and delivers its findings; the next Review rebuilds the Advisor Session from the compacted context.
99
+
100
+ `maxSessionTokens` bounds the Advisor Session itself. After a Review, when the Advisor Session's context exceeds the cap, Advisor compacts it with Pi's native compaction (`compact()`, as `/compact` does), which summarizes older Advisor history with the Advisor model and keeps Pi's `compaction.keepRecentTokens` of recent history; a cap below `keepRecentTokens` acts as `keepRecentTokens`. The summary is asked to keep the observed request, findings already reported, open concerns, and what the Advisor verified. Observed evidence continues incrementally afterwards, and delivered and deferred findings are kept by Advisor itself. Compaction is one extra Advisor model call that re-reads the history it summarizes without cache hits, counted in that Review's cost. It runs after the Review's findings are delivered, under its own deadline of `reviewTimeoutMs`. If it fails or times out, for example because an inherited extension cancels it, Advisor does not pause: it discards the Advisor Session, and the next Review starts a new one from a Context Seed. Pi declining because the whole Advisor Session is recent history it keeps is not a failure. `auto` takes half the Advisor model's context window (Pi's 128k fallback when a model declares none), at most 200k tokens: room for an `auto` Context Seed plus as much again for later Reviews, so a full seed alone never forces compaction, and far below Pi's own threshold (the window less `compaction.reserveTokens`), where each Review would re-read almost a full window. The 200k ceiling exists because the per-Review cost follows the Advisor Session's size, not the model's window: with a 1M-token window, half the window is about 500k tokens, where an Advisor Session was measured costing at least $0.12 for each Review that found nothing, and $0.20–0.34 for typical ones, from cache reads alone, more than the observed agent cost over the same period. At 200k, the same prices bound that floor at about 40% of it (about $0.05). A lower cap makes each Review cheaper at the price of more frequent compaction; a higher one, up to the window, compacts less often. When Context Management is loaded in the Advisor Session, its Rollover manages that context instead, and `maxSessionTokens` is not applied.
101
+
102
+ `/advisor status` shows the cost of the last Review, including any compaction it ran, and the running total of Reviews since the session started or Advisor was reloaded, from the native usage the Advisor Session records. Reviews that failed or were cancelled after prompting the Advisor count, since they were billed; Consultations do not. Its `tokens` and `cost` instead cover the current Advisor Session, including Consultations, and restart when that session is rebuilt. Unknown cost, such as for a model without prices, is shown as unknown; a Review that reported no usage on a priced model costs $0. Expanded, status also shows each Child Agent's Review cost.
103
+
76
104
  ## Scheduling and safety
77
105
 
78
- Reviews combine completed observed turns (one model response plus its tool batches) and use bounded catch-up waits of at most 30 seconds. Each Review has its own deadline, investigative-call budget, and configurable finding limit. One terminating report returns findings in severity order. Formatting-equivalent findings retain their highest severity within a Review; later escalation from Nit to Concern to Blocker remains deliverable. Distinct Concerns are delivered together when eligible or retained together for re-evaluation during the three-turn cooldown.
106
+ Reviews combine completed observed turns (one model response plus its tool batches), as often as `reviewEvery` allows, and use bounded catch-up waits of at most 30 seconds. Each Review has its own deadline, investigative-call budget, and configurable finding limit. One terminating report returns findings in severity order. Formatting-equivalent findings retain their highest severity within a Review; later escalation from Nit to Concern to Blocker remains deliverable. Distinct Concerns are delivered together when eligible or retained together for re-evaluation during the three-turn cooldown.
107
+
108
+ ## Finding quality
109
+
110
+ Each finding must name a concrete defect in work the observed agent has already done and cite its evidence: the short `ref` that Review Evidence gives each tool call and its result, or a short verbatim quote. Advice about what to do, test, or say next belongs in a consultation (`advisor_ask`), not in an Intervention. The default Advisor Prompt asks for this, and `advisor_report` requires an `evidence` object with `refs`, a `quote`, or both on every finding, so a replaced Advisor Prompt keeps the requirement. Advisor drops a finding that cites no evidence, or cites a `ref` it never supplied to that Advisor Session, and tells the Advisor which findings it dropped; quotes are not checked against the evidence. A report in the older single-finding form cites no evidence, so its finding is dropped too. An `advisor_report` call that does not match the report schema, such as a finding without `evidence`, is rejected with the reason so the Advisor can correct it; after two rejected calls the Review ends without findings rather than retrying until its deadline, and Advisor is not paused. Delivered Interventions keep their evidence in the journaled message details; the observed agent sees only the severity and message.
111
+
112
+ A Review judges the turns completed when it started. If the observed agent completes more turns before the Review's findings are delivered, those findings are superseded: they may already be fixed or explained, so none of them is delivered. Instead, the next Review receives them alongside the newer turns, re-validates them, and reports again only those that still apply; this adds no model call. Under `reviewEvery: "turn"` that Review is already due. Under `N` or `"request"` it waits for the next cadence point, at the latest the Review when the request completes, which normally has no newer turns to supersede it (unless a new request has already started). Findings are withheld this way at most once. If the agent also outruns a Review that was given withheld findings, all of that Review's Concerns and Blockers are delivered anyway, including ones it reports for the first time, since it judged them against newer turns, and all its Nits are dropped. This is a deliberate starvation guard: it is applied per Review, so it holds even when the re-validating Review rewords a finding or cites newer references. A withheld Concern that the next Review defers for the Concern cooldown keeps that mark. So findings are never held back for more than one extra Review, however fast turns arrive. Superseded findings still waiting when Advisor is disabled, reconfigured, resumed after a pause, or moved to another branch or session are dropped with the rest of its invalidated review state, and so are those whose re-validating Review does not finish within a headless or Child Agent final drain.
113
+
114
+ `maxNitsPerRequest` (default 3) limits the Nits delivered per request; further Nits are dropped. Concerns and Blockers are never capped. A request runs from a user prompt (or a Minimal Subagents task) through its completion, including the Review at its completion and any Corrective Turn; the count restarts with the next request. Superseded Nits are kept for re-validation only while the request has room for them. `/advisor status` shows how many findings await re-validation and how many were dropped since the session started or Advisor was reloaded: Nits over the cap, findings with missing or unknown evidence, Nits from a re-validating Review that was superseded again, and Reviews ended by invalid reports. The Nit count follows the selected branch, so it survives a configuration change within a request.
79
115
 
80
116
  Nits are non-interrupting and enter context at the next natural step boundary without starting a Corrective Turn. Running work receives native steering for eligible Concerns and Blockers. A Blocker after normal interactive completion may receive a tracked corrective continuation within the configured budget; aborted, uncertain, deliberately interrupted, and headless-completed work is preserved without a hidden restart. Child corrections stay inside Minimal's owned operation. Headless root shutdown allows only a bounded final drain and never starts hidden corrective work.
81
117
 
82
- Status reports effective settings, sources, review or consultation state, backlog, usage/cost, and the last error. Unknown cost is shown as unknown, never zero. Review failure pauses Advisor while leaving the observed agent running; changing configuration, branch, or session identity invalidates stale in-flight work.
118
+ Status reports effective settings, sources, review or consultation state, backlog, usage/cost of the current Advisor Session, Review cost, and the last error. Unknown cost is shown as unknown, never zero. Review failure pauses Advisor while leaving the observed agent running; changing configuration, branch, or session identity invalidates stale in-flight work.
83
119
 
84
120
  Advisor is privileged extension code. Review inherited extensions and granted tools before installing it in a session with access to local files, credentials, or mutating APIs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-advisor",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Background review and attributed corrective advice for Pi agent sessions.",
6
6
  "keywords": [
@@ -32,13 +32,13 @@
32
32
  "provenance": true
33
33
  },
34
34
  "dependencies": {
35
- "@ian-pascoe/pi-utils": "^0.3.1"
35
+ "@ian-pascoe/pi-utils": "^0.5.0"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-agent-core": ">=0.99.0",
39
- "@earendil-works/pi-ai": ">=0.99.0",
40
- "@earendil-works/pi-coding-agent": ">=0.99.0",
41
- "@earendil-works/pi-tui": ">=0.99.0",
38
+ "@earendil-works/pi-agent-core": ">=1.1.0",
39
+ "@earendil-works/pi-ai": ">=1.1.0",
40
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
41
+ "@earendil-works/pi-tui": ">=1.1.0",
42
42
  "typebox": "*"
43
43
  },
44
44
  "engines": {
@@ -53,7 +53,7 @@
53
53
  ]
54
54
  },
55
55
  "scripts": {
56
- "test": "vitest run --config ../../vitest.config.ts --root .",
56
+ "test": "vitest run",
57
57
  "typecheck": "tsc --noEmit -p tsconfig.json"
58
58
  }
59
59
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pi-advisor
3
- description: Diagnose Pi Advisor when reviews or advisor_ask are missing, consultation fails, the Advisor is paused, settings are ineffective, backlog waits do not release, or corrective advice is not delivered safely.
3
+ description: Diagnose Pi Advisor when reviews or advisor_ask are missing, consultation fails, the Advisor is paused, settings are ineffective, backlog waits do not release, corrective advice is not delivered safely, or Reviews cost too much.
4
4
  license: MIT
5
5
  disable-model-invocation: true
6
6
  ---
@@ -13,10 +13,14 @@ Use this sequence for a live Advisor problem:
13
13
  2. If the state is disabled, check the selected session override, trusted-project settings, and global settings in that order. Advisor defaults to disabled. Use `/advisor on`, `/advisor off`, `/advisor inherit`, or `/advisor set <key> <JSON>` at the intended scope; use `/advisor prompt` for the native prompt editor. Confirm the selected branch after resume, fork, or tree navigation.
14
14
  3. If the state is paused, read the reported capability, Review, or Consultation error. Correct the setting or unavailable runtime capability, then explicitly re-enable or reload as the diagnostic instructs. A pause leaves the observed agent running.
15
15
  4. If `advisor_ask` is missing, confirm Advisor is enabled; disabled Advisors intentionally hide it. A paused Advisor keeps the tool visible for its diagnostic. Under `codemode.mode: "only"` it stays active but undeclared, reachable only through `codemode` scripts. Consultations wait for the active Review, return plain advice, and leave Review Backlog accounting unchanged. User cancellation does not pause Advisor; runtime failures do.
16
- 5. For missing reviews, inspect backlog and catch-up configuration. Backlog counts completed model responses with their tool batches, including work under review. `off` never waits; a positive threshold waits only while backlog is at least that threshold, and each wait is capped at 30 seconds. A Review has its own deadline, investigative-call limit, and `maxFindingsPerReview` limit of 1–32.
16
+ 5. For missing reviews, inspect backlog and catch-up configuration. Backlog counts completed model responses with their tool batches, including work under review. `off` never waits; a positive threshold waits only while backlog is at least that threshold, and each wait is capped at 30 seconds. A Review has its own deadline, investigative-call limit, and `maxFindingsPerReview` limit of 1–32. Check `reviewEvery`: under `"request"` a Review runs only when the request completes, and under `N` after every N turns and at completion; a turn with a failed tool call is reviewed at once. A Catch-up Wait waits only for a running Review, so turns waiting for their cadence never block the observed agent.
17
17
  6. If Context Management is loaded, verify that all three private tools—`context_notes`, `context_history`, and `context_rollover`—are explicitly granted. Any missing grant pauses Advisor before review. These tools operate on the Advisor Session's private Notes, History, and Checkpoints; they never grant access to the observed session. Confirm native settlement and checkpoint completion rather than treating an early response or `agent_end` report as completion.
18
18
  7. For tool failures, compare the effective `allowedTools` list with the loaded registry. Missing names are reported and ignored; Advisor does not autogrant tools. Grant `codemode` explicitly for scripts. An inactive advice tool after extension binding pauses the Advisor. A Tool Grant covers the tool's full native interface and is not a filesystem or operation sandbox.
19
19
  8. For stale, duplicated, or unsafe advice, check branch/session identity and configuration-generation changes. Advisor Sessions are separate persisted native sessions with fresh extension resources. Unsupported opaque inline/custom resources pause rather than being reused. Nits use non-interrupting context delivery and never start Corrective Turns. Running Concerns and Blockers use Pi's native steering boundary; Blockers after normal interactive completion use only tracked, budgeted Corrective Turns. Deliberately interrupted, aborted, uncertain, and headless-completed work is preserved without restart. Child correction must finish inside Minimal's owned operation.
20
- 9. On shutdown, allow only the bounded headless final drain. Verify that no hidden corrective turn starts after root completion and that native Advisor resources are aborted before disposal.
20
+ - If a finding never arrived, check `/advisor status` for findings awaiting re-validation: a Superseded Finding is withheld when the agent completed more turns before delivery, and the next Review reports it again only if it still applies. Findings are withheld at most once: if that Review is superseded too, its Concerns and Blockers are delivered and its Nits dropped. Status also counts Nits dropped over `maxNitsPerRequest`, findings dropped for citing no evidence or an unknown Tool-Call Reference (the Advisor Session's `advisor_report` result names them), Nits from a superseded re-validating Review, and Reviews ended after two invalid `advisor_report` calls; frequent invalid reports suggest an Advisor model that cannot follow the report schema.
21
+ - If findings are directives rather than defects, or lack evidence, check whether a replaced Advisor Prompt dropped the default's evidence and completed-work wording; `advisor_report` still requires evidence on every finding.
22
+ 9. If a finding seems to miss tool output or schemas, remember that Review Evidence carries tool-result text but not display-only `details`, and the Context Seed's Observed Setup lists observed tools by name and one-line summary only. Grant read tools or use `advisor_ask` when the Advisor must inspect more than the observed model received. If an early finding misses older history, check `seedBudgetTokens`: the Context Seed keeps the Observed Setup, the original request, the newest turn, and the newest turns that fit, lists the observed messages it keeps, and names the observed session file for the rest.
23
+ 10. If Advisor costs too much, compare the last Review's cost and the running total in `/advisor status` with the observed agent's cost. Each Review re-reads the whole Advisor Session: lower `maxSessionTokens` so Pi's native compaction keeps it smaller, or use a coarser `reviewEvery`, which delays findings. A compaction that fails or times out does not pause Advisor; the next Review instead starts a new Advisor Session from a Context Seed (`Current context seed.` in its prompt). Context Management's Rollover replaces this compaction where it is loaded.
24
+ 11. On shutdown, allow only the bounded headless final drain, which waits up to one `reviewTimeoutMs` Review deadline. Verify that no hidden corrective turn starts after root completion and that native Advisor resources are aborted before disposal.
21
25
 
22
26
  Finish when `/advisor status` shows the intended settings and the failing Review or Consultation now settles without error: a Consultation returns advice, or a Review produces an attributed finding or zero backlog. If a capability remains unsupported, report the exact diagnostic instead of bypassing native lifecycle or permission policy.
@@ -1,64 +1,43 @@
1
+ import {
2
+ completeSettingsCommandArguments,
3
+ parseSettingsCommand,
4
+ type SettingsCommand,
5
+ } from "@ian-pascoe/pi-utils/settings-command";
1
6
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
7
  import {
3
8
  advisorOptionKey,
4
9
  advisorOptionKeys,
5
10
  parseAdvisorOptions,
6
11
  type AdvisorOptions,
12
+ type AdvisorSettingScope,
7
13
  } from "./advisor-settings.js";
8
14
 
9
15
  /** Native command completion values replace the entire argument prefix. */
10
16
  export function completeAdvisorCommandArguments(prefix: string): AutocompleteItem[] {
11
- const candidates = ["on", "off", "status", "prompt", "inherit", "set"];
12
- const keyPrefix = /^((?:set|inherit)\s+)\S*$/.exec(prefix)?.[1];
13
- if (keyPrefix) candidates.push(...advisorOptionKeys.map((key) => `${keyPrefix}${key}`));
14
- const scopePrefix = /^(.*\s+)(--\S*)?$/s.exec(prefix)?.[1];
15
- if (scopePrefix) {
16
- try {
17
- const command = parseAdvisorCommand(scopePrefix);
18
- if ("scope" in command && command.scope === "session")
19
- candidates.push(`${scopePrefix}--global`, `${scopePrefix}--project`);
20
- } catch {
21
- // Incomplete commands and JSON values cannot accept a scope yet.
22
- }
23
- }
24
- return candidates
25
- .filter((value) => value.startsWith(prefix))
26
- .map((value) => ({ value, label: value }));
17
+ return completeSettingsCommandArguments(prefix, {
18
+ words: ["on", "off", "status", "prompt", "inherit", "set"],
19
+ optionKeys: advisorOptionKeys,
20
+ parse: parseAdvisorCommand,
21
+ });
27
22
  }
28
23
 
29
24
  const usage =
30
25
  "Usage: /advisor [on|off|status|prompt|inherit [key]|set <key> <JSON>] [--global|--project]; /advisor alone opens settings";
31
26
 
27
+ /** One parsed `/advisor` command. */
28
+ export type AdvisorCommand = SettingsCommand<
29
+ AdvisorOptions,
30
+ keyof AdvisorOptions,
31
+ { action: "prompt"; scope: AdvisorSettingScope }
32
+ >;
33
+
32
34
  /** Parse one configuration change; validated patches cannot invent option keys. */
33
- export function parseAdvisorCommand(input: string) {
34
- const flag = /\s+--(global|project)$/.exec(input.trim());
35
- const scope =
36
- flag?.[1] === "global"
37
- ? ("global" as const)
38
- : flag?.[1] === "project"
39
- ? ("project" as const)
40
- : ("session" as const);
41
- const text = flag ? input.trim().slice(0, flag.index) : input.trim();
42
- if (text === "" || text === "status") {
43
- if (flag) throw new Error(usage);
44
- return text === "" ? { action: "menu" as const } : { action: "status" as const };
45
- }
46
- if (text === "prompt") return { action: "prompt" as const, scope };
47
- if (text === "on" || text === "off") {
48
- return {
49
- action: "set" as const,
50
- scope,
51
- key: "enabled" as const,
52
- patch: { enabled: text === "on" },
53
- };
54
- }
55
- const inherit = /^inherit(?:\s+(\S+))?$/.exec(text);
56
- if (inherit) {
57
- return { action: "inherit" as const, scope, key: advisorOptionKey(inherit[1] ?? "enabled") };
58
- }
59
- const set = /^set\s+(\S+)\s+([\s\S]+)$/.exec(text);
60
- if (!set?.[1] || !set[2]) throw new Error(usage);
61
- const key = advisorOptionKey(set[1]);
62
- const patch: AdvisorOptions = parseAdvisorOptions({ [key]: JSON.parse(set[2]) }, scope);
63
- return { action: "set" as const, scope, key, patch };
35
+ export function parseAdvisorCommand(input: string): AdvisorCommand {
36
+ return parseSettingsCommand(input, {
37
+ usage,
38
+ optionKey: advisorOptionKey,
39
+ parseOptions: parseAdvisorOptions,
40
+ toggle: (enabled) => ({ key: "enabled", patch: { enabled } }),
41
+ parseExtra: (text, scope) => (text === "prompt" ? { action: "prompt", scope } : undefined),
42
+ });
64
43
  }
@@ -8,16 +8,82 @@ export const advisorSeveritySchema = Type.Union([
8
8
  ]);
9
9
  export type AdvisorSeverity = Static<typeof advisorSeveritySchema>;
10
10
 
11
- /** One reported finding; also the journaled details of an Intervention message. */
12
- export const advisorFindingSchema = Type.Object(
11
+ /** What a finding cites from the Review Evidence it was judged against. */
12
+ export const advisorEvidenceSchema = Type.Object(
13
13
  {
14
- severity: advisorSeveritySchema,
15
- message: Type.String({ minLength: 1, maxLength: 4000 }),
14
+ refs: Type.Optional(
15
+ Type.Array(Type.String({ minLength: 1, maxLength: 64 }), {
16
+ maxItems: 8,
17
+ description:
18
+ "Tool-Call References (the `ref` of a tool call or its result in the supplied evidence) that show the defect",
19
+ }),
20
+ ),
21
+ quote: Type.Optional(
22
+ Type.String({
23
+ minLength: 1,
24
+ maxLength: 1000,
25
+ description: "A short verbatim quote from the supplied evidence that shows the defect",
26
+ }),
27
+ ),
16
28
  },
17
29
  { additionalProperties: false },
18
30
  );
31
+ export type AdvisorEvidence = Static<typeof advisorEvidenceSchema>;
32
+
33
+ const findingFields = {
34
+ severity: advisorSeveritySchema,
35
+ message: Type.String({ minLength: 1, maxLength: 4000 }),
36
+ };
37
+
38
+ /**
39
+ * One finding; also the journaled details of an Intervention message. `evidence` is absent from
40
+ * Interventions recorded before findings cited evidence, and from legacy single-finding reports.
41
+ */
42
+ export const advisorFindingSchema = Type.Object(
43
+ { ...findingFields, evidence: Type.Optional(advisorEvidenceSchema) },
44
+ { additionalProperties: false },
45
+ );
19
46
  export type AdvisorFinding = Static<typeof advisorFindingSchema>;
20
47
 
48
+ const knownCost = Type.Union([Type.Number(), Type.Null()]);
49
+ /**
50
+ * Native usage cost of Reviews since the Advisor was loaded, across Advisor Session rebuilds:
51
+ * the number of Reviews, the last one's cost, and their total. A cost is null when unknown;
52
+ * the total stays null once any Review's cost was unknown.
53
+ */
54
+ export const advisorReviewCostSchema = Type.Object({
55
+ reviews: Type.Number(),
56
+ last: knownCost,
57
+ total: knownCost,
58
+ });
59
+ export type AdvisorReviewCost = Static<typeof advisorReviewCostSchema>;
60
+
61
+ /** A finding as `advisor_report` accepts it: every finding cites its evidence. */
62
+ export const advisorReportFindingSchema = Type.Object(
63
+ {
64
+ ...findingFields,
65
+ evidence: Type.Object(advisorEvidenceSchema.properties, {
66
+ additionalProperties: false,
67
+ description:
68
+ "Evidence for a concrete defect in completed work: at least one Tool-Call Reference or a verbatim quote",
69
+ }),
70
+ },
71
+ { additionalProperties: false },
72
+ );
73
+
74
+ /**
75
+ * Findings an Advisor dropped since it started, by reason: Nits over `maxNitsPerRequest`,
76
+ * findings citing no or unknown evidence, Nits of a re-validating Review that was superseded
77
+ * again, and Reviews ended without findings by invalid reports.
78
+ */
79
+ export const advisorDroppedFindingsSchema = Type.Object({
80
+ overNitCap: Type.Number(),
81
+ unsupported: Type.Number(),
82
+ superseded: Type.Number(),
83
+ invalidReviews: Type.Number(),
84
+ });
85
+ export type AdvisorDroppedFindings = Static<typeof advisorDroppedFindingsSchema>;
86
+
21
87
  /** Observer lifecycle state reported by its `status`. */
22
88
  export const advisorObserverStateSchema = Type.Union([
23
89
  Type.Literal("disabled"),
@@ -0,0 +1,157 @@
1
+ import type { Context, Tool } from "@earendil-works/pi-ai";
2
+ import {
3
+ combineEvidence,
4
+ evidenceItemsCost,
5
+ fitEvidence,
6
+ projectEvidenceItem,
7
+ shortenEvidence,
8
+ type Evidence,
9
+ type EvidenceItem,
10
+ } from "@ian-pascoe/pi-utils/evidence";
11
+
12
+ export {
13
+ evidenceRefs,
14
+ evidenceTokens,
15
+ messageOrigins,
16
+ projectEvidence,
17
+ toolCallRef,
18
+ type Evidence,
19
+ type EvidenceBlock,
20
+ type EvidenceMessage,
21
+ } from "@ian-pascoe/pi-utils/evidence";
22
+
23
+ /** Marks text shortened to fit the Context Seed budget. */
24
+ const omissionMarker = (omitted: number) =>
25
+ `[… ${omitted} characters omitted from the Context Seed]`;
26
+
27
+ /** Observed Setup: the observed system prompt and tool summaries that open a Context Seed. */
28
+ export interface ObservedSetup {
29
+ systemPrompt: string | undefined;
30
+ tools: { name: string; summary: string }[];
31
+ }
32
+
33
+ const summaryLimit = 160;
34
+
35
+ /** First sentence of a tool description's first line, bounded for the Observed Setup. */
36
+ function toolSummary(description: string): string {
37
+ const line = description.trim().split("\n", 1)[0]?.trim() ?? "";
38
+ const sentence = /^(.+?[.!?])(?:\s|$)/.exec(line)?.[1] ?? line;
39
+ return sentence.length > summaryLimit
40
+ ? `${sentence.slice(0, summaryLimit - 1).trimEnd()}…`
41
+ : sentence;
42
+ }
43
+
44
+ export function projectObservedSetup(
45
+ context: Pick<Context, "systemPrompt" | "tools">,
46
+ ): ObservedSetup {
47
+ return {
48
+ systemPrompt: context.systemPrompt,
49
+ tools: (context.tools ?? []).map((tool: Tool) => ({
50
+ name: tool.name,
51
+ summary: toolSummary(tool.description),
52
+ })),
53
+ };
54
+ }
55
+
56
+ /** A Context Seed: the Observed Setup and the observed messages that fit its token budget. */
57
+ export interface ContextSeed extends Evidence {
58
+ observedSetup: ObservedSetup;
59
+ /** Zero-based positions of the kept observed messages, ascending. */
60
+ kept: number[];
61
+ /** Long texts shortened, with a marker, so the newest messages fit. */
62
+ shortened: number;
63
+ }
64
+
65
+ /**
66
+ * Fit a Context Seed to a token budget, measured as Pi's chars/4 estimate of the seed JSON with
67
+ * Pi's per-image estimate. Always kept: the Observed Setup; the original request (the first
68
+ * user request, or after compaction the summary, which carries the earlier goal, and the first
69
+ * request after it); and the newest turn with the request that prompted it, shortened if they
70
+ * alone exceed the budget. The rest takes the newest turns that fit, shortening only the oldest
71
+ * one included. A turn (an assistant message with its tool results) is never split.
72
+ */
73
+ export function selectContextSeed(
74
+ context: Pick<Context, "systemPrompt" | "tools" | "messages">,
75
+ options: { budgetTokens: number; origins?: readonly string[] },
76
+ ): ContextSeed {
77
+ const { messages } = context;
78
+ const observedSetup = projectObservedSetup(context);
79
+ const projected = messages.map((message): EvidenceItem[] => {
80
+ const item = projectEvidenceItem(message);
81
+ return item ? [item] : [];
82
+ });
83
+ // Fall back to the converted role only when no converted user message has a known origin.
84
+ const origins = options.origins?.some(
85
+ (origin, index) => messages[index]?.role === "user" && origin !== "unknown",
86
+ )
87
+ ? options.origins
88
+ : undefined;
89
+ const isRequest = (index: number) =>
90
+ origins ? origins[index] === "user" : messages[index]?.role === "user";
91
+ // Units: a message, with any tool results that follow it.
92
+ const starts = messages.flatMap((message, index) =>
93
+ index === 0 || message.role !== "toolResult" ? [index] : [],
94
+ );
95
+ const units = starts.map((start, index) => ({
96
+ start,
97
+ end: starts[index + 1] ?? messages.length,
98
+ }));
99
+ const unitAt = (position: number) => units.findLastIndex((unit) => unit.start <= position);
100
+ const content = (unit: number) => {
101
+ const { start, end } = units[unit] ?? { start: 0, end: 0 };
102
+ return projected.slice(start, end).flat();
103
+ };
104
+ const summary = options.origins?.indexOf("compactionSummary") ?? -1;
105
+ const request = messages.findIndex((_message, index) => index > summary && isRequest(index));
106
+ const newest = units.length - 1;
107
+ const prompt = messages.findLastIndex(
108
+ (_message, index) => index <= (units[newest]?.start ?? -1) && isRequest(index),
109
+ );
110
+ const anchors = new Set([summary, request].filter((index) => index >= 0).map(unitAt));
111
+ const recent = new Set([prompt, messages.length - 1].filter((index) => index >= 0).map(unitAt));
112
+ for (const unit of anchors) recent.delete(unit);
113
+
114
+ const chosen = new Map<number, EvidenceItem[]>();
115
+ let remaining =
116
+ options.budgetTokens - Math.ceil(JSON.stringify({ observedSetup, messages: [] }).length / 4);
117
+ const anchorItems = [...anchors].flatMap(content);
118
+ const recentItems = [...recent].flatMap(content);
119
+ // Shorten the newest turn first, then the original request too, so both always appear.
120
+ const required =
121
+ fitEvidence(recentItems, remaining - evidenceItemsCost(anchorItems), omissionMarker)?.concat(
122
+ anchorItems,
123
+ ) ??
124
+ fitEvidence([...recentItems, ...anchorItems], remaining, omissionMarker) ??
125
+ shortenEvidence([...recentItems, ...anchorItems], 0, omissionMarker);
126
+ remaining -= evidenceItemsCost(required);
127
+ const shortenedRequired = new Map(
128
+ [...recentItems, ...anchorItems].map((item, index) => [item, required[index]] as const),
129
+ );
130
+ for (const unit of [...anchors, ...recent])
131
+ chosen.set(
132
+ unit,
133
+ content(unit).map((item) => shortenedRequired.get(item) ?? item),
134
+ );
135
+ for (let unit = newest; unit >= 0; unit--) {
136
+ if (chosen.has(unit)) continue;
137
+ const items = fitEvidence(content(unit), remaining, omissionMarker);
138
+ if (!items) break;
139
+ chosen.set(unit, items);
140
+ remaining -= evidenceItemsCost(items);
141
+ if (items.some((item, index) => item !== content(unit)[index])) break;
142
+ }
143
+
144
+ const ordered = [...chosen.keys()].toSorted((left, right) => left - right);
145
+ const kept = ordered.flatMap((unit) => {
146
+ const { start, end } = units[unit] ?? { start: 0, end: 0 };
147
+ return Array.from({ length: end - start }, (_value, offset) => start + offset);
148
+ });
149
+ const items = ordered.flatMap((unit) => chosen.get(unit) ?? []);
150
+ const original = ordered.flatMap(content);
151
+ return {
152
+ observedSetup,
153
+ ...combineEvidence(items),
154
+ kept,
155
+ shortened: items.filter((item, index) => item.message !== original[index]?.message).length,
156
+ };
157
+ }