pi-advisor-flow 0.2.2 → 0.2.3
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 +108 -67
- package/package.json +1 -1
- package/src/commands.ts +4 -0
- package/src/config.ts +56 -0
- package/src/conversation.ts +91 -16
- package/src/tools.ts +5 -1
- package/src/ui.ts +81 -3
package/README.md
CHANGED
|
@@ -1,84 +1,109 @@
|
|
|
1
1
|
# pi-advisor
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
|
-
<img src="https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/screenshot.png" alt="Pi Advisor
|
|
4
|
+
<img src="https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/screenshot.png" alt="Pi Advisor consultation in the terminal" width="760">
|
|
5
|
+
|
|
6
|
+
A configurable second-opinion workflow for <a href="https://github.com/earendil-works/pi">Pi</a> coding agents.
|
|
7
|
+
|
|
5
8
|
</div>
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Keep the work to the executor model, while the advisor steers it.
|
|
8
11
|
|
|
9
12
|
This extension introduces a strategic "Executor/Advisor" workflow, inspired by Claudes [Advisor](https://code.claude.com/docs/en/advisor).
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
[Read more about it here](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
|
|
14
|
+
`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 provides a concise review, answers questions and can provide help; it does not take over planning or run tools.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
[Read more about Advisors here](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
|
|
16
17
|
|
|
17
|
-
Install
|
|
18
|
+
## Install
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
Install into your Pi agent environment:
|
|
20
21
|
|
|
21
22
|
```bash
|
|
23
|
+
# npm
|
|
22
24
|
pi install npm:pi-advisor-flow
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
### From Git
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
# GitHub
|
|
28
27
|
pi install git:github.com/philipbrembeck/pi-advisor.git
|
|
29
|
-
```
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
```bash
|
|
29
|
+
# local checkout, useful during development
|
|
34
30
|
pi install /path/to/pi-advisor
|
|
35
31
|
```
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
Restart or reload Pi after installation, then run `/advisor` in a session.
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
## Start using it
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
1. Run `/advisor` to enable the flow and register `ask_advisor`.
|
|
38
|
+
2. Run `/advisor-models` to choose the Executor and Advisor models.
|
|
39
|
+
3. Run `/advisor-settings` to set context, gates, privacy controls, and output limits.
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
Enable with models in one command when preferred:
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
-
|
|
43
|
+
```text
|
|
44
|
+
/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`/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.
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
## Commands
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
| Command | Purpose |
|
|
52
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `/advisor` | Enable the flow, select the configured Executor, and register `ask_advisor`. Accepts `executor=`, `advisor=`, and `contextMaxChars=` overrides. |
|
|
54
|
+
| `/advisor-manual [focus]` | Start a parallel Advisor consultation without interrupting the current Executor turn. |
|
|
55
|
+
| `/advisor-models` | Choose Executor and Advisor models plus their reasoning effort. |
|
|
56
|
+
| `/advisor-settings` | Open all Advisor settings in one keyboard-navigable screen. |
|
|
57
|
+
| `/advisor-off` | Disable the flow and remove `ask_advisor` from the active session. |
|
|
51
58
|
|
|
52
|
-
###
|
|
59
|
+
### `ask_advisor`
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
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.
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
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.
|
|
57
64
|
|
|
58
|
-
|
|
65
|
+
Normal consultations preserve the provider's final Markdown. They do not parse a verdict or block execution.
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
## Automatic loop gate
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
2. Advisor Model & Reasoning Effort
|
|
69
|
+
The optional loop gate detects consecutive calls with the same normalized tool signature. By default, it consults the Advisor after three repeats.
|
|
64
70
|
|
|
65
|
-
|
|
71
|
+
Unlike ordinary consultations, a loop-gate reply must start with exactly one decision header:
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
```text
|
|
74
|
+
Decision: proceed
|
|
75
|
+
Decision: revise
|
|
76
|
+
Decision: blocked
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Decision | Effect |
|
|
80
|
+
| --------- | --------------------------------------------------- |
|
|
81
|
+
| `proceed` | Reset the repeat counter and allow the tool action. |
|
|
82
|
+
| `revise` | Block the repeated tool action. |
|
|
83
|
+
| `blocked` | Apply the configured gate-failure policy. |
|
|
68
84
|
|
|
69
|
-
|
|
85
|
+
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.
|
|
70
86
|
|
|
71
|
-
|
|
87
|
+
| Failure mode | Effect |
|
|
88
|
+
| ------------------------- | ----------------------------------- |
|
|
89
|
+
| `block-session` (default) | Block the session. |
|
|
90
|
+
| `block-tool` | Block only the current tool action. |
|
|
91
|
+
| `warn-and-continue` | Show a warning and continue. |
|
|
72
92
|
|
|
73
|
-
|
|
93
|
+
| Condition | `block-session` | `block-tool` | `warn-and-continue` |
|
|
94
|
+
| -------------------------------------------------------- | --------------- | ----------------- | ------------------- |
|
|
95
|
+
| Advisor unavailable or timed out | Block session | Block tool action | Warn and continue |
|
|
96
|
+
| Missing, malformed, duplicate, or contradictory decision | Block session | Block tool action | Warn and continue |
|
|
97
|
+
| Shared budget exhausted | Block session | Block tool action | Warn and continue |
|
|
98
|
+
| `Decision: blocked` | Block session | Block tool action | Warn and continue |
|
|
74
99
|
|
|
75
|
-
|
|
100
|
+
`advisorBlockOnBlocked` controls whether a session block immediately aborts the active run. It never turns a session block into a tool-only block.
|
|
76
101
|
|
|
77
|
-
|
|
102
|
+
## Settings and configuration
|
|
78
103
|
|
|
79
|
-
|
|
104
|
+
`/advisor-models` and `/advisor-settings` save to `advisor.json` in the Pi agent directory. If a trusted project already has its own configuration, Pi uses that file instead.
|
|
80
105
|
|
|
81
|
-
|
|
106
|
+
All fields are optional. This example shows the available settings and their normal defaults:
|
|
82
107
|
|
|
83
108
|
```json
|
|
84
109
|
{
|
|
@@ -87,60 +112,76 @@ The selected configuration is saved as `advisor.json` in the Pi agent directory
|
|
|
87
112
|
"executorEffort": "medium",
|
|
88
113
|
"advisorEffort": "xhigh",
|
|
89
114
|
"contextMaxChars": 25000,
|
|
115
|
+
|
|
90
116
|
"advisorPlanGate": true,
|
|
91
117
|
"advisorFailureGate": true,
|
|
92
118
|
"advisorCompletionGate": true,
|
|
93
|
-
"advisorCollapseResponses": false,
|
|
94
119
|
"advisorCustomInvocation": "before changing a production deployment",
|
|
95
|
-
"
|
|
120
|
+
"advisorCollapseResponses": false,
|
|
121
|
+
|
|
96
122
|
"advisorAutoLoopGate": true,
|
|
97
123
|
"advisorLoopThreshold": 3,
|
|
98
124
|
"advisorMaxCallsPerSession": 5,
|
|
99
|
-
"
|
|
125
|
+
"advisorBlockOnBlocked": true,
|
|
100
126
|
"gateFailureMode": "block-session",
|
|
127
|
+
|
|
128
|
+
"advisorSessionSummary": true,
|
|
101
129
|
"advisorHerdrIntegration": true,
|
|
102
130
|
"advisorToolResultMaxLines": 2000,
|
|
103
|
-
"advisorToolResultMaxBytes": 51200
|
|
131
|
+
"advisorToolResultMaxBytes": 51200,
|
|
132
|
+
|
|
133
|
+
"advisorRedactSecrets": false,
|
|
134
|
+
"advisorToolPolicies": {
|
|
135
|
+
"bash": "summary",
|
|
136
|
+
"deploy": "exclude"
|
|
137
|
+
}
|
|
104
138
|
}
|
|
105
139
|
```
|
|
106
140
|
|
|
107
|
-
|
|
141
|
+
### Context and limits
|
|
142
|
+
|
|
143
|
+
- `contextMaxChars` defaults to `15000`. It preserves complete semantic entries and adds an omission marker rather than splitting a message.
|
|
144
|
+
- Set `contextMaxChars` to `0` to omit reconstructed history. `9007199254740991` is the persisted value for `ALL`.
|
|
145
|
+
- Tool results default to Pi's `2000` lines and `50 KiB` limits. Oversized results preserve their beginning and end with an omission marker.
|
|
146
|
+
- `advisorLoopThreshold` is an integer of at least `2`; its default is `3`.
|
|
147
|
+
- Omit `advisorMaxCallsPerSession` for an unlimited shared budget. Otherwise it must be a non-negative safe integer.
|
|
108
148
|
|
|
109
|
-
|
|
149
|
+
### Privacy controls
|
|
110
150
|
|
|
111
|
-
|
|
151
|
+
Advisor context can contain user messages, tool calls, and tool results. Configure disclosure deliberately:
|
|
112
152
|
|
|
113
|
-
|
|
153
|
+
- `advisorRedactSecrets` defaults to `false`. When enabled, pi-advisor locally redacts common credential patterns before including context in an Advisor request.
|
|
154
|
+
- `advisorToolPolicies` matches an **exact tool name**. Each tool may use `full`, `summary`, or `exclude`.
|
|
155
|
+
- `full` includes the call arguments and capped result output.
|
|
156
|
+
- `summary` omits call arguments and result output but includes result status and size metadata.
|
|
157
|
+
- `exclude` omits both call details and output.
|
|
158
|
+
- Tools not listed in `advisorToolPolicies`, including custom and newly added tools, use `full` for backward compatibility.
|
|
114
159
|
|
|
115
|
-
|
|
160
|
+
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.
|
|
116
161
|
|
|
117
|
-
|
|
162
|
+
### Session summary and Herdr
|
|
118
163
|
|
|
119
|
-
-
|
|
120
|
-
- `@philipbrembeck/pi-advisor-flow` to GitHub Packages, which makes the package appear in this repository’s **Packages** sidebar
|
|
164
|
+
The optional Session Advisor Summary is local and in-memory only. It appears after a non-blocked settled run and is never persisted.
|
|
121
165
|
|
|
122
|
-
|
|
166
|
+
It distinguishes regular Markdown advice from gate decisions and records the trigger, model, usage/cost when available, failures, budget, and execution effect.
|
|
123
167
|
|
|
124
|
-
|
|
168
|
+
[Herdr](https://github.com/ogulcancelik/herdr) integration is enabled by default. It reports Advisor activity and blocked state through Herdr's metadata paths; disable it with `advisorHerdrIntegration`.
|
|
125
169
|
|
|
126
|
-
|
|
170
|
+
## Development
|
|
127
171
|
|
|
128
172
|
```bash
|
|
129
173
|
git clone git@github.com:philipbrembeck/pi-advisor.git
|
|
130
174
|
cd pi-advisor
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### 2. Install dependencies
|
|
134
|
-
|
|
135
|
-
```bash
|
|
136
175
|
bun install
|
|
137
|
-
```
|
|
138
176
|
|
|
139
|
-
|
|
177
|
+
bun test
|
|
178
|
+
bun run typecheck
|
|
179
|
+
bun run lint
|
|
180
|
+
```
|
|
140
181
|
|
|
141
|
-
|
|
182
|
+
## Links
|
|
142
183
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
184
|
+
- [MIT LICENSE](LICENSE)
|
|
185
|
+
- [Changelog](CHANGELOG.md)
|
|
186
|
+
- [npm package](https://www.npmjs.com/package/pi-advisor-flow)
|
|
187
|
+
- [Why use an Advisor flow?](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need)
|
package/package.json
CHANGED
package/src/commands.ts
CHANGED
|
@@ -26,8 +26,10 @@ import {
|
|
|
26
26
|
setAdvisorLoopThresholdRef,
|
|
27
27
|
setAdvisorMaxCallsPerSessionRef,
|
|
28
28
|
setAdvisorPlanGateRef,
|
|
29
|
+
setAdvisorRedactSecretsRef,
|
|
29
30
|
setAdvisorRef,
|
|
30
31
|
setAdvisorSessionSummaryRef,
|
|
32
|
+
setAdvisorToolPoliciesRef,
|
|
31
33
|
setAdvisorToolResultMaxBytesRef,
|
|
32
34
|
setAdvisorToolResultMaxLinesRef,
|
|
33
35
|
setContextMaxCharsRef,
|
|
@@ -430,6 +432,8 @@ export const registerCommands = (
|
|
|
430
432
|
setAdvisorHerdrIntegrationRef(settings.herdrIntegration ?? true);
|
|
431
433
|
setAdvisorToolResultMaxLinesRef(settings.toolResultMaxLines ?? 2000);
|
|
432
434
|
setAdvisorToolResultMaxBytesRef(settings.toolResultMaxBytes ?? 50 * 1024);
|
|
435
|
+
setAdvisorRedactSecretsRef(settings.redactSecrets ?? false);
|
|
436
|
+
setAdvisorToolPoliciesRef(settings.toolPolicies ?? {});
|
|
433
437
|
const path = saveConfig(ctx);
|
|
434
438
|
ctx.ui.notify(`Saved Advisor settings to ${path}`, "info");
|
|
435
439
|
},
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,13 @@ export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
|
|
|
14
14
|
export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
|
|
15
15
|
export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES = DEFAULT_MAX_LINES;
|
|
16
16
|
export const DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES = DEFAULT_MAX_BYTES;
|
|
17
|
+
export type AdvisorToolPolicy = "full" | "summary" | "exclude";
|
|
18
|
+
export type AdvisorToolPolicies = Record<string, AdvisorToolPolicy>;
|
|
19
|
+
export const ADVISOR_TOOL_POLICIES: AdvisorToolPolicy[] = [
|
|
20
|
+
"full",
|
|
21
|
+
"summary",
|
|
22
|
+
"exclude",
|
|
23
|
+
];
|
|
17
24
|
export type GateFailureMode =
|
|
18
25
|
| "block-session"
|
|
19
26
|
| "block-tool"
|
|
@@ -43,6 +50,8 @@ export let advisorFailureModeRef: GateFailureMode = "block-session";
|
|
|
43
50
|
export let advisorHerdrIntegrationRef = true;
|
|
44
51
|
export let advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
45
52
|
export let advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
53
|
+
export let advisorRedactSecretsRef = false;
|
|
54
|
+
export let advisorToolPoliciesRef: AdvisorToolPolicies = {};
|
|
46
55
|
|
|
47
56
|
export const setExecutorRef = (ref: string) => {
|
|
48
57
|
executorRef = ref;
|
|
@@ -119,6 +128,12 @@ export const setAdvisorToolResultMaxLinesRef = (value: number) => {
|
|
|
119
128
|
export const setAdvisorToolResultMaxBytesRef = (value: number) => {
|
|
120
129
|
advisorToolResultMaxBytesRef = value;
|
|
121
130
|
};
|
|
131
|
+
export const setAdvisorRedactSecretsRef = (enabled: boolean) => {
|
|
132
|
+
advisorRedactSecretsRef = enabled;
|
|
133
|
+
};
|
|
134
|
+
export const setAdvisorToolPoliciesRef = (policies: AdvisorToolPolicies) => {
|
|
135
|
+
advisorToolPoliciesRef = { ...policies };
|
|
136
|
+
};
|
|
122
137
|
|
|
123
138
|
/**
|
|
124
139
|
* Returns the current live settings state. Use this at UI boundaries instead of
|
|
@@ -138,7 +153,9 @@ export const getAdvisorSettings = () => ({
|
|
|
138
153
|
loopThreshold: advisorLoopThresholdRef,
|
|
139
154
|
maxCallsPerSession: advisorMaxCallsPerSessionRef,
|
|
140
155
|
planGate: advisorPlanGateRef,
|
|
156
|
+
redactSecrets: advisorRedactSecretsRef,
|
|
141
157
|
sessionSummary: advisorSessionSummaryRef,
|
|
158
|
+
toolPolicies: { ...advisorToolPoliciesRef },
|
|
142
159
|
toolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
143
160
|
toolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
144
161
|
});
|
|
@@ -168,7 +185,9 @@ export interface AdvisorConfig {
|
|
|
168
185
|
advisorLoopThreshold?: number;
|
|
169
186
|
advisorMaxCallsPerSession?: number;
|
|
170
187
|
advisorPlanGate?: boolean;
|
|
188
|
+
advisorRedactSecrets?: boolean;
|
|
171
189
|
advisorSessionSummary?: boolean;
|
|
190
|
+
advisorToolPolicies?: AdvisorToolPolicies;
|
|
172
191
|
advisorToolResultMaxBytes?: number;
|
|
173
192
|
advisorToolResultMaxLines?: number;
|
|
174
193
|
contextMaxChars?: number;
|
|
@@ -193,6 +212,8 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
|
|
|
193
212
|
"advisorSessionSummary",
|
|
194
213
|
"advisorToolResultMaxBytes",
|
|
195
214
|
"advisorToolResultMaxLines",
|
|
215
|
+
"advisorRedactSecrets",
|
|
216
|
+
"advisorToolPolicies",
|
|
196
217
|
"contextMaxChars",
|
|
197
218
|
"executor",
|
|
198
219
|
"executorEffort",
|
|
@@ -207,6 +228,7 @@ const BOOLEAN_CONFIG_KEYS = [
|
|
|
207
228
|
"advisorAutoLoopGate",
|
|
208
229
|
"advisorSessionSummary",
|
|
209
230
|
"advisorHerdrIntegration",
|
|
231
|
+
"advisorRedactSecrets",
|
|
210
232
|
] as const;
|
|
211
233
|
const STRING_CONFIG_KEYS = [
|
|
212
234
|
"executor",
|
|
@@ -262,6 +284,20 @@ const validateBooleanValues = (config: ConfigRecord, path: string) => {
|
|
|
262
284
|
}
|
|
263
285
|
};
|
|
264
286
|
|
|
287
|
+
export const isValidAdvisorToolPolicies = (
|
|
288
|
+
value: unknown
|
|
289
|
+
): value is AdvisorToolPolicies => {
|
|
290
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return Object.entries(value).every(
|
|
294
|
+
([toolName, policy]) =>
|
|
295
|
+
toolName.trim().length > 0 &&
|
|
296
|
+
typeof policy === "string" &&
|
|
297
|
+
ADVISOR_TOOL_POLICIES.includes(policy as AdvisorToolPolicy)
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
265
301
|
const validateNumericValues = (config: ConfigRecord, path: string) => {
|
|
266
302
|
const numericRules: [
|
|
267
303
|
keyof AdvisorConfig,
|
|
@@ -315,6 +351,16 @@ export const validateConfig = (
|
|
|
315
351
|
validateStringValues(config, path);
|
|
316
352
|
validateBooleanValues(config, path);
|
|
317
353
|
validateNumericValues(config, path);
|
|
354
|
+
if (
|
|
355
|
+
config.advisorToolPolicies !== undefined &&
|
|
356
|
+
!isValidAdvisorToolPolicies(config.advisorToolPolicies)
|
|
357
|
+
) {
|
|
358
|
+
invalidConfigValue(
|
|
359
|
+
path,
|
|
360
|
+
"advisorToolPolicies",
|
|
361
|
+
"a JSON object with non-empty tool names and full, summary, or exclude values"
|
|
362
|
+
);
|
|
363
|
+
}
|
|
318
364
|
if (
|
|
319
365
|
config.gateFailureMode !== undefined &&
|
|
320
366
|
!isValidGateFailureMode(config.gateFailureMode)
|
|
@@ -344,6 +390,8 @@ const resetDefaults = () => {
|
|
|
344
390
|
advisorHerdrIntegrationRef = true;
|
|
345
391
|
advisorToolResultMaxLinesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES;
|
|
346
392
|
advisorToolResultMaxBytesRef = DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES;
|
|
393
|
+
advisorRedactSecretsRef = false;
|
|
394
|
+
advisorToolPoliciesRef = {};
|
|
347
395
|
};
|
|
348
396
|
|
|
349
397
|
const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
|
|
@@ -426,6 +474,12 @@ const applyConfig = (config: AdvisorConfig) => {
|
|
|
426
474
|
"advisorToolResultMaxBytes",
|
|
427
475
|
setAdvisorToolResultMaxBytesRef
|
|
428
476
|
);
|
|
477
|
+
applyOptionalConfig(
|
|
478
|
+
config,
|
|
479
|
+
"advisorRedactSecrets",
|
|
480
|
+
setAdvisorRedactSecretsRef
|
|
481
|
+
);
|
|
482
|
+
applyOptionalConfig(config, "advisorToolPolicies", setAdvisorToolPoliciesRef);
|
|
429
483
|
};
|
|
430
484
|
|
|
431
485
|
const readConfig = (path: string): AdvisorConfig => {
|
|
@@ -488,7 +542,9 @@ export const saveConfig = (ctx: ExtensionContext) => {
|
|
|
488
542
|
? {}
|
|
489
543
|
: { advisorMaxCallsPerSession: advisorMaxCallsPerSessionRef }),
|
|
490
544
|
advisorHerdrIntegration: advisorHerdrIntegrationRef,
|
|
545
|
+
advisorRedactSecrets: advisorRedactSecretsRef,
|
|
491
546
|
advisorSessionSummary: advisorSessionSummaryRef,
|
|
547
|
+
advisorToolPolicies: advisorToolPoliciesRef,
|
|
492
548
|
advisorToolResultMaxBytes: advisorToolResultMaxBytesRef,
|
|
493
549
|
advisorToolResultMaxLines: advisorToolResultMaxLinesRef,
|
|
494
550
|
gateFailureMode: advisorFailureModeRef,
|
package/src/conversation.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
type AdvisorToolPolicies,
|
|
4
|
+
advisorRedactSecretsRef,
|
|
5
|
+
advisorToolPoliciesRef,
|
|
3
6
|
advisorToolResultMaxBytesRef,
|
|
4
7
|
advisorToolResultMaxLinesRef,
|
|
5
8
|
DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
|
|
@@ -33,6 +36,29 @@ export const textFrom = (content: unknown): string =>
|
|
|
33
36
|
|
|
34
37
|
const byteLength = (value: string) => Buffer.byteLength(value, "utf8");
|
|
35
38
|
|
|
39
|
+
const REDACTION_MARKER = "[REDACTED SECRET]";
|
|
40
|
+
const SECRET_PATTERNS = [
|
|
41
|
+
/-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/gi,
|
|
42
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
43
|
+
/\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s"'&,;)}\]]+)/gi,
|
|
44
|
+
/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi,
|
|
45
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
46
|
+
/\b(?:aws_secret_access_key|aws_session_token)\s*[:=]\s*[^\s"'&,;)}\]]+/gi,
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
/** Redacts common credential forms locally; it is not a data-classification system. */
|
|
50
|
+
export const redactSecrets = (value: string): string => {
|
|
51
|
+
let redacted = value;
|
|
52
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
53
|
+
redacted = redacted.replace(pattern, (_match, scheme) =>
|
|
54
|
+
typeof scheme === "string"
|
|
55
|
+
? `${scheme}${REDACTION_MARKER}@`
|
|
56
|
+
: REDACTION_MARKER
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return redacted;
|
|
60
|
+
};
|
|
61
|
+
|
|
36
62
|
export interface ToolResultTruncation {
|
|
37
63
|
content: string;
|
|
38
64
|
omittedLines: number;
|
|
@@ -122,18 +148,35 @@ export const capToolResult = (
|
|
|
122
148
|
};
|
|
123
149
|
};
|
|
124
150
|
|
|
125
|
-
const assistantEntry = (
|
|
151
|
+
const assistantEntry = (
|
|
152
|
+
message: RecordValue,
|
|
153
|
+
policies: AdvisorToolPolicies,
|
|
154
|
+
redact: boolean
|
|
155
|
+
): string | undefined => {
|
|
126
156
|
const parts: string[] = [];
|
|
127
157
|
const text = textFrom(message.content);
|
|
128
158
|
if (text) {
|
|
129
|
-
parts.push(text);
|
|
159
|
+
parts.push(redact ? redactSecrets(text) : text);
|
|
130
160
|
}
|
|
131
161
|
for (const part of contentParts(message.content)) {
|
|
132
162
|
if (!isRecord(part) || part.type !== "toolCall") {
|
|
133
163
|
continue;
|
|
134
164
|
}
|
|
165
|
+
const toolName = typeof part.name === "string" ? part.name : "unknown";
|
|
166
|
+
const policy = policies[toolName] ?? "full";
|
|
167
|
+
if (policy === "exclude") {
|
|
168
|
+
parts.push(`[Tool Call: ${toolName}] (excluded by Advisor tool policy)`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (policy === "summary") {
|
|
172
|
+
parts.push(
|
|
173
|
+
`[Tool Call: ${toolName}] (arguments omitted by Advisor tool policy: summary)`
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const argumentsText = JSON.stringify(part.arguments) ?? "undefined";
|
|
135
178
|
parts.push(
|
|
136
|
-
`[Tool Call: ${
|
|
179
|
+
`[Tool Call: ${toolName}(${redact ? redactSecrets(argumentsText) : argumentsText})]`
|
|
137
180
|
);
|
|
138
181
|
}
|
|
139
182
|
return parts.length > 0 ? `Executor: ${parts.join("\n")}` : undefined;
|
|
@@ -142,29 +185,47 @@ const assistantEntry = (message: RecordValue): string | undefined => {
|
|
|
142
185
|
const toolResultEntry = (
|
|
143
186
|
message: RecordValue,
|
|
144
187
|
toolResultMaxLines: number,
|
|
145
|
-
toolResultMaxBytes: number
|
|
188
|
+
toolResultMaxBytes: number,
|
|
189
|
+
policies: AdvisorToolPolicies,
|
|
190
|
+
redact: boolean
|
|
146
191
|
): string => {
|
|
147
|
-
const status = message.isError ? "
|
|
192
|
+
const status = message.isError ? "error" : "success";
|
|
193
|
+
const toolName =
|
|
194
|
+
typeof message.toolName === "string" ? message.toolName : "unknown";
|
|
195
|
+
const policy = policies[toolName] ?? "full";
|
|
196
|
+
const source = textFrom(message.content);
|
|
197
|
+
if (policy === "exclude") {
|
|
198
|
+
return `[Tool Result for ${toolName}] (excluded by Advisor tool policy)`;
|
|
199
|
+
}
|
|
200
|
+
if (policy === "summary") {
|
|
201
|
+
const capped = capToolResult(
|
|
202
|
+
source,
|
|
203
|
+
toolResultMaxLines,
|
|
204
|
+
toolResultMaxBytes
|
|
205
|
+
);
|
|
206
|
+
return `[Tool Result for ${toolName}] (output omitted by Advisor tool policy: summary; status: ${status}; ${capped.totalLines} lines, ${capped.totalBytes} bytes; source output was${capped.truncated ? "" : " not"} truncated)`;
|
|
207
|
+
}
|
|
208
|
+
const disclosed = redact ? redactSecrets(source) : source;
|
|
148
209
|
const capped = capToolResult(
|
|
149
|
-
|
|
210
|
+
disclosed,
|
|
150
211
|
toolResultMaxLines,
|
|
151
212
|
toolResultMaxBytes
|
|
152
213
|
);
|
|
153
|
-
|
|
154
|
-
typeof message.toolName === "string" ? message.toolName : "unknown";
|
|
155
|
-
return `[Tool Result for ${toolName}] (${status}output):\n${capped.content}`;
|
|
214
|
+
return `[Tool Result for ${toolName}] (${message.isError ? "Error " : ""}output):\n${capped.content}`;
|
|
156
215
|
};
|
|
157
216
|
|
|
158
217
|
const conversationEntry = (
|
|
159
218
|
entry: unknown,
|
|
160
219
|
toolResultMaxLines: number,
|
|
161
|
-
toolResultMaxBytes: number
|
|
220
|
+
toolResultMaxBytes: number,
|
|
221
|
+
policies: AdvisorToolPolicies,
|
|
222
|
+
redact: boolean
|
|
162
223
|
): string | undefined => {
|
|
163
224
|
if (!isRecord(entry)) {
|
|
164
225
|
return;
|
|
165
226
|
}
|
|
166
227
|
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
|
167
|
-
return `[System Compaction Summary]: ${entry.summary}`;
|
|
228
|
+
return `[System Compaction Summary]: ${redact ? redactSecrets(entry.summary) : entry.summary}`;
|
|
168
229
|
}
|
|
169
230
|
if (entry.type !== "message" || !isRecord(entry.message)) {
|
|
170
231
|
return;
|
|
@@ -172,13 +233,19 @@ const conversationEntry = (
|
|
|
172
233
|
const { message } = entry;
|
|
173
234
|
if (message.role === "user") {
|
|
174
235
|
const text = textFrom(message.content);
|
|
175
|
-
return text ? `User: ${text}` : undefined;
|
|
236
|
+
return text ? `User: ${redact ? redactSecrets(text) : text}` : undefined;
|
|
176
237
|
}
|
|
177
238
|
if (message.role === "assistant") {
|
|
178
|
-
return assistantEntry(message);
|
|
239
|
+
return assistantEntry(message, policies, redact);
|
|
179
240
|
}
|
|
180
241
|
if (message.role === "toolResult" || message.role === "tool") {
|
|
181
|
-
return toolResultEntry(
|
|
242
|
+
return toolResultEntry(
|
|
243
|
+
message,
|
|
244
|
+
toolResultMaxLines,
|
|
245
|
+
toolResultMaxBytes,
|
|
246
|
+
policies,
|
|
247
|
+
redact
|
|
248
|
+
);
|
|
182
249
|
}
|
|
183
250
|
};
|
|
184
251
|
|
|
@@ -208,7 +275,9 @@ export const recentConversation = (
|
|
|
208
275
|
ctx: ExtensionContext,
|
|
209
276
|
maxChars = 15_000,
|
|
210
277
|
toolResultMaxLines = advisorToolResultMaxLinesRef,
|
|
211
|
-
toolResultMaxBytes = advisorToolResultMaxBytesRef
|
|
278
|
+
toolResultMaxBytes = advisorToolResultMaxBytesRef,
|
|
279
|
+
policies = advisorToolPoliciesRef,
|
|
280
|
+
redact = advisorRedactSecretsRef
|
|
212
281
|
): string => {
|
|
213
282
|
if (maxChars === 0) {
|
|
214
283
|
return "";
|
|
@@ -216,7 +285,13 @@ export const recentConversation = (
|
|
|
216
285
|
const entries = ctx.sessionManager
|
|
217
286
|
.getBranch()
|
|
218
287
|
.map((entry) =>
|
|
219
|
-
conversationEntry(
|
|
288
|
+
conversationEntry(
|
|
289
|
+
entry,
|
|
290
|
+
toolResultMaxLines,
|
|
291
|
+
toolResultMaxBytes,
|
|
292
|
+
policies,
|
|
293
|
+
redact
|
|
294
|
+
)
|
|
220
295
|
)
|
|
221
296
|
.filter((entry): entry is string => entry !== undefined);
|
|
222
297
|
return selectRecentEntries(entries, maxChars);
|
package/src/tools.ts
CHANGED
|
@@ -72,6 +72,10 @@ export const resolveAdvisorRequest = (question?: string) =>
|
|
|
72
72
|
export const advisorMessageText = (conversation: string, question?: string) =>
|
|
73
73
|
`${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
|
|
74
74
|
|
|
75
|
+
/** The sole reconstructed-context boundary for outgoing Advisor requests. */
|
|
76
|
+
export const advisorRequestConversation = (ctx: ExtensionContext) =>
|
|
77
|
+
recentConversation(ctx, contextMaxCharsRef);
|
|
78
|
+
|
|
75
79
|
export const renderAdvisorCallBox = (
|
|
76
80
|
question: string | undefined,
|
|
77
81
|
theme: Theme
|
|
@@ -272,7 +276,7 @@ const collectAdvisorResponse = async (
|
|
|
272
276
|
throw new Error(`No API key for ${advisorRef}`);
|
|
273
277
|
}
|
|
274
278
|
|
|
275
|
-
const conversation =
|
|
279
|
+
const conversation = advisorRequestConversation(ctx);
|
|
276
280
|
const messages: Message[] = [
|
|
277
281
|
{
|
|
278
282
|
content: [
|
package/src/ui.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
matchesKey,
|
|
11
11
|
truncateToWidth,
|
|
12
12
|
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { isValidAdvisorToolPolicies } from "./config.js";
|
|
13
14
|
|
|
14
15
|
interface RenderRequester {
|
|
15
16
|
requestRender: () => void;
|
|
@@ -197,7 +198,9 @@ export interface AdvisorSettings {
|
|
|
197
198
|
loopThreshold?: number;
|
|
198
199
|
maxCallsPerSession?: number;
|
|
199
200
|
planGate: boolean;
|
|
201
|
+
redactSecrets?: boolean;
|
|
200
202
|
sessionSummary?: boolean;
|
|
203
|
+
toolPolicies?: Record<string, "full" | "summary" | "exclude">;
|
|
201
204
|
toolResultMaxBytes?: number;
|
|
202
205
|
toolResultMaxLines?: number;
|
|
203
206
|
}
|
|
@@ -208,7 +211,10 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
208
211
|
private effortIndex: number;
|
|
209
212
|
private readonly settings: AdvisorSettings;
|
|
210
213
|
private readonly customInput = new Input();
|
|
214
|
+
private readonly policiesInput = new Input();
|
|
211
215
|
private editingCustom: boolean;
|
|
216
|
+
private editingPolicies: boolean;
|
|
217
|
+
private policiesError: string | undefined;
|
|
212
218
|
private _focused = false;
|
|
213
219
|
private readonly options: AdvisorSettingsSelectorOptions;
|
|
214
220
|
|
|
@@ -218,11 +224,14 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
218
224
|
set focused(value: boolean) {
|
|
219
225
|
this._focused = value;
|
|
220
226
|
this.customInput.focused = value && this.editingCustom;
|
|
227
|
+
this.policiesInput.focused = value && this.editingPolicies;
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
constructor(options: AdvisorSettingsSelectorOptions) {
|
|
224
231
|
this.selectedRow = 0;
|
|
225
232
|
this.editingCustom = false;
|
|
233
|
+
this.editingPolicies = false;
|
|
234
|
+
this.policiesError = undefined;
|
|
226
235
|
this.options = options;
|
|
227
236
|
this.settings = { ...options.initial };
|
|
228
237
|
this.contextIndex = Math.max(
|
|
@@ -248,6 +257,32 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
248
257
|
this.customInput.focused = false;
|
|
249
258
|
this.options.tui.requestRender();
|
|
250
259
|
};
|
|
260
|
+
this.policiesInput.onSubmit = (value) => {
|
|
261
|
+
let parsed: unknown;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(value || "{}");
|
|
264
|
+
} catch {
|
|
265
|
+
this.policiesError = "Enter a valid JSON object.";
|
|
266
|
+
this.options.tui.requestRender();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!isValidAdvisorToolPolicies(parsed)) {
|
|
270
|
+
this.policiesError =
|
|
271
|
+
"Use non-empty tool names with full, summary, or exclude values.";
|
|
272
|
+
this.options.tui.requestRender();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.settings.toolPolicies = parsed;
|
|
276
|
+
this.policiesError = undefined;
|
|
277
|
+
this.editingPolicies = false;
|
|
278
|
+
this.policiesInput.focused = false;
|
|
279
|
+
this.options.tui.requestRender();
|
|
280
|
+
};
|
|
281
|
+
this.policiesInput.onEscape = () => {
|
|
282
|
+
this.editingPolicies = false;
|
|
283
|
+
this.policiesInput.focused = false;
|
|
284
|
+
this.options.tui.requestRender();
|
|
285
|
+
};
|
|
251
286
|
}
|
|
252
287
|
|
|
253
288
|
invalidate(): void {
|
|
@@ -278,6 +313,19 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
278
313
|
: theme.fg("text", text);
|
|
279
314
|
}
|
|
280
315
|
|
|
316
|
+
private policyInputRows(width: number): string[] {
|
|
317
|
+
if (!this.editingPolicies) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const rows = [
|
|
321
|
+
` ${this.policiesInput.render(Math.max(10, width - 6))[0] || ""}`,
|
|
322
|
+
];
|
|
323
|
+
if (this.policiesError) {
|
|
324
|
+
rows.push(` ${this.options.theme.fg("error", this.policiesError)}`);
|
|
325
|
+
}
|
|
326
|
+
return rows;
|
|
327
|
+
}
|
|
328
|
+
|
|
281
329
|
render(width: number): string[] {
|
|
282
330
|
const { theme, presets } = this.options;
|
|
283
331
|
const trackWidth = Math.max(24, Math.min(60, width - 4));
|
|
@@ -361,13 +409,26 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
361
409
|
String(this.settings.toolResultMaxBytes ?? 50 * 1024),
|
|
362
410
|
15
|
|
363
411
|
),
|
|
412
|
+
this.row(
|
|
413
|
+
"Redact common secrets",
|
|
414
|
+
onOff(this.settings.redactSecrets ?? false),
|
|
415
|
+
16
|
|
416
|
+
),
|
|
417
|
+
this.row(
|
|
418
|
+
"Tool disclosure policies",
|
|
419
|
+
Object.keys(this.settings.toolPolicies ?? {}).length
|
|
420
|
+
? "Exact names configured"
|
|
421
|
+
: "All tools: full",
|
|
422
|
+
17
|
|
423
|
+
),
|
|
364
424
|
];
|
|
365
425
|
if (this.editingCustom) {
|
|
366
426
|
rows.push(
|
|
367
427
|
` ${this.customInput.render(Math.max(10, width - 6))[0] || ""}`
|
|
368
428
|
);
|
|
369
429
|
}
|
|
370
|
-
rows.push(this.
|
|
430
|
+
rows.push(...this.policyInputRows(width));
|
|
431
|
+
rows.push(this.row("Save changes", "", 18));
|
|
371
432
|
return [
|
|
372
433
|
theme.fg("accent", theme.bold(" Advisor settings")),
|
|
373
434
|
"",
|
|
@@ -387,10 +448,14 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
387
448
|
this.customInput.handleInput(keyData);
|
|
388
449
|
return;
|
|
389
450
|
}
|
|
451
|
+
if (this.editingPolicies) {
|
|
452
|
+
this.policiesInput.handleInput(keyData);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
390
455
|
if (matchesKey(keyData, Key.up)) {
|
|
391
456
|
this.selectedRow = Math.max(0, this.selectedRow - 1);
|
|
392
457
|
} else if (matchesKey(keyData, Key.down)) {
|
|
393
|
-
this.selectedRow = Math.min(
|
|
458
|
+
this.selectedRow = Math.min(18, this.selectedRow + 1);
|
|
394
459
|
} else if (matchesKey(keyData, Key.left)) {
|
|
395
460
|
this.adjust(-1);
|
|
396
461
|
} else if (matchesKey(keyData, Key.right)) {
|
|
@@ -403,7 +468,17 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
403
468
|
tui.requestRender();
|
|
404
469
|
return;
|
|
405
470
|
}
|
|
406
|
-
if (this.selectedRow ===
|
|
471
|
+
if (this.selectedRow === 17) {
|
|
472
|
+
this.editingPolicies = true;
|
|
473
|
+
this.policiesError = undefined;
|
|
474
|
+
this.policiesInput.setValue(
|
|
475
|
+
JSON.stringify(this.settings.toolPolicies ?? {})
|
|
476
|
+
);
|
|
477
|
+
this.policiesInput.focused = this.focused;
|
|
478
|
+
tui.requestRender();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (this.selectedRow === 18) {
|
|
407
482
|
this.options.onSave({
|
|
408
483
|
...this.settings,
|
|
409
484
|
contextMaxChars: this.currentContext().value,
|
|
@@ -500,6 +575,9 @@ export class AdvisorSettingsSelector implements Component, Focusable {
|
|
|
500
575
|
this.settings.herdrIntegration ?? true
|
|
501
576
|
);
|
|
502
577
|
break;
|
|
578
|
+
case 16:
|
|
579
|
+
this.settings.redactSecrets = !(this.settings.redactSecrets ?? false);
|
|
580
|
+
break;
|
|
503
581
|
case 14: {
|
|
504
582
|
const values = [0, 500, 1000, 2000, 5000, 10_000];
|
|
505
583
|
const index = Math.max(
|