pi-advisor-flow 0.2.8 → 0.2.9
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 +12 -0
- package/README.md +43 -170
- package/extensions/index.ts +4 -2
- package/package.json +1 -1
- package/src/commands.ts +47 -11
- package/src/config.ts +17 -9
- package/src/conversation.ts +25 -12
- package/src/git.ts +5 -1
- package/src/outcomes.ts +93 -23
- package/src/tools.ts +37 -13
- package/src/ui.ts +49 -29
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 0.2.9
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Preserved custom numeric values when stepping `/advisor-settings` controls instead of resetting off-preset budgets and disclosure caps.
|
|
12
|
+
- Kept recorded session blocks enforced after `/advisor-off`, restored configuration after failed activation, and charged Advisor-call budgets only when consultation execution begins.
|
|
13
|
+
- Kept conversation and repository disclosure within their configured caps and explicitly identified disabled repository context as withheld.
|
|
14
|
+
- Made concurrent outcome records append safely with one exclusively initialized digest salt, and stopped superseded manual consultations from entering session summaries.
|
|
15
|
+
- Preserved and ignored forward-compatible `advisor.json` fields with a warning, and made malformed configuration errors actionable in every Advisor command.
|
|
16
|
+
- Isolated call budgets, repeated-action counters, and safety blocks between concurrent same-process sessions.
|
|
17
|
+
- Kept the repository-context withheld warning visible when its disclosure budget is zero.
|
|
18
|
+
|
|
7
19
|
## 0.2.8
|
|
8
20
|
|
|
9
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -8,17 +8,22 @@ A configurable second-opinion workflow for <a href="https://github.com/earendil-
|
|
|
8
8
|
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
`pi-advisor-flow` keeps one model focused on execution and makes a second, smarter model available for consequential decisions, stalled work, and final reviews. The Executor still owns the work. The Advisor challenges assumptions, exposes risks, and suggests verification steps without taking over or running tools.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The idea is simple: keep implementation on a fast model and borrow frontier reasoning only when decisions matter. [Read why this workflow is useful](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## Features
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
- **On-demand second opinions** through the `ask_advisor` tool or `/advisor-manual`.
|
|
18
|
+
- **Configurable review gates** before plans, after repeated failures, and before declaring completion.
|
|
19
|
+
- **Automatic loop detection** for repeated tool calls, with explicit proceed, revise, or blocked decisions.
|
|
20
|
+
- **Separate model and reasoning controls** for the Executor and Advisor.
|
|
21
|
+
- **Privacy controls** for conversation history, repository context, tool results, secret redaction, and outcome logging.
|
|
22
|
+
- **Optional persistent activation, Simple mode, session summaries, and Herdr integration.**
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
## Install
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
Current release: **0.2.9**. Requires Pi 0.80.7 or later. The extension installs no dependencies of its own; Pi supplies its runtime modules.
|
|
22
27
|
|
|
23
28
|
```bash
|
|
24
29
|
# npm
|
|
@@ -27,197 +32,65 @@ pi install npm:pi-advisor-flow
|
|
|
27
32
|
# GitHub
|
|
28
33
|
pi install git:github.com/philipbrembeck/pi-advisor.git
|
|
29
34
|
|
|
30
|
-
# local checkout
|
|
35
|
+
# local checkout
|
|
31
36
|
pi install /path/to/pi-advisor
|
|
32
37
|
```
|
|
33
38
|
|
|
34
|
-
Restart or reload Pi after installation
|
|
39
|
+
Restart or reload Pi after installation.
|
|
35
40
|
|
|
36
|
-
##
|
|
41
|
+
## Quick start
|
|
37
42
|
|
|
38
43
|
1. Run `/advisor` to enable the flow and register `ask_advisor`.
|
|
39
44
|
2. Run `/advisor-models` to choose the Executor and Advisor models.
|
|
40
|
-
3. Run `/advisor-settings` to
|
|
41
|
-
|
|
42
|
-
Enable with models in one command when preferred:
|
|
43
|
-
|
|
44
|
-
```text
|
|
45
|
-
/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
`/advisor contextMaxChars=30000` sets the reconstructed-context limit for the current session. Use `0` for no history. The `ALL` option in settings represents the complete current branch and is still subject to the Advisor model's context limit.
|
|
49
|
-
|
|
50
|
-
## Commands
|
|
51
|
-
|
|
52
|
-
| Command | Purpose |
|
|
53
|
-
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
-
| `/advisor` | Enable the flow, select the configured Executor, and register `ask_advisor`. Accepts `executor=`, `advisor=`, and `contextMaxChars=` overrides. |
|
|
55
|
-
| `/advisor-manual [focus]` | Start a parallel Advisor consultation without interrupting the current Executor turn. |
|
|
56
|
-
| `/advisor-models` | Choose Executor and Advisor models plus their reasoning effort. |
|
|
57
|
-
| `/advisor-settings` | Open all Advisor settings in one keyboard-navigable screen. |
|
|
58
|
-
| `/advisor-off` | Disable the flow and remove `ask_advisor` from the active session. |
|
|
59
|
-
|
|
60
|
-
### `ask_advisor`
|
|
61
|
-
|
|
62
|
-
The Executor calls `ask_advisor({})` for a general review of the current task and reconstructed conversation. It can pass a `question` for a targeted review, or a concise `draft` for plan and completion reviews. A draft should name proposed work, validation, and remaining risks; it is an unverified claim, not evidence.
|
|
63
|
-
|
|
64
|
-
Successful calls return an opaque `adviceId`. When global outcome logging is enabled, the Executor may voluntarily call `record_advisor_outcome` once with that ID, an adoption value, and a final validation status.
|
|
65
|
-
|
|
66
|
-
Use the Advisor after the Executor has investigated and formed a candidate direction. It is intended to challenge assumptions, expose risks, and confirm the next verification step—not to replace the Executor's work.
|
|
67
|
-
|
|
68
|
-
Normal consultations preserve the provider's final Markdown and never block execution. When the Advisor has no material concern or recommendation, it may begin with the exact first line `Verdict: sound`; Pi renders that response with the static `◆ ADVISOR · SOUND` header, for both `ask_advisor` results and `/advisor-manual`.
|
|
45
|
+
3. Run `/advisor-settings` to configure review gates, context, privacy, and limits.
|
|
69
46
|
|
|
70
|
-
|
|
47
|
+
Unknown fields in `advisor.json` are preserved for forward compatibility and reported as non-blocking warnings. Invalid recognized values remain errors, and Advisor commands show the configuration problem without crashing their handlers.
|
|
71
48
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Unlike ordinary consultations, a loop-gate reply must start with exactly one decision header:
|
|
49
|
+
You can also enable the flow and select both models at once:
|
|
75
50
|
|
|
76
51
|
```text
|
|
77
|
-
|
|
78
|
-
Decision: revise
|
|
79
|
-
Decision: blocked
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
| Decision | Effect |
|
|
83
|
-
| --------- | --------------------------------------------------- |
|
|
84
|
-
| `proceed` | Reset the repeat counter and allow the tool action. |
|
|
85
|
-
| `revise` | Block the repeated tool action. |
|
|
86
|
-
| `blocked` | Apply the configured gate-failure policy. |
|
|
87
|
-
|
|
88
|
-
Malformed, missing, duplicate, or contradictory decisions are gate failures. The same policy also applies when the Advisor is unavailable or the shared call budget is exhausted.
|
|
89
|
-
|
|
90
|
-
| Failure mode | Effect |
|
|
91
|
-
| ------------------------- | ----------------------------------- |
|
|
92
|
-
| `block-session` (default) | Block the session. |
|
|
93
|
-
| `block-tool` | Block only the current tool action. |
|
|
94
|
-
| `warn-and-continue` | Show a warning and continue. |
|
|
95
|
-
|
|
96
|
-
| Condition | `block-session` | `block-tool` | `warn-and-continue` |
|
|
97
|
-
| -------------------------------------------------------- | --------------- | ----------------- | ------------------- |
|
|
98
|
-
| Advisor unavailable or timed out | Block session | Block tool action | Warn and continue |
|
|
99
|
-
| Missing, malformed, duplicate, or contradictory decision | Block session | Block tool action | Warn and continue |
|
|
100
|
-
| Shared budget exhausted | Block session | Block tool action | Warn and continue |
|
|
101
|
-
| `Decision: blocked` | Block session | Block tool action | Warn and continue |
|
|
102
|
-
|
|
103
|
-
`advisorBlockOnBlocked` controls whether a session block immediately aborts the active run. It never turns a session block into a tool-only block.
|
|
104
|
-
|
|
105
|
-
## Settings and configuration
|
|
106
|
-
|
|
107
|
-
`/advisor-models` and `/advisor-settings` save to global `advisor.json` in the Pi agent directory. Repository-controlled project `advisor.json` files are not applied; models, prompts, gates, budgets, disclosure, redaction, integrations, and consent remain under the user's global configuration.
|
|
108
|
-
|
|
109
|
-
All fields are optional. This example shows the available settings and their normal defaults:
|
|
110
|
-
|
|
111
|
-
```json
|
|
112
|
-
{
|
|
113
|
-
"executor": "openai/gpt-5.6-luna",
|
|
114
|
-
"advisor": "anthropic/claude-fable-5",
|
|
115
|
-
"executorEffort": "medium",
|
|
116
|
-
"advisorEffort": "xhigh",
|
|
117
|
-
"contextMaxChars": 25000,
|
|
118
|
-
|
|
119
|
-
"advisorPlanGate": true,
|
|
120
|
-
"advisorFailureGate": true,
|
|
121
|
-
"advisorCompletionGate": true,
|
|
122
|
-
"advisorCustomInvocation": "before changing a production deployment",
|
|
123
|
-
"advisorCollapseResponses": false,
|
|
124
|
-
|
|
125
|
-
"advisorAutoLoopGate": true,
|
|
126
|
-
"advisorLoopThreshold": 3,
|
|
127
|
-
"advisorMaxCallsPerSession": 5,
|
|
128
|
-
"advisorBlockOnBlocked": true,
|
|
129
|
-
"gateFailureMode": "block-session",
|
|
130
|
-
|
|
131
|
-
"advisorSessionSummary": false,
|
|
132
|
-
"advisorGitContext": "summary",
|
|
133
|
-
"advisorGitContextMaxChars": 20000,
|
|
134
|
-
"simpleMode": false,
|
|
135
|
-
"alwaysOn": false,
|
|
136
|
-
"advisorHerdrIntegration": true,
|
|
137
|
-
"advisorToolResultMaxLines": 2000,
|
|
138
|
-
"advisorToolResultMaxBytes": 51200,
|
|
139
|
-
|
|
140
|
-
"advisorRedactSecrets": false,
|
|
141
|
-
"advisorUntrackedContent": false,
|
|
142
|
-
"advisorOutcomeLogging": false,
|
|
143
|
-
"advisorToolPolicies": {
|
|
144
|
-
"bash": "summary",
|
|
145
|
-
"deploy": "exclude"
|
|
146
|
-
}
|
|
147
|
-
}
|
|
52
|
+
/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol
|
|
148
53
|
```
|
|
149
54
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
- `simpleMode` defaults to `false`. When enabled, `ask_advisor` and `/advisor-manual` remain available for voluntary second opinions, while plan/failure/completion rules, loop gates, blocking, call budgets, and session summaries are disabled. Context limits, result caps, redaction, and tool disclosure policies still apply.
|
|
153
|
-
- `alwaysOn` defaults to `false`. When enabled, Pi restores the configured Executor and activates `ask_advisor` for new, resumed, forked, and reloaded sessions. While the Advisor flow is active, an explicit `/model` selection becomes the persisted Executor for the next activation; a model restored with a session does not change the saved Executor. `/advisor-off` turns `alwaysOn` off so the flow stays disabled in later sessions.
|
|
154
|
-
- In Simple mode, settings keeps the Context window/history slider alongside Simple mode and Always on; advanced values remain saved and take effect when Simple mode is disabled.
|
|
155
|
-
|
|
156
|
-
### Repository context
|
|
157
|
-
|
|
158
|
-
- `advisorGitContext` defaults to `summary`. It controls how much of the working tree reaches the Advisor:
|
|
159
|
-
- `off` sends no repository information.
|
|
160
|
-
- `summary` sends changed file names, change status, and line counts. It never sends file contents.
|
|
161
|
-
- `full` additionally sends the patch.
|
|
162
|
-
- Changes are measured against the last commit and cover staged and unstaged work. Untracked files are always listed by name only; their contents are never sent by `gitContext: full`.
|
|
163
|
-
- `advisorUntrackedContent` defaults to false. When enabled, `includeUntracked` can attach only exact named, repository-relative, untracked regular files. Files are redacted and capped before egress; sibling files remain withheld.
|
|
164
|
-
- `advisorGitContextMaxChars` defaults to `20000`. Repository context may claim its own cap or half of `contextMaxChars`, whichever is smaller, so it cannot crowd out the conversation.
|
|
165
|
-
- The Executor may pass `gitContext` to `ask_advisor` as `none`, `summary`, or `full`. `advisorGitContext` is the ceiling: a larger request is narrowed to the configured level and the Advisor is told that a fuller view was withheld, so it does not claim verification it could not perform.
|
|
166
|
-
- `summary` deliberately excludes diff hunk headers. Git derives those from surrounding file content, so a hunk header can reproduce a line the change never touched, including a credential.
|
|
167
|
-
- Redaction runs before the region is capped, and repository content is labelled as untrusted data in the request. Paths and patch text are escaped so a crafted path cannot close the region early and have the remainder read as instructions.
|
|
168
|
-
- File names themselves can be sensitive. `summary` withholds file contents, not file names; use `off` when names must not leave the machine.
|
|
169
|
-
- Collection shares a single overall time budget across its git commands and degrades to a stated failure rather than implying a clean tree.
|
|
170
|
-
|
|
171
|
-
### Project preferences and outcomes
|
|
55
|
+
## How it works
|
|
172
56
|
|
|
173
|
-
|
|
57
|
+
1. The Executor investigates the task and forms its own candidate direction.
|
|
58
|
+
2. For a consequential decision, stalled attempt, or final review, it calls `ask_advisor` with the reconstructed conversation and allowed repository context.
|
|
59
|
+
3. The Advisor returns a concise review. It may challenge assumptions, identify risks, or recommend the next verification step.
|
|
60
|
+
4. The Executor decides what to adopt, performs the work, and validates the result.
|
|
174
61
|
|
|
175
|
-
|
|
62
|
+
A normal consultation never blocks execution. The optional automatic loop gate is different: it evaluates repeated tool calls and applies the configured failure policy when the Advisor says to revise, reports a block, is unavailable, or returns an invalid decision.
|
|
176
63
|
|
|
177
|
-
|
|
64
|
+
Successful calls return an opaque `adviceId`. If global outcome logging is enabled, the Executor can call `record_advisor_outcome` once to record whether the advice was adopted and whether final validation passed.
|
|
178
65
|
|
|
179
|
-
|
|
180
|
-
- Set `contextMaxChars` to `0` to omit reconstructed history. `9007199254740991` is the persisted value for `ALL`.
|
|
181
|
-
- Tool results default to Pi's `2000` lines and `50 KiB` limits. Oversized results preserve their beginning and end with an omission marker.
|
|
182
|
-
- `advisorLoopThreshold` is an integer of at least `2`; its default is `3`.
|
|
183
|
-
- Omit `advisorMaxCallsPerSession` for an unlimited shared budget. Otherwise it must be a non-negative safe integer.
|
|
184
|
-
|
|
185
|
-
### Privacy controls
|
|
186
|
-
|
|
187
|
-
Advisor context can contain user messages, tool calls, and tool results. Configure disclosure deliberately:
|
|
188
|
-
|
|
189
|
-
- `advisorRedactSecrets` defaults to `false`. When enabled, pi-advisor locally redacts common credential patterns before including context in an Advisor request.
|
|
190
|
-
- `advisorToolPolicies` matches an **exact tool name**. Each tool may use `full`, `summary`, or `exclude`.
|
|
191
|
-
- `full` includes the call arguments and capped result output.
|
|
192
|
-
- `summary` omits call arguments and result output but includes result status and size metadata.
|
|
193
|
-
- `exclude` omits both call details and output.
|
|
194
|
-
- Tools not listed in `advisorToolPolicies`, including custom and newly added tools, use `full` for backward compatibility.
|
|
195
|
-
|
|
196
|
-
Redaction and output limits reduce accidental disclosure; they are not a data-classification system and cannot guarantee every secret is found. Use tool policies for content that must not be sent to the Advisor.
|
|
66
|
+
## Commands
|
|
197
67
|
|
|
198
|
-
|
|
68
|
+
| Command | Purpose |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `/advisor` | Enable the flow and optionally override the Executor, Advisor, or context limit. |
|
|
71
|
+
| `/advisor-manual [focus]` | Start a parallel consultation without interrupting the current Executor turn. |
|
|
72
|
+
| `/advisor-models` | Choose both models and their reasoning effort. |
|
|
73
|
+
| `/advisor-settings` | Configure behavior, context, gates, privacy, and output limits. |
|
|
74
|
+
| `/advisor-off` | Disable the flow and turn off persistent activation. |
|
|
199
75
|
|
|
200
|
-
The
|
|
76
|
+
The Executor calls `ask_advisor({})` for a general review. It can pass a targeted `question` or a concise `draft` describing proposed work, validation, and remaining risks. Draft claims give the Advisor review context; they are not verification evidence.
|
|
201
77
|
|
|
202
|
-
|
|
78
|
+
## What gets sent to the Advisor
|
|
203
79
|
|
|
204
|
-
|
|
80
|
+
Advisor context can include user messages, tool calls, tool results, and repository information. Secret redaction is off by default, and tools without an explicit disclosure policy default to full context. Review the privacy settings before using the extension with sensitive work.
|
|
205
81
|
|
|
206
|
-
|
|
82
|
+
Repository context is configurable from no access through changed-file summaries to a capped patch. When context is disabled or its budget is zero, the Advisor is told it was withheld rather than shown an apparently clean tree. Untracked file contents require a separate explicit opt-in.
|
|
207
83
|
|
|
208
|
-
|
|
209
|
-
git clone git@github.com:philipbrembeck/pi-advisor.git
|
|
210
|
-
cd pi-advisor
|
|
211
|
-
bun install
|
|
84
|
+
## Documentation
|
|
212
85
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
86
|
+
- [Configuration and automatic loop gates](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/configuration.md)
|
|
87
|
+
- [Privacy and data handling](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/privacy.md)
|
|
88
|
+
- [Development](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/development.md)
|
|
89
|
+
- [Documentation index](https://github.com/philipbrembeck/pi-advisor/blob/main/docs/README.md)
|
|
217
90
|
|
|
218
91
|
## Links
|
|
219
92
|
|
|
220
|
-
- [MIT
|
|
93
|
+
- [MIT License](LICENSE)
|
|
221
94
|
- [Changelog](CHANGELOG.md)
|
|
222
95
|
- [npm package](https://www.npmjs.com/package/pi-advisor-flow)
|
|
223
96
|
- [Why use an Advisor flow?](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need)
|
package/extensions/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerCommands } from "../src/commands.js";
|
|
3
3
|
import { setHerdrBlockedEmitter } from "../src/herdr.js";
|
|
4
|
+
import { AdvisorSessionState } from "../src/session-state.js";
|
|
4
5
|
import {
|
|
5
6
|
consultAdvisor as consultAdvisorImplementation,
|
|
6
7
|
parseAutomaticDecision as parseAutomaticDecisionImplementation,
|
|
@@ -29,9 +30,10 @@ export const runAdvisorGate = (
|
|
|
29
30
|
) => runAdvisorGateImplementation(...args);
|
|
30
31
|
|
|
31
32
|
export default function (pi: ExtensionAPI) {
|
|
33
|
+
const sessionState = new AdvisorSessionState();
|
|
32
34
|
setHerdrBlockedEmitter((active, label) =>
|
|
33
35
|
pi.events.emit("herdr:blocked", { active, label })
|
|
34
36
|
);
|
|
35
|
-
registerAdvisorTool(pi);
|
|
36
|
-
registerCommands(pi);
|
|
37
|
+
registerAdvisorTool(pi, sessionState);
|
|
38
|
+
registerCommands(pi, { sessionState });
|
|
37
39
|
}
|
package/package.json
CHANGED
package/src/commands.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
advisorMaxCallsPerSessionRef,
|
|
10
10
|
advisorRef,
|
|
11
11
|
alwaysOnRef,
|
|
12
|
+
contextMaxCharsRef,
|
|
12
13
|
executorEffortRef,
|
|
13
14
|
executorRef,
|
|
14
15
|
getAdvisorSettings,
|
|
@@ -47,10 +48,11 @@ import {
|
|
|
47
48
|
splitRef,
|
|
48
49
|
} from "./config.js";
|
|
49
50
|
import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "./herdr.js";
|
|
51
|
+
import type { AdvisorSessionState } from "./session-state.js";
|
|
50
52
|
import {
|
|
51
53
|
adviceForDisplay,
|
|
52
|
-
advisorSessionState,
|
|
53
54
|
consultAdvisor,
|
|
55
|
+
advisorSessionState as defaultAdvisorSessionState,
|
|
54
56
|
hasSoundVerdict,
|
|
55
57
|
renderAdvisorCallBox,
|
|
56
58
|
renderAdvisorResponseHeader,
|
|
@@ -138,8 +140,13 @@ const findConfiguredModel = (ctx: ExtensionContext, ref: string) => {
|
|
|
138
140
|
|
|
139
141
|
export const registerCommands = (
|
|
140
142
|
pi: ExtensionAPI,
|
|
141
|
-
dependencies: {
|
|
143
|
+
dependencies: {
|
|
144
|
+
consult?: ManualConsult;
|
|
145
|
+
sessionState?: AdvisorSessionState;
|
|
146
|
+
} = {}
|
|
142
147
|
) => {
|
|
148
|
+
const advisorSessionState =
|
|
149
|
+
dependencies.sessionState ?? defaultAdvisorSessionState;
|
|
143
150
|
const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
|
|
144
151
|
const requestAdvisor =
|
|
145
152
|
dependencies.consult ??
|
|
@@ -155,15 +162,15 @@ export const registerCommands = (
|
|
|
155
162
|
herdrAdvisorActivity.start();
|
|
156
163
|
return requestAdvisor(ctx, question, controller.signal)
|
|
157
164
|
.then(({ markdown }) => {
|
|
165
|
+
if (controller.signal.aborted) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
158
168
|
advisorSessionState.recordInvocation({
|
|
159
169
|
executionEffect: "continued",
|
|
160
170
|
kind: "markdown",
|
|
161
171
|
model: advisorRef,
|
|
162
172
|
trigger: "manual",
|
|
163
173
|
});
|
|
164
|
-
if (controller.signal.aborted) {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
174
|
pi.sendMessage(
|
|
168
175
|
{
|
|
169
176
|
content: `Manual Advisor consultation${question ? ` (${question})` : ""}:\n\n${markdown}`,
|
|
@@ -232,19 +239,48 @@ export const registerCommands = (
|
|
|
232
239
|
return {};
|
|
233
240
|
};
|
|
234
241
|
|
|
242
|
+
const loadCommandConfig = (ctx: ExtensionContext) => {
|
|
243
|
+
try {
|
|
244
|
+
loadConfig(ctx);
|
|
245
|
+
return true;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
248
|
+
notify(
|
|
249
|
+
ctx,
|
|
250
|
+
`Advisor command could not load configuration: ${message} Fix advisor.json and retry.`,
|
|
251
|
+
"error"
|
|
252
|
+
);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
235
257
|
const activateAdvisor = async (
|
|
236
258
|
args: string,
|
|
237
259
|
ctx: ExtensionContext,
|
|
238
260
|
announce = true
|
|
239
261
|
) => {
|
|
240
|
-
|
|
262
|
+
if (!loadCommandConfig(ctx)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const previous = {
|
|
266
|
+
advisor: advisorRef,
|
|
267
|
+
contextMaxChars: contextMaxCharsRef,
|
|
268
|
+
executor: executorRef,
|
|
269
|
+
};
|
|
270
|
+
const restoreRefs = () => {
|
|
271
|
+
setAdvisorRef(previous.advisor);
|
|
272
|
+
setContextMaxCharsRef(previous.contextMaxChars);
|
|
273
|
+
setExecutorRef(previous.executor);
|
|
274
|
+
};
|
|
241
275
|
const argumentError = parseArgs(args);
|
|
242
276
|
if (argumentError) {
|
|
277
|
+
restoreRefs();
|
|
243
278
|
notify(ctx, argumentError, "error");
|
|
244
279
|
return;
|
|
245
280
|
}
|
|
246
281
|
const { error } = await resolveActivationModels(ctx);
|
|
247
282
|
if (error) {
|
|
283
|
+
restoreRefs();
|
|
248
284
|
notify(ctx, error, "error");
|
|
249
285
|
return;
|
|
250
286
|
}
|
|
@@ -356,7 +392,9 @@ export const registerCommands = (
|
|
|
356
392
|
description:
|
|
357
393
|
"Consult the Advisor in parallel; accepts an optional focused question and fans its response out to the Executor",
|
|
358
394
|
handler: (args, ctx) => {
|
|
359
|
-
|
|
395
|
+
if (!loadCommandConfig(ctx)) {
|
|
396
|
+
return Promise.resolve();
|
|
397
|
+
}
|
|
360
398
|
if (
|
|
361
399
|
!(
|
|
362
400
|
isSimpleMode() ||
|
|
@@ -396,8 +434,7 @@ export const registerCommands = (
|
|
|
396
434
|
description:
|
|
397
435
|
"Select and persist the Executor and Advisor models with reasoning levels",
|
|
398
436
|
handler: async (_args, ctx) => {
|
|
399
|
-
|
|
400
|
-
if (!ctx.hasUI) {
|
|
437
|
+
if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
|
|
401
438
|
return;
|
|
402
439
|
}
|
|
403
440
|
const refs = ctx.modelRegistry
|
|
@@ -475,8 +512,7 @@ export const registerCommands = (
|
|
|
475
512
|
description: "Configure Advisor context and reasoning effort",
|
|
476
513
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one settings form maps every persisted control.
|
|
477
514
|
handler: async (_args, ctx) => {
|
|
478
|
-
|
|
479
|
-
if (!ctx.hasUI) {
|
|
515
|
+
if (!(loadCommandConfig(ctx) && ctx.hasUI)) {
|
|
480
516
|
return;
|
|
481
517
|
}
|
|
482
518
|
|
package/src/config.ts
CHANGED
|
@@ -305,16 +305,10 @@ const invalidConfigValue = (
|
|
|
305
305
|
);
|
|
306
306
|
};
|
|
307
307
|
|
|
308
|
-
const
|
|
309
|
-
|
|
308
|
+
const unknownConfigKeys = (config: ConfigRecord) =>
|
|
309
|
+
Object.keys(config).filter(
|
|
310
310
|
(key) => !CONFIG_KEYS.has(key as keyof AdvisorConfig)
|
|
311
311
|
);
|
|
312
|
-
if (unknownKeys.length > 0) {
|
|
313
|
-
throw new TypeError(
|
|
314
|
-
`Invalid advisor configuration at ${path}: unknown key(s) ${unknownKeys.map((key) => JSON.stringify(key)).join(", ")}. Remove them or upgrade pi-advisor.`
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
312
|
|
|
319
313
|
const validateStringValues = (config: ConfigRecord, path: string) => {
|
|
320
314
|
for (const key of STRING_CONFIG_KEYS) {
|
|
@@ -406,7 +400,6 @@ export const validateConfig = (
|
|
|
406
400
|
);
|
|
407
401
|
}
|
|
408
402
|
const config = value as ConfigRecord;
|
|
409
|
-
validateKnownKeys(config, path);
|
|
410
403
|
validateStringValues(config, path);
|
|
411
404
|
validateBooleanValues(config, path);
|
|
412
405
|
validateNumericValues(config, path);
|
|
@@ -587,10 +580,12 @@ const configCache = new Map<
|
|
|
587
580
|
string,
|
|
588
581
|
{ config: AdvisorConfig; identity: string }
|
|
589
582
|
>();
|
|
583
|
+
const warnedUnknownConfigIdentities = new Set<string>();
|
|
590
584
|
|
|
591
585
|
/** Drops the parsed-configuration cache; the next load re-reads from disk. */
|
|
592
586
|
export const resetConfigCache = () => {
|
|
593
587
|
configCache.clear();
|
|
588
|
+
warnedUnknownConfigIdentities.clear();
|
|
594
589
|
};
|
|
595
590
|
|
|
596
591
|
/**
|
|
@@ -626,6 +621,19 @@ export const loadConfig = (_ctx: ExtensionContext) => {
|
|
|
626
621
|
: undefined;
|
|
627
622
|
if (globalConfig) {
|
|
628
623
|
applyConfig(globalConfig);
|
|
624
|
+
const unknownKeys = unknownConfigKeys(globalConfig as ConfigRecord);
|
|
625
|
+
const warningIdentity = `${global}:${configIdentity(global)}`;
|
|
626
|
+
if (
|
|
627
|
+
unknownKeys.length > 0 &&
|
|
628
|
+
_ctx.hasUI &&
|
|
629
|
+
!warnedUnknownConfigIdentities.has(warningIdentity)
|
|
630
|
+
) {
|
|
631
|
+
_ctx.ui.notify(
|
|
632
|
+
`Advisor configuration at ${global} contains unrecognized key(s) ${unknownKeys.map((key) => JSON.stringify(key)).join(", ")}. They were preserved but ignored; check for typos or upgrade pi-advisor.`,
|
|
633
|
+
"warning"
|
|
634
|
+
);
|
|
635
|
+
warnedUnknownConfigIdentities.add(warningIdentity);
|
|
636
|
+
}
|
|
629
637
|
}
|
|
630
638
|
// Repository-controlled project configuration is never applied. Models,
|
|
631
639
|
// prompts, gates, budgets, disclosure, redaction, integrations, and consent
|
package/src/conversation.ts
CHANGED
|
@@ -288,28 +288,41 @@ const selectRecentEntries = (entries: string[], maxChars: number): string => {
|
|
|
288
288
|
if (joined.length <= maxChars || maxChars === Number.MAX_SAFE_INTEGER) {
|
|
289
289
|
return joined;
|
|
290
290
|
}
|
|
291
|
+
const newestTruncated = "[Newest entry truncated]";
|
|
292
|
+
if (entries.length === 1) {
|
|
293
|
+
const prefix = `${newestTruncated}${separator}`;
|
|
294
|
+
return `${prefix}${entries[0].slice(0, Math.max(0, maxChars - prefix.length))}`.slice(
|
|
295
|
+
0,
|
|
296
|
+
maxChars
|
|
297
|
+
);
|
|
298
|
+
}
|
|
291
299
|
const selected: string[] = [];
|
|
292
|
-
// Track the joined length instead of re-joining the accumulator each step;
|
|
293
|
-
// long branches with a large maxChars are otherwise quadratic in bytes.
|
|
294
300
|
let selectedLength = 0;
|
|
295
301
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
296
302
|
const entry = entries[index];
|
|
303
|
+
const candidateCount = selected.length + 1;
|
|
304
|
+
const omitted = entries.length - candidateCount;
|
|
305
|
+
const marker = `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
|
|
297
306
|
const candidateLength =
|
|
298
307
|
selectedLength +
|
|
299
308
|
entry.length +
|
|
300
|
-
(selected.length
|
|
301
|
-
if (
|
|
302
|
-
selected.unshift(entry);
|
|
303
|
-
selectedLength = candidateLength;
|
|
304
|
-
} else {
|
|
309
|
+
(selected.length > 0 ? separator.length : 0);
|
|
310
|
+
if (marker.length + separator.length + candidateLength > maxChars) {
|
|
305
311
|
break;
|
|
306
312
|
}
|
|
313
|
+
selected.unshift(entry);
|
|
314
|
+
selectedLength = candidateLength;
|
|
307
315
|
}
|
|
308
|
-
const omitted = entries.length - selected.length;
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
316
|
+
const omitted = entries.length - Math.max(1, selected.length);
|
|
317
|
+
const marker = `[Older context omitted: ${omitted} complete entr${omitted === 1 ? "y" : "ies"}]`;
|
|
318
|
+
if (selected.length > 0) {
|
|
319
|
+
return `${marker}${separator}${selected.join(separator)}`;
|
|
320
|
+
}
|
|
321
|
+
const prefix = `${marker}${separator}${newestTruncated}${separator}`;
|
|
322
|
+
return `${prefix}${entries.at(-1)?.slice(0, Math.max(0, maxChars - prefix.length)) ?? ""}`.slice(
|
|
323
|
+
0,
|
|
324
|
+
maxChars
|
|
325
|
+
);
|
|
313
326
|
};
|
|
314
327
|
|
|
315
328
|
export const recentConversation = (
|
package/src/git.ts
CHANGED
|
@@ -60,8 +60,12 @@ export const capRepositoryContext = (value: string, maxChars: number) => {
|
|
|
60
60
|
if (value.length <= maxChars) {
|
|
61
61
|
return { text: value, truncated: false };
|
|
62
62
|
}
|
|
63
|
+
const contentChars = Math.max(0, maxChars - TRUNCATION_NOTICE.length);
|
|
63
64
|
return {
|
|
64
|
-
text:
|
|
65
|
+
text:
|
|
66
|
+
maxChars < TRUNCATION_NOTICE.length
|
|
67
|
+
? TRUNCATION_NOTICE.slice(0, maxChars)
|
|
68
|
+
: `${value.slice(0, contentChars)}${TRUNCATION_NOTICE}`,
|
|
65
69
|
truncated: true,
|
|
66
70
|
};
|
|
67
71
|
};
|
package/src/outcomes.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
appendFile,
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
stat,
|
|
10
|
+
unlink,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
3
13
|
import { join } from "node:path";
|
|
4
14
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
15
|
|
|
@@ -35,18 +45,76 @@ export const outcomeLogPath = () =>
|
|
|
35
45
|
|
|
36
46
|
const salt = async () => {
|
|
37
47
|
const path = statePath();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
|
|
49
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
50
|
+
try {
|
|
51
|
+
// biome-ignore lint/performance/noAwaitInLoops: contenders retry sequentially until one salt is atomically published.
|
|
52
|
+
const existing = await readFile(path);
|
|
53
|
+
if (existing.length === 32) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
// Recover a salt file left incomplete by an interrupted older writer.
|
|
57
|
+
await unlink(path);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
42
63
|
const value = randomBytes(32);
|
|
43
|
-
const temporary = `${path}.${process.pid}.${
|
|
64
|
+
const temporary = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.${attempt}`;
|
|
44
65
|
await writeFile(temporary, value, { mode: 0o600 });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
try {
|
|
67
|
+
await link(temporary, path);
|
|
68
|
+
return value;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
await unlink(temporary).catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error("Advisor outcome salt initialization did not complete.");
|
|
78
|
+
};
|
|
79
|
+
const sameFile = (
|
|
80
|
+
left: { dev: number | bigint; ino: number | bigint },
|
|
81
|
+
right: { dev: number | bigint; ino: number | bigint }
|
|
82
|
+
) => left.dev === right.dev && left.ino === right.ino;
|
|
83
|
+
|
|
84
|
+
const withOutcomeLock = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
85
|
+
const lockPath = `${outcomeLogPath()}.lock`;
|
|
86
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
87
|
+
try {
|
|
88
|
+
// biome-ignore lint/performance/noAwaitInLoops: lock acquisition must retry sequentially across processes.
|
|
89
|
+
const lock = await open(lockPath, "wx", 0o600);
|
|
90
|
+
const identity = await lock.stat();
|
|
91
|
+
try {
|
|
92
|
+
return await run();
|
|
93
|
+
} finally {
|
|
94
|
+
await lock.close();
|
|
95
|
+
const current = await stat(lockPath).catch(() => undefined);
|
|
96
|
+
if (current && sameFile(identity, current)) {
|
|
97
|
+
await unlink(lockPath).catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const observed = await stat(lockPath).catch(() => undefined);
|
|
105
|
+
if (observed && Date.now() - observed.mtimeMs > 30_000) {
|
|
106
|
+
const current = await stat(lockPath).catch(() => undefined);
|
|
107
|
+
if (current && sameFile(observed, current)) {
|
|
108
|
+
await unlink(lockPath).catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
113
|
+
}
|
|
48
114
|
}
|
|
115
|
+
throw new Error("Timed out waiting to append an Advisor outcome.");
|
|
49
116
|
};
|
|
117
|
+
|
|
50
118
|
export const adviceDigest = (advice: string, key: Buffer) =>
|
|
51
119
|
createHmac("sha256", key).update(advice).digest("hex").slice(0, 16);
|
|
52
120
|
|
|
@@ -58,12 +126,6 @@ export const appendOutcome = async (
|
|
|
58
126
|
) => {
|
|
59
127
|
const path = outcomeLogPath();
|
|
60
128
|
await mkdir(getAgentDir(), { mode: 0o700, recursive: true });
|
|
61
|
-
let previous = "";
|
|
62
|
-
try {
|
|
63
|
-
previous = await readFile(path, "utf8");
|
|
64
|
-
} catch {
|
|
65
|
-
/* new log */
|
|
66
|
-
}
|
|
67
129
|
const next: OutcomeRecord = {
|
|
68
130
|
adoption: record.adoption,
|
|
69
131
|
adviceHash: adviceDigest(record.advice, await salt()),
|
|
@@ -73,13 +135,21 @@ export const appendOutcome = async (
|
|
|
73
135
|
validationStatus: record.validationStatus,
|
|
74
136
|
};
|
|
75
137
|
const line = `${JSON.stringify(next)}\n`;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
await withOutcomeLock(async () => {
|
|
139
|
+
const currentBytes = await stat(path)
|
|
140
|
+
.then((value) => value.size)
|
|
141
|
+
.catch((error: NodeJS.ErrnoException) => {
|
|
142
|
+
if (error.code === "ENOENT") {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
if (currentBytes + Buffer.byteLength(line) > MAX_LOG_BYTES) {
|
|
148
|
+
await writeFile(path, line, { encoding: "utf8", mode: 0o600 });
|
|
149
|
+
} else {
|
|
150
|
+
await appendFile(path, line, { encoding: "utf8", mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
await chmod(path, 0o600);
|
|
153
|
+
});
|
|
84
154
|
return next;
|
|
85
155
|
};
|
package/src/tools.ts
CHANGED
|
@@ -141,6 +141,8 @@ export const gitContextNote = (
|
|
|
141
141
|
return `Repository context was limited to "${allowed}" by user configuration; a fuller view was requested but withheld.`;
|
|
142
142
|
}
|
|
143
143
|
switch (result.status) {
|
|
144
|
+
case "disabled":
|
|
145
|
+
return "Repository context was disabled or had no disclosure budget; it was withheld. Do not assume the working tree is clean.";
|
|
144
146
|
case "no-changes":
|
|
145
147
|
return "The working tree has no uncommitted changes.";
|
|
146
148
|
case "not-a-repository":
|
|
@@ -157,6 +159,20 @@ const LEVEL_WITHHELD: Record<string, boolean> = {
|
|
|
157
159
|
"no-changes": false,
|
|
158
160
|
};
|
|
159
161
|
|
|
162
|
+
export const advisorRepositoryContext = (
|
|
163
|
+
result: GitContextResult,
|
|
164
|
+
requested: GitContextLevel,
|
|
165
|
+
allowed: GitContextLevel,
|
|
166
|
+
budget: number
|
|
167
|
+
) => {
|
|
168
|
+
const note = gitContextNote(result, requested, allowed);
|
|
169
|
+
const payload = capRepositoryContext(
|
|
170
|
+
escapeRepositoryText(result.text),
|
|
171
|
+
budget
|
|
172
|
+
).text;
|
|
173
|
+
return [note, payload].filter(Boolean).join("\n\n");
|
|
174
|
+
};
|
|
175
|
+
|
|
160
176
|
/**
|
|
161
177
|
* The conversation boundary for outgoing Advisor requests. Repository context is
|
|
162
178
|
* the only other egress path; both are assembled by advisorMessageText and both
|
|
@@ -433,11 +449,14 @@ const collectAdvisorResponse = async (
|
|
|
433
449
|
);
|
|
434
450
|
// The note is placed first so a cap can never drop the statement that the
|
|
435
451
|
// Advisor's view of the repository is limited.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
452
|
+
// The disclosure warning is control metadata, not repository payload. Keep it
|
|
453
|
+
// outside the zero-byte Git budget so disabling disclosure cannot erase it.
|
|
454
|
+
const changeText = advisorRepositoryContext(
|
|
455
|
+
changes,
|
|
456
|
+
gitContext ?? allowed,
|
|
457
|
+
level,
|
|
439
458
|
gitBudget
|
|
440
|
-
)
|
|
459
|
+
);
|
|
441
460
|
// Repository context spends part of the shared budget, so a large patch
|
|
442
461
|
// cannot silently push the conversation past the model's context window.
|
|
443
462
|
const conversation = advisorRequestConversation(
|
|
@@ -668,7 +687,6 @@ const reserveAdvisorCall = (
|
|
|
668
687
|
notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
|
|
669
688
|
return { block: true, reason: message };
|
|
670
689
|
}
|
|
671
|
-
session.consumeCall();
|
|
672
690
|
reservedCalls.add(event.toolCallId);
|
|
673
691
|
return {};
|
|
674
692
|
};
|
|
@@ -934,8 +952,10 @@ const renderAdvisorResult = (
|
|
|
934
952
|
return box;
|
|
935
953
|
};
|
|
936
954
|
|
|
937
|
-
export const registerAdvisorTool = (
|
|
938
|
-
|
|
955
|
+
export const registerAdvisorTool = (
|
|
956
|
+
pi: ExtensionAPI,
|
|
957
|
+
session: AdvisorSessionState = advisorSessionState
|
|
958
|
+
) => {
|
|
939
959
|
const reservedCalls = new Set<string>();
|
|
940
960
|
|
|
941
961
|
pi.registerMessageRenderer?.(
|
|
@@ -1021,16 +1041,16 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
1021
1041
|
});
|
|
1022
1042
|
|
|
1023
1043
|
pi.on("tool_call", (event, ctx) => {
|
|
1024
|
-
if (
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
loadConfig(ctx);
|
|
1028
|
-
if (!isSimpleMode() && session.blocked) {
|
|
1044
|
+
if (session.blocked) {
|
|
1029
1045
|
return {
|
|
1030
1046
|
block: true,
|
|
1031
1047
|
reason: session.blockedReason ?? "Advisor session is blocked.",
|
|
1032
1048
|
};
|
|
1033
1049
|
}
|
|
1050
|
+
if (!pi.getActiveTools().includes("ask_advisor")) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
loadConfig(ctx);
|
|
1034
1054
|
const reservation = reserveAdvisorCall(event, ctx, session, reservedCalls);
|
|
1035
1055
|
if (event.toolName === "ask_advisor") {
|
|
1036
1056
|
return reservation;
|
|
@@ -1039,6 +1059,9 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
1039
1059
|
});
|
|
1040
1060
|
|
|
1041
1061
|
pi.on("agent_settled", (_event, ctx) => {
|
|
1062
|
+
// Any reservation still present never reached execute (for example because
|
|
1063
|
+
// another handler blocked it or the turn was aborted).
|
|
1064
|
+
reservedCalls.clear();
|
|
1042
1065
|
if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) {
|
|
1043
1066
|
return;
|
|
1044
1067
|
}
|
|
@@ -1057,7 +1080,8 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
1057
1080
|
description:
|
|
1058
1081
|
"Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review.",
|
|
1059
1082
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1060
|
-
|
|
1083
|
+
reservedCalls.delete(_id);
|
|
1084
|
+
if (!isSimpleMode()) {
|
|
1061
1085
|
if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
|
|
1062
1086
|
throw new Error("Advisor call budget exhausted for this session.");
|
|
1063
1087
|
}
|
package/src/ui.ts
CHANGED
|
@@ -24,6 +24,18 @@ interface SearchableModelSelectorOptions {
|
|
|
24
24
|
title: string;
|
|
25
25
|
tui: RenderRequester;
|
|
26
26
|
}
|
|
27
|
+
const stepNumericPreset = (
|
|
28
|
+
current: number,
|
|
29
|
+
presets: number[],
|
|
30
|
+
direction: number
|
|
31
|
+
) => {
|
|
32
|
+
const values = presets.includes(current)
|
|
33
|
+
? presets
|
|
34
|
+
: [...presets, current].sort((a, b) => a - b);
|
|
35
|
+
const index = values.indexOf(current);
|
|
36
|
+
return values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
37
|
+
};
|
|
38
|
+
|
|
27
39
|
interface AdvisorSettingsSelectorOptions {
|
|
28
40
|
effortLevels: string[];
|
|
29
41
|
initial: AdvisorSettings;
|
|
@@ -304,14 +316,24 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
304
316
|
this.editingCustom = false;
|
|
305
317
|
this.editingPolicies = false;
|
|
306
318
|
this.policiesError = undefined;
|
|
307
|
-
this.options = options;
|
|
308
319
|
// Retain every advanced value even when Simple mode hides its controls.
|
|
309
320
|
this.settings = { ...options.initial };
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
321
|
+
const configuredContext = this.settings.contextMaxChars;
|
|
322
|
+
const presets = options.presets.some(
|
|
323
|
+
(preset) => preset.value === configuredContext
|
|
324
|
+
)
|
|
325
|
+
? [...options.presets]
|
|
326
|
+
: [
|
|
327
|
+
...options.presets,
|
|
328
|
+
{
|
|
329
|
+
description: "Custom configured value",
|
|
330
|
+
label: String(configuredContext),
|
|
331
|
+
value: configuredContext,
|
|
332
|
+
},
|
|
333
|
+
].sort((a, b) => a.value - b.value);
|
|
334
|
+
this.options = { ...options, presets };
|
|
335
|
+
this.contextIndex = presets.findIndex(
|
|
336
|
+
(preset) => preset.value === configuredContext
|
|
315
337
|
);
|
|
316
338
|
this.effortIndex = Math.max(
|
|
317
339
|
0,
|
|
@@ -746,6 +768,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
746
768
|
tui.requestRender();
|
|
747
769
|
}
|
|
748
770
|
|
|
771
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one keyboard dispatcher maps each settings row to its bounded adjustment.
|
|
749
772
|
private adjust(direction: number): void {
|
|
750
773
|
switch (this.selectedRowId()) {
|
|
751
774
|
case "simpleMode":
|
|
@@ -808,11 +831,14 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
808
831
|
);
|
|
809
832
|
break;
|
|
810
833
|
case "maxCallsPerSession": {
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
834
|
+
const current = this.settings.maxCallsPerSession;
|
|
835
|
+
const numeric = [0, 1, 2, 3, 5, 10, 25, 50];
|
|
836
|
+
const sorted =
|
|
837
|
+
current === undefined || numeric.includes(current)
|
|
838
|
+
? numeric
|
|
839
|
+
: [...numeric, current].sort((a, b) => a - b);
|
|
840
|
+
const values: (number | undefined)[] = [undefined, ...sorted];
|
|
841
|
+
const index = values.indexOf(current);
|
|
816
842
|
this.settings.maxCallsPerSession =
|
|
817
843
|
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
818
844
|
break;
|
|
@@ -865,33 +891,27 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
865
891
|
break;
|
|
866
892
|
}
|
|
867
893
|
case "gitContextMaxChars": {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
0,
|
|
871
|
-
|
|
894
|
+
this.settings.gitContextMaxChars = stepNumericPreset(
|
|
895
|
+
this.settings.gitContextMaxChars ?? 20_000,
|
|
896
|
+
[0, 5000, 10_000, 20_000, 50_000, 100_000],
|
|
897
|
+
direction
|
|
872
898
|
);
|
|
873
|
-
this.settings.gitContextMaxChars =
|
|
874
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
875
899
|
break;
|
|
876
900
|
}
|
|
877
901
|
case "toolResultMaxLines": {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
0,
|
|
881
|
-
|
|
902
|
+
this.settings.toolResultMaxLines = stepNumericPreset(
|
|
903
|
+
this.settings.toolResultMaxLines ?? 2000,
|
|
904
|
+
[0, 500, 1000, 2000, 5000, 10_000],
|
|
905
|
+
direction
|
|
882
906
|
);
|
|
883
|
-
this.settings.toolResultMaxLines =
|
|
884
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
885
907
|
break;
|
|
886
908
|
}
|
|
887
909
|
case "toolResultMaxBytes": {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
0,
|
|
891
|
-
|
|
910
|
+
this.settings.toolResultMaxBytes = stepNumericPreset(
|
|
911
|
+
this.settings.toolResultMaxBytes ?? 50 * 1024,
|
|
912
|
+
[0, 10 * 1024, 50 * 1024, 100 * 1024, 500 * 1024],
|
|
913
|
+
direction
|
|
892
914
|
);
|
|
893
|
-
this.settings.toolResultMaxBytes =
|
|
894
|
-
values[Math.max(0, Math.min(values.length - 1, index + direction))];
|
|
895
915
|
break;
|
|
896
916
|
}
|
|
897
917
|
default:
|