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 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 Flow Screenshot" width="600">
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
- An on-demand Advisor model flow for autonomous **Pi** coding agents.
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
- The primary agent (the Executor) acts, writes code, and executes tools. Whenever the Executor encounters high risk, ambiguity, or potential loops, it MUST escalate the scenario to a smarter, second-opinion LLM (the Advisor) for strategic guidance.
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
- ## Installation
16
+ [Read more about Advisors here](https://philipbrembeck.com/writings/2026/07/only-as-much-intelligence-as-you-need).
16
17
 
17
- Install the package directly into your global Pi agent environment:
18
+ ## Install
18
19
 
19
- ### From NPM
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
- ```bash
26
+ # GitHub
28
27
  pi install git:github.com/philipbrembeck/pi-advisor.git
29
- ```
30
28
 
31
- ### From Local Folder (For Development)
32
-
33
- ```bash
29
+ # local checkout, useful during development
34
30
  pi install /path/to/pi-advisor
35
31
  ```
36
32
 
37
- ## Usage & Commands
33
+ Restart or reload Pi after installation, then run `/advisor` in a session.
38
34
 
39
- Once installed, the following commands are available inside the Pi terminal:
35
+ ## Start using it
40
36
 
41
- ### `/advisor`
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
- Enables the Advisor flow. Switches the primary model to the configured Executor model and registers the `ask_advisor` tool.
41
+ Enable with models in one command when preferred:
44
42
 
45
- - _Example:_ `/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol`
46
- - _Context size:_ `/advisor contextMaxChars=30000` uses up to 30,000 characters of the reconstructed conversation for each consultation. `0` disables history; `Number.MAX_SAFE_INTEGER` represents the complete branch. Larger values increase request cost and can exceed the Advisor model's context window.
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
- ### `/advisor-manual [focus]`
49
+ ## Commands
49
50
 
50
- Starts an Advisor consultation in parallel without interrupting the Executor's active tool work. An optional `focus` is passed to the Advisor; when it completes, the advice is delivered to the Executor before its next model call. This works while the Executor is mid-turn.
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
- ### `/advisor-settings`
59
+ ### `ask_advisor`
53
60
 
54
- Opens a single keyboard-navigable settings screen. It includes a Claude Code-style context slider with `0`, `10k`, `25k`, `100k`, `200k`, and `ALL`; `0` sends no reconstructed history and `ALL` sends the complete current branch, subject to the Advisor model's context limit.
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
- It also configures Advisor reasoning effort, whether long Advisor responses collapse to a short preview (`Ctrl+O` expands them), and each built-in invocation gate independently (consequential plans, repeated failures, and completion review). It includes controls for critical-response blocking, the automatic loop gate and its repeat threshold, a per-session Advisor-call limit, and the local Session Advisor Summary. Response collapsing is off by default. You can add one custom natural-language invocation rule. Settings persist in `advisor.json`.
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
- ### `/advisor-models`
65
+ Normal consultations preserve the provider's final Markdown. They do not parse a verdict or block execution.
59
66
 
60
- Opens an interactive, scrollable fuzzy-search picker in the TUI to choose:
67
+ ## Automatic loop gate
61
68
 
62
- 1. Executor Model & Reasoning Effort
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
- Saves and persists your configuration to `~/.pi/agent/advisor.json`.
71
+ Unlike ordinary consultations, a loop-gate reply must start with exactly one decision header:
66
72
 
67
- ### `ask_advisor`
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
- The Executor can call `ask_advisor` with an empty object for a general review of the current task and conversation, or provide `question` for targeted feedback. The Advisor is a brief second opinion: the Executor investigates and forms its own candidate direction first, then uses the Advisor to challenge assumptions and validate a consequential next step. It should not delegate the entire plan or task.
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
- Normal Advisor consultations return the provider's final Markdown unchanged. They never parse JSON, synthesize a verdict, or block Executor work. Only automatic loop gates use machine-readable decisions: the first non-empty line must be `Decision: proceed`, `Decision: revise`, or `Decision: blocked`; malformed, missing, duplicate, or contradictory decisions are explicit gate failures.
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
- ### Automatic loop gate
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
- When enabled, the loop gate consults the Advisor after the configured number of consecutive calls with the same normalized tool signature (default: three). A `proceed` decision resets the repetition counter and allows the call. `revise` blocks only the repeated tool action; `blocked` can block the session according to the configured policy. Gate failures default to `block-session` and may be changed to `block-tool` or `warn-and-continue`. Normalization is allowlisted: object keys are deterministic, arrays retain order, and only volatile IDs/timestamps, temporary paths, and safe shell whitespace are normalized.
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
- Every manual, Executor-requested, and automatic Advisor invocation consumes one shared session budget. The default is unlimited; a finite budget appears as used/remaining in the Executor instructions and local summary. The optional Session Advisor Summary is local, in-memory only, and appears after a non-blocked settled run; it is never persisted or sent to Herdr. It separates Markdown advice from gate decisions and records trigger, model, usage/cost when available, failure, budget, and execution effect.
102
+ ## Settings and configuration
78
103
 
79
- ### Context configuration
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
- The selected configuration is saved as `advisor.json` in the Pi agent directory (or an existing trusted project configuration). `/advisor-models` and `/advisor-settings` share this file:
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
- "advisorBlockOnBlocked": true,
120
+ "advisorCollapseResponses": false,
121
+
96
122
  "advisorAutoLoopGate": true,
97
123
  "advisorLoopThreshold": 3,
98
124
  "advisorMaxCallsPerSession": 5,
99
- "advisorSessionSummary": true,
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
- All fields are optional. `executor`, `advisor`, and their effort settings are managed by `/advisor-models`. `/advisor-settings` manages `advisorEffort`, `contextMaxChars`, the three invocation-gate booleans, `advisorCollapseResponses`, `advisorCustomInvocation`, `advisorBlockOnBlocked`, `advisorAutoLoopGate`, `advisorLoopThreshold`, `advisorMaxCallsPerSession`, `advisorSessionSummary`, `gateFailureMode`, `advisorHerdrIntegration`, and the tool-result line/byte limits.
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
- `contextMaxChars` is a soft character budget: it preserves complete semantic entries and adds an older-context omission marker rather than starting mid-message. Its default is 15,000, `0` omits history, and `9007199254740991` means ALL. Oversized tool results default to Pi's 2,000-line/50 KiB limits and preserve beginning/end sections with an omission marker; `advisorToolResultMaxLines` and `advisorToolResultMaxBytes` override them. `advisorLoopThreshold` must be at least `2` and defaults to `3`. Omit `advisorMaxCallsPerSession` for an unlimited shared budget; otherwise it must be a non-negative safe integer. `gateFailureMode` accepts `block-session`, `block-tool`, or `warn-and-continue`, defaulting to `block-session`. Herdr integration and the notification/activity/blocked metadata paths default to enabled; failure notifications use sanitized `notification.show` requests and can be disabled with `advisorHerdrIntegration`. Critical blocking, the automatic loop gate, and the Session Advisor Summary default to `true`. Unknown or invalid configuration keys fail at startup with the file, key, accepted values, and remediation; save operations preserve unknown fields.
149
+ ### Privacy controls
110
150
 
111
- ### `/advisor-off`
151
+ Advisor context can contain user messages, tool calls, and tool results. Configure disclosure deliberately:
112
152
 
113
- Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
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
- ## Publishing releases
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
- CI manages `vX.Y.Z` release tags from the version in `package.json`; contributors must not create or push release tags manually. The release workflow verifies the version, type-checks, tests, and then publishes:
162
+ ### Session summary and Herdr
118
163
 
119
- - `pi-advisor-flow` to [npm](https://www.npmjs.com/package/pi-advisor-flow)
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
- ## Local Development
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
- `pi-advisor` uses Bun for rapid testing and TypeScript. Standard commands apply:
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
- ### 1. Clone the repository
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
- ### 3. Run type-checks & tests
177
+ bun test
178
+ bun run typecheck
179
+ bun run lint
180
+ ```
140
181
 
141
- Verify code-splitting correctness and registration logic:
182
+ ## Links
142
183
 
143
- ```bash
144
- bun test # Run unit tests
145
- bun run typecheck # Perform strict TS checks
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
@@ -32,7 +32,7 @@
32
32
  "*.{json,jsonc,ts}": "bun run lint:fix --"
33
33
  },
34
34
  "type": "module",
35
- "version": "0.2.2",
35
+ "version": "0.2.3",
36
36
  "license": "MIT",
37
37
  "repository": {
38
38
  "type": "git",
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,
@@ -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 = (message: RecordValue): string | undefined => {
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: ${String(part.name)}(${JSON.stringify(part.arguments)})]`
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 ? "Error " : "";
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
- textFrom(message.content),
210
+ disclosed,
150
211
  toolResultMaxLines,
151
212
  toolResultMaxBytes
152
213
  );
153
- const toolName =
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(message, toolResultMaxLines, toolResultMaxBytes);
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(entry, toolResultMaxLines, toolResultMaxBytes)
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 = recentConversation(ctx, contextMaxCharsRef);
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.row("Save changes", "", 16));
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(16, this.selectedRow + 1);
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 === 16) {
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(