pi-advisor-flow 0.1.4 → 0.1.6
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 +31 -5
- package/package.json +2 -2
- package/src/commands.ts +116 -5
- package/src/config.ts +68 -10
- package/src/conversation.ts +1 -0
- package/src/tools.ts +55 -28
- package/src/ui.ts +143 -1
package/README.md
CHANGED
|
@@ -43,7 +43,17 @@ Once installed, the following commands are available inside the Pi terminal:
|
|
|
43
43
|
Enables the Advisor flow. Switches the primary model to the configured Executor model and registers the `ask_advisor` tool.
|
|
44
44
|
|
|
45
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.
|
|
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.
|
|
47
|
+
|
|
48
|
+
### `/advisor-manual [focus]`
|
|
49
|
+
|
|
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
|
+
|
|
52
|
+
### `/advisor-settings`
|
|
53
|
+
|
|
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.
|
|
55
|
+
|
|
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). Response collapsing is off by default. You can add one custom natural-language invocation rule. Settings persist in `advisor.json`.
|
|
47
57
|
|
|
48
58
|
### `/advisor-models`
|
|
49
59
|
|
|
@@ -60,20 +70,36 @@ The Executor can call `ask_advisor` with an empty object for a general review of
|
|
|
60
70
|
|
|
61
71
|
### Context configuration
|
|
62
72
|
|
|
63
|
-
The selected configuration is saved as `advisor.json` in the Pi agent directory (or an existing trusted project configuration).
|
|
73
|
+
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:
|
|
64
74
|
|
|
65
75
|
```json
|
|
66
76
|
{
|
|
67
|
-
"
|
|
77
|
+
"executor": "aikeys/claude-sonnet-5",
|
|
78
|
+
"advisor": "aikeys/claude-fable-5",
|
|
79
|
+
"executorEffort": "high",
|
|
80
|
+
"advisorEffort": "high",
|
|
81
|
+
"contextMaxChars": 25000,
|
|
82
|
+
"advisorPlanGate": true,
|
|
83
|
+
"advisorFailureGate": true,
|
|
84
|
+
"advisorCompletionGate": true,
|
|
85
|
+
"advisorCollapseResponses": false,
|
|
86
|
+
"advisorCustomInvocation": "before changing a production deployment"
|
|
68
87
|
}
|
|
69
88
|
```
|
|
70
89
|
|
|
71
|
-
`contextMaxChars` must be a
|
|
90
|
+
All fields are optional. `executor`, `advisor`, and their effort settings are managed by `/advisor-models`. `/advisor-settings` manages `advisorEffort`, `contextMaxChars`, the three gate booleans, `advisorCollapseResponses`, and `advisorCustomInvocation`. `contextMaxChars` must be a non-negative safe integer: its default is 15,000, `0` omits history, and `9007199254740991` means ALL.
|
|
72
91
|
|
|
73
92
|
### `/advisor-off`
|
|
74
93
|
|
|
75
94
|
Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
|
|
76
95
|
|
|
96
|
+
## Publishing releases
|
|
97
|
+
|
|
98
|
+
Pushing a version tag (`vX.Y.Z`) runs the release workflow. It verifies that the tag matches `package.json`, type-checks, tests, then publishes:
|
|
99
|
+
|
|
100
|
+
- `pi-advisor-flow` to [npm](https://www.npmjs.com/package/pi-advisor-flow)
|
|
101
|
+
- `@philipbrembeck/pi-advisor-flow` to GitHub Packages, which makes the package appear in this repository’s **Packages** sidebar
|
|
102
|
+
|
|
77
103
|
## Local Development
|
|
78
104
|
|
|
79
105
|
`pi-advisor` uses Bun for rapid testing and TypeScript. Standard commands apply:
|
|
@@ -97,5 +123,5 @@ Verify code-splitting correctness and registration logic:
|
|
|
97
123
|
|
|
98
124
|
```bash
|
|
99
125
|
bun test # Run unit tests
|
|
100
|
-
|
|
126
|
+
bun run typecheck # Perform strict TS checks
|
|
101
127
|
```
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-advisor-flow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "
|
|
7
|
+
"url": "https://github.com/philipbrembeck/pi-advisor.git"
|
|
8
8
|
},
|
|
9
9
|
"homepage": "https://github.com/philipbrembeck/pi-advisor",
|
|
10
10
|
"author": "Philip Brembeck",
|
package/src/commands.ts
CHANGED
|
@@ -1,15 +1,86 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { getMarkdownTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
|
|
2
3
|
import {
|
|
4
|
+
advisorCollapseResponsesRef, advisorCompletionGateRef, advisorCustomInvocationRef, advisorFailureGateRef, advisorPlanGateRef,
|
|
3
5
|
advisorRef, advisorEffortRef, executorRef, executorEffortRef,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
setAdvisorCollapseResponsesRef, setAdvisorCompletionGateRef, setAdvisorCustomInvocationRef, setAdvisorFailureGateRef, setAdvisorPlanGateRef,
|
|
7
|
+
setAdvisorRef, setAdvisorEffortRef, setContextMaxCharsRef, setExecutorRef, setExecutorEffortRef,
|
|
8
|
+
contextMaxCharsRef, loadConfig, saveConfig, parseArgs, splitRef,
|
|
6
9
|
} from "./config.js";
|
|
7
|
-
import { SearchableModelSelector } from "./ui.js";
|
|
10
|
+
import { AdvisorSettingsSelector, SearchableModelSelector, type AdvisorSettings, type ContextPreset } from "./ui.js";
|
|
11
|
+
import { adviceForDisplay, consult, renderAdvisorCallBox, resolveAdvisorRequest } from "./tools.js";
|
|
8
12
|
|
|
9
13
|
const EFFORT_LEVELS = ["Default (Model Default)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
const CONTEXT_PRESETS: ContextPreset[] = [
|
|
16
|
+
{ label: "0", value: 0, description: "No conversation history. The Advisor receives only its standing instructions." },
|
|
17
|
+
{ label: "10k", value: 10_000, description: "The most recent 10,000 characters of the current branch." },
|
|
18
|
+
{ label: "25k", value: 25_000, description: "The most recent 25,000 characters of the current branch." },
|
|
19
|
+
{ label: "100k", value: 100_000, description: "The most recent 100,000 characters of the current branch." },
|
|
20
|
+
{ label: "200k", value: 200_000, description: "The most recent 200,000 characters of the current branch." },
|
|
21
|
+
{ label: "ALL", value: Number.MAX_SAFE_INTEGER, description: "The complete reconstructed conversation branch. Cost and model context limits apply." },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const registerCommands = (pi: ExtensionAPI, dependencies: { consult?: typeof consult } = {}) => {
|
|
12
25
|
const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
|
|
26
|
+
const requestAdvisor = dependencies.consult ?? consult;
|
|
27
|
+
const manualConsultations = new Set<AbortController>();
|
|
28
|
+
|
|
29
|
+
pi.registerEntryRenderer?.("advisor-manual-call", (entry, _options, theme) => {
|
|
30
|
+
const { question } = (entry.data ?? {}) as { question?: string };
|
|
31
|
+
return renderAdvisorCallBox(question, theme);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.registerMessageRenderer?.("advisor-manual-result", (message, { expanded }, theme) => {
|
|
35
|
+
const details = message.details as { advisor?: string; text?: string } | undefined;
|
|
36
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
37
|
+
box.addChild(new Text(theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE")), 0, 0));
|
|
38
|
+
if (details?.advisor) box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
|
|
39
|
+
const advice = details?.text ?? (typeof message.content === "string" ? message.content : "(Advisor returned no advice.)");
|
|
40
|
+
box.addChild(new Markdown(adviceForDisplay(advice, expanded), 0, 0, getMarkdownTheme()));
|
|
41
|
+
return box;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.on("session_shutdown", () => {
|
|
45
|
+
for (const controller of manualConsultations) controller.abort();
|
|
46
|
+
manualConsultations.clear();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.registerCommand("advisor-manual", {
|
|
50
|
+
description: "Consult the Advisor in parallel; accepts an optional focused question and fans its response out to the Executor",
|
|
51
|
+
handler: async (args, ctx) => {
|
|
52
|
+
const question = resolveAdvisorRequest(args);
|
|
53
|
+
// A single visible progress surface avoids competing consultations overwriting
|
|
54
|
+
// each other's streamed state. A newer manual request replaces the previous one.
|
|
55
|
+
for (const pending of manualConsultations) pending.abort();
|
|
56
|
+
manualConsultations.clear();
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
manualConsultations.add(controller);
|
|
59
|
+
pi.appendEntry?.("advisor-manual-call", { question });
|
|
60
|
+
|
|
61
|
+
void requestAdvisor(ctx, question, controller.signal)
|
|
62
|
+
.then(({ advice }) => {
|
|
63
|
+
if (controller.signal.aborted) return;
|
|
64
|
+
pi.sendMessage({
|
|
65
|
+
customType: "advisor-manual-result",
|
|
66
|
+
content: `Manual Advisor consultation${question ? ` (${question})` : ""}:\n\n${advice}`,
|
|
67
|
+
display: true,
|
|
68
|
+
details: { advisor: advisorRef, question, text: advice },
|
|
69
|
+
}, {
|
|
70
|
+
// Steer lets the current turn finish its active work; the Executor sees
|
|
71
|
+
// the result before its next model call rather than being interrupted.
|
|
72
|
+
deliverAs: "steer",
|
|
73
|
+
triggerTurn: true,
|
|
74
|
+
});
|
|
75
|
+
})
|
|
76
|
+
.catch((error: unknown) => {
|
|
77
|
+
if (controller.signal.aborted) return;
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
if (ctx.hasUI) ctx.ui.notify(`Advisor consultation failed: ${message}`, "error");
|
|
80
|
+
})
|
|
81
|
+
.finally(() => manualConsultations.delete(controller));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
13
84
|
|
|
14
85
|
pi.registerCommand("advisor", {
|
|
15
86
|
description: "Enable the Executor/Advisor flow and switch to the configured Executor model; accepts contextMaxChars=N",
|
|
@@ -64,6 +135,46 @@ export const registerCommands = (pi: ExtensionAPI) => {
|
|
|
64
135
|
},
|
|
65
136
|
});
|
|
66
137
|
|
|
138
|
+
pi.registerCommand("advisor-settings", {
|
|
139
|
+
description: "Configure Advisor context and reasoning effort",
|
|
140
|
+
handler: async (_args, ctx) => {
|
|
141
|
+
loadConfig(ctx);
|
|
142
|
+
if (!ctx.hasUI) return;
|
|
143
|
+
|
|
144
|
+
const initial: AdvisorSettings = {
|
|
145
|
+
contextMaxChars: contextMaxCharsRef,
|
|
146
|
+
effort: advisorEffortRef,
|
|
147
|
+
planGate: advisorPlanGateRef,
|
|
148
|
+
failureGate: advisorFailureGateRef,
|
|
149
|
+
completionGate: advisorCompletionGateRef,
|
|
150
|
+
collapseResponses: advisorCollapseResponsesRef,
|
|
151
|
+
customRule: advisorCustomInvocationRef,
|
|
152
|
+
};
|
|
153
|
+
const settings = await ctx.ui.custom<AdvisorSettings | undefined>((tui, theme, _keybindings, done) =>
|
|
154
|
+
new AdvisorSettingsSelector({
|
|
155
|
+
tui,
|
|
156
|
+
theme,
|
|
157
|
+
presets: CONTEXT_PRESETS,
|
|
158
|
+
effortLevels: EFFORT_LEVELS,
|
|
159
|
+
initial,
|
|
160
|
+
onSave: done,
|
|
161
|
+
onCancel: () => done(undefined),
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
if (!settings) return;
|
|
165
|
+
|
|
166
|
+
setAdvisorEffortRef(settings.effort === "Default (Model Default)" ? undefined : settings.effort);
|
|
167
|
+
setContextMaxCharsRef(settings.contextMaxChars);
|
|
168
|
+
setAdvisorPlanGateRef(settings.planGate);
|
|
169
|
+
setAdvisorFailureGateRef(settings.failureGate);
|
|
170
|
+
setAdvisorCompletionGateRef(settings.completionGate);
|
|
171
|
+
setAdvisorCollapseResponsesRef(settings.collapseResponses);
|
|
172
|
+
setAdvisorCustomInvocationRef(settings.customRule);
|
|
173
|
+
const path = saveConfig(ctx);
|
|
174
|
+
ctx.ui.notify(`Saved Advisor settings to ${path}`, "info");
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
67
178
|
pi.registerCommand("advisor-off", {
|
|
68
179
|
description: "Disable on-demand Advisor calls; keep the current model",
|
|
69
180
|
handler: async (_args, ctx) => {
|
package/src/config.ts
CHANGED
|
@@ -5,21 +5,32 @@ import { CONFIG_DIR_NAME, type ExtensionContext, getAgentDir } from "@earendil-w
|
|
|
5
5
|
export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
|
|
6
6
|
export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
|
|
7
7
|
export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
|
|
8
|
-
|
|
8
|
+
// MAX_SAFE_INTEGER represents the complete reconstructed branch (the ALL preset).
|
|
9
|
+
export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
|
|
9
10
|
|
|
10
11
|
export let executorRef = FALLBACK_EXECUTOR;
|
|
11
12
|
export let advisorRef = FALLBACK_ADVISOR;
|
|
12
13
|
export let executorEffortRef: string | undefined = undefined;
|
|
13
14
|
export let advisorEffortRef: string | undefined = undefined;
|
|
14
15
|
export let contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
|
|
16
|
+
export let advisorPlanGateRef = true;
|
|
17
|
+
export let advisorFailureGateRef = true;
|
|
18
|
+
export let advisorCompletionGateRef = true;
|
|
19
|
+
export let advisorCustomInvocationRef: string | undefined = undefined;
|
|
20
|
+
export let advisorCollapseResponsesRef = false;
|
|
15
21
|
|
|
16
22
|
export const setExecutorRef = (ref: string) => { executorRef = ref; };
|
|
17
23
|
export const setAdvisorRef = (ref: string) => { advisorRef = ref; };
|
|
18
24
|
export const setExecutorEffortRef = (effort: string | undefined) => { executorEffortRef = effort; };
|
|
19
25
|
export const setAdvisorEffortRef = (effort: string | undefined) => { advisorEffortRef = effort; };
|
|
20
26
|
export const isValidContextMaxChars = (value: unknown): value is number =>
|
|
21
|
-
typeof value === "number" && Number.isSafeInteger(value) && value
|
|
27
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_CONTEXT_MAX_CHARS;
|
|
22
28
|
export const setContextMaxCharsRef = (value: number) => { contextMaxCharsRef = value; };
|
|
29
|
+
export const setAdvisorPlanGateRef = (enabled: boolean) => { advisorPlanGateRef = enabled; };
|
|
30
|
+
export const setAdvisorFailureGateRef = (enabled: boolean) => { advisorFailureGateRef = enabled; };
|
|
31
|
+
export const setAdvisorCompletionGateRef = (enabled: boolean) => { advisorCompletionGateRef = enabled; };
|
|
32
|
+
export const setAdvisorCustomInvocationRef = (rule: string | undefined) => { advisorCustomInvocationRef = rule?.trim() || undefined; };
|
|
33
|
+
export const setAdvisorCollapseResponsesRef = (enabled: boolean) => { advisorCollapseResponsesRef = enabled; };
|
|
23
34
|
|
|
24
35
|
export const splitRef = (ref: string): [string, string] => {
|
|
25
36
|
const i = ref.indexOf("/");
|
|
@@ -31,27 +42,60 @@ export const configPaths = (ctx: ExtensionContext) => [
|
|
|
31
42
|
join(getAgentDir(), "advisor.json"),
|
|
32
43
|
];
|
|
33
44
|
|
|
45
|
+
type AdvisorConfig = {
|
|
46
|
+
executor?: string;
|
|
47
|
+
advisor?: string;
|
|
48
|
+
executorEffort?: string;
|
|
49
|
+
advisorEffort?: string;
|
|
50
|
+
contextMaxChars?: number;
|
|
51
|
+
advisorPlanGate?: boolean;
|
|
52
|
+
advisorFailureGate?: boolean;
|
|
53
|
+
advisorCompletionGate?: boolean;
|
|
54
|
+
advisorCustomInvocation?: string;
|
|
55
|
+
advisorCollapseResponses?: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isValidConfig = (value: unknown): value is AdvisorConfig => {
|
|
59
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
60
|
+
const config = value as Record<string, unknown>;
|
|
61
|
+
return (config.executor === undefined || typeof config.executor === "string")
|
|
62
|
+
&& (config.advisor === undefined || typeof config.advisor === "string")
|
|
63
|
+
&& (config.executorEffort === undefined || typeof config.executorEffort === "string")
|
|
64
|
+
&& (config.advisorEffort === undefined || typeof config.advisorEffort === "string")
|
|
65
|
+
&& (config.contextMaxChars === undefined || isValidContextMaxChars(config.contextMaxChars))
|
|
66
|
+
&& (config.advisorPlanGate === undefined || typeof config.advisorPlanGate === "boolean")
|
|
67
|
+
&& (config.advisorFailureGate === undefined || typeof config.advisorFailureGate === "boolean")
|
|
68
|
+
&& (config.advisorCompletionGate === undefined || typeof config.advisorCompletionGate === "boolean")
|
|
69
|
+
&& (config.advisorCustomInvocation === undefined || typeof config.advisorCustomInvocation === "string")
|
|
70
|
+
&& (config.advisorCollapseResponses === undefined || typeof config.advisorCollapseResponses === "boolean");
|
|
71
|
+
};
|
|
72
|
+
|
|
34
73
|
export const loadConfig = (ctx: ExtensionContext) => {
|
|
35
74
|
executorRef = FALLBACK_EXECUTOR;
|
|
36
75
|
advisorRef = FALLBACK_ADVISOR;
|
|
37
76
|
executorEffortRef = undefined;
|
|
38
77
|
advisorEffortRef = undefined;
|
|
39
78
|
contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
|
|
79
|
+
advisorPlanGateRef = true;
|
|
80
|
+
advisorFailureGateRef = true;
|
|
81
|
+
advisorCompletionGateRef = true;
|
|
82
|
+
advisorCustomInvocationRef = undefined;
|
|
83
|
+
advisorCollapseResponsesRef = false;
|
|
40
84
|
for (const path of configPaths(ctx)) {
|
|
41
85
|
if (!path || !existsSync(path)) continue;
|
|
42
86
|
try {
|
|
43
|
-
const config = JSON.parse(readFileSync(path, "utf8"))
|
|
44
|
-
|
|
45
|
-
advisor?: string;
|
|
46
|
-
executorEffort?: string;
|
|
47
|
-
advisorEffort?: string;
|
|
48
|
-
contextMaxChars?: number;
|
|
49
|
-
};
|
|
87
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
if (!isValidConfig(config)) throw new TypeError("Invalid advisor configuration");
|
|
50
89
|
if (config.executor) executorRef = config.executor;
|
|
51
90
|
if (config.advisor) advisorRef = config.advisor;
|
|
52
91
|
if (config.executorEffort) executorEffortRef = config.executorEffort;
|
|
53
92
|
if (config.advisorEffort) advisorEffortRef = config.advisorEffort;
|
|
54
93
|
if (isValidContextMaxChars(config.contextMaxChars)) contextMaxCharsRef = config.contextMaxChars;
|
|
94
|
+
if (typeof config.advisorPlanGate === "boolean") advisorPlanGateRef = config.advisorPlanGate;
|
|
95
|
+
if (typeof config.advisorFailureGate === "boolean") advisorFailureGateRef = config.advisorFailureGate;
|
|
96
|
+
if (typeof config.advisorCompletionGate === "boolean") advisorCompletionGateRef = config.advisorCompletionGate;
|
|
97
|
+
if (typeof config.advisorCustomInvocation === "string") advisorCustomInvocationRef = config.advisorCustomInvocation || undefined;
|
|
98
|
+
if (typeof config.advisorCollapseResponses === "boolean") advisorCollapseResponsesRef = config.advisorCollapseResponses;
|
|
55
99
|
return path;
|
|
56
100
|
} catch {
|
|
57
101
|
// Ignore malformed config and keep looking for a valid fallback.
|
|
@@ -63,12 +107,26 @@ export const loadConfig = (ctx: ExtensionContext) => {
|
|
|
63
107
|
export const saveConfig = (ctx: ExtensionContext) => {
|
|
64
108
|
const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
|
|
65
109
|
const path = ctx.isProjectTrusted() && existsSync(project) ? project : join(getAgentDir(), "advisor.json");
|
|
110
|
+
// Preserve settings owned by future versions or other tools that share this file.
|
|
111
|
+
let existing: Record<string, unknown> = {};
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
114
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record<string, unknown>;
|
|
115
|
+
} catch {
|
|
116
|
+
// A missing or malformed file is safely replaced with the current settings.
|
|
117
|
+
}
|
|
66
118
|
const data = {
|
|
119
|
+
...existing,
|
|
67
120
|
executor: executorRef,
|
|
68
121
|
advisor: advisorRef,
|
|
69
122
|
executorEffort: executorEffortRef,
|
|
70
123
|
advisorEffort: advisorEffortRef,
|
|
71
124
|
contextMaxChars: contextMaxCharsRef,
|
|
125
|
+
advisorPlanGate: advisorPlanGateRef,
|
|
126
|
+
advisorFailureGate: advisorFailureGateRef,
|
|
127
|
+
advisorCompletionGate: advisorCompletionGateRef,
|
|
128
|
+
advisorCustomInvocation: advisorCustomInvocationRef,
|
|
129
|
+
advisorCollapseResponses: advisorCollapseResponsesRef,
|
|
72
130
|
};
|
|
73
131
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
74
132
|
return path;
|
|
@@ -86,7 +144,7 @@ export const parseArgs = (args: string): string | undefined => {
|
|
|
86
144
|
if (key === "contextMaxChars") {
|
|
87
145
|
const parsed = Number(value);
|
|
88
146
|
if (!isValidContextMaxChars(parsed)) {
|
|
89
|
-
return `contextMaxChars must be a
|
|
147
|
+
return `contextMaxChars must be a non-negative integer no greater than ${MAX_CONTEXT_MAX_CHARS}.`;
|
|
90
148
|
}
|
|
91
149
|
nextContextMaxChars = parsed;
|
|
92
150
|
}
|
package/src/conversation.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const textFrom = (content: unknown): string => {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export const recentConversation = (ctx: ExtensionContext, maxChars = 15000): string => {
|
|
17
|
+
if (maxChars === 0) return "";
|
|
17
18
|
const entries: string[] = [];
|
|
18
19
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
19
20
|
if (entry.type === "message") {
|
package/src/tools.ts
CHANGED
|
@@ -2,23 +2,55 @@ import { stream, type Message, type AssistantMessage } from "@earendil-works/pi-
|
|
|
2
2
|
import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
advisorCollapseResponsesRef, advisorCompletionGateRef, advisorCustomInvocationRef, advisorFailureGateRef, advisorPlanGateRef,
|
|
7
|
+
advisorRef, advisorEffortRef, contextMaxCharsRef, loadConfig, splitRef,
|
|
8
|
+
} from "./config.js";
|
|
6
9
|
import { recentConversation, textFrom } from "./conversation.js";
|
|
7
10
|
|
|
8
11
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
-
export const
|
|
12
|
+
export const resolveAdvisorRequest = (question?: string) => question?.trim() || undefined;
|
|
13
|
+
export const advisorMessageText = (conversation: string, question?: string) =>
|
|
14
|
+
`${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
|
|
10
15
|
|
|
11
|
-
export const
|
|
16
|
+
export const renderAdvisorCallBox = (question: string | undefined, theme: any) => {
|
|
17
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
18
|
+
const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
|
|
19
|
+
const title = theme.fg("customMessageText", "Executor → Advisor");
|
|
20
|
+
box.addChild(new Text(question ? `${label} ${title}\n${theme.fg("dim", ` ${question}`)}` : `${label} ${title}`, 0, 0));
|
|
21
|
+
return box;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const COLLAPSED_ADVICE_LINES = 12;
|
|
25
|
+
|
|
26
|
+
export const adviceForDisplay = (advice: string, expanded: boolean) => {
|
|
27
|
+
if (!advisorCollapseResponsesRef || expanded) return advice;
|
|
28
|
+
const lines = advice.split("\n");
|
|
29
|
+
if (lines.length <= COLLAPSED_ADVICE_LINES) return advice;
|
|
30
|
+
return `${lines.slice(0, COLLAPSED_ADVICE_LINES).join("\n")}\n\n… (${lines.length - COLLAPSED_ADVICE_LINES} more lines, Ctrl+O to expand)`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const advisorInvocationGuidelines = () => {
|
|
34
|
+
const guidelines: string[] = [];
|
|
35
|
+
if (advisorPlanGateRef) guidelines.push("Before committing to a materially consequential plan, use ask_advisor after investigating and forming your own candidate direction. Use it to stress-test consequential architectural, security, data-loss, compatibility, or difficult-to-reverse decisions. Do not delegate the entire plan or task.");
|
|
36
|
+
if (advisorFailureGateRef) guidelines.push("Use ask_advisor after two consecutive materially equivalent failed attempts, when a fix recreates an earlier failure, or after two actions produce no measurable progress. Do not make another materially equivalent attempt before consulting.");
|
|
37
|
+
if (advisorCompletionGateRef) guidelines.push("Before declaring success, use ask_advisor to review the goal, changed files, key decisions, tests, results, and remaining risks. Skip this only for demonstrably trivial, low-risk work.");
|
|
38
|
+
if (advisorCustomInvocationRef) guidelines.push(`Also use ask_advisor when: ${advisorCustomInvocationRef}`);
|
|
39
|
+
if (guidelines.length > 0) guidelines.push("Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives context. Include question only for a genuinely specific assumption or trade-off.");
|
|
40
|
+
return guidelines;
|
|
41
|
+
};
|
|
12
42
|
|
|
13
43
|
export const ADVISOR_SYSTEM = [
|
|
14
44
|
"You are the Advisor: a senior engineer giving a brief second opinion to an autonomous coding agent.",
|
|
15
|
-
"You
|
|
16
|
-
"
|
|
45
|
+
"You already have the relevant reconstructed conversation context. No question or other input from the Executor is needed for a general review.",
|
|
46
|
+
"When no targeted focus is supplied, proactively review the task, risks, proposed direction, and validation from the context. Do not ask the Executor for a question, clarification, more input, or confirmation.",
|
|
47
|
+
"The context may be truncated, so state any material uncertainty and make the best recommendation you can from what is present.",
|
|
48
|
+
"You do not act or take over planning. Identify risks, challenge assumptions, and recommend the smallest correct next step. No preamble.",
|
|
17
49
|
].join(" ");
|
|
18
50
|
|
|
19
51
|
export const consult = async (
|
|
20
52
|
ctx: ExtensionContext,
|
|
21
|
-
question
|
|
53
|
+
question?: string,
|
|
22
54
|
signal?: AbortSignal,
|
|
23
55
|
onChunk?: (thinking: string, text: string) => void,
|
|
24
56
|
) => {
|
|
@@ -33,7 +65,7 @@ export const consult = async (
|
|
|
33
65
|
const conversation = recentConversation(ctx, contextMaxCharsRef);
|
|
34
66
|
const messages: Message[] = [{
|
|
35
67
|
role: "user",
|
|
36
|
-
content: [{ type: "text", text:
|
|
68
|
+
content: [{ type: "text", text: advisorMessageText(conversation, question) }],
|
|
37
69
|
timestamp: Date.now(),
|
|
38
70
|
}];
|
|
39
71
|
|
|
@@ -71,32 +103,27 @@ export const consult = async (
|
|
|
71
103
|
};
|
|
72
104
|
|
|
73
105
|
export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
106
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
107
|
+
if (!pi.getActiveTools().includes("ask_advisor")) return;
|
|
108
|
+
loadConfig(ctx);
|
|
109
|
+
const guidelines = advisorInvocationGuidelines();
|
|
110
|
+
return guidelines.length > 0 ? { systemPrompt: `${ctx.getSystemPrompt()}\n\nAdvisor invocation settings:\n${guidelines.map((rule) => `- ${rule}`).join("\n")}` } : undefined;
|
|
111
|
+
});
|
|
112
|
+
|
|
74
113
|
pi.registerTool({
|
|
75
114
|
name: "ask_advisor",
|
|
76
115
|
label: "Ask Advisor",
|
|
77
|
-
description: "Consult the on-demand Advisor model for strategic guidance. Call with
|
|
78
|
-
promptSnippet: "Consult the Advisor
|
|
116
|
+
description: "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a context-aware review; add question only for a genuinely targeted focus.",
|
|
117
|
+
promptSnippet: "Consult the Advisor using its existing context; omit question unless a specific focus is necessary",
|
|
79
118
|
promptGuidelines: [
|
|
80
|
-
"
|
|
81
|
-
"1. PLAN GATE: Before committing to a materially consequential plan, first investigate and form your own candidate direction. Then call `ask_advisor` to stress-test the decision when multiple approaches exist, requirements are ambiguous, or the decision has architectural, security, data-loss, compatibility, or difficult-to-reverse consequences. Do not delegate the whole plan or task to the Advisor.",
|
|
82
|
-
"2. FAILURE GATE: You MUST call `ask_advisor` after two consecutive materially equivalent failed attempts, when an attempted fix recreates an earlier failure, or when two consecutive actions produce no measurable progress. Do NOT attempt another materially equivalent fix before consulting.",
|
|
83
|
-
"3. COMPLETION GATE: You MUST call `ask_advisor` with the goal, changed files, key decisions, tests performed, results, and remaining risks BEFORE declaring success or calling `goal_complete`. You MAY skip this only for demonstrably trivial, low-risk work with no meaningful trade-offs or failures.",
|
|
84
|
-
"Call `ask_advisor` with an empty object for a general review of the current task and conversation. Provide `question` when you want the Advisor to assess a specific assumption, trade-off, or proposed next step.",
|
|
85
|
-
"Do NOT use `ask_advisor` for routine decisions outside these three gates.",
|
|
119
|
+
"Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives the context. Include question only for a genuinely specific assumption or trade-off.",
|
|
86
120
|
],
|
|
87
|
-
parameters: Type.Object({ question: Type.Optional(Type.String({ description: "The specific question or decision to get advice on. Omit for
|
|
121
|
+
parameters: Type.Object({ question: Type.Optional(Type.String({ description: "The specific question or decision to get advice on. Omit this for normal reviews: the Advisor already has the conversation context." })) }),
|
|
88
122
|
renderShell: "self",
|
|
89
|
-
renderCall(args, theme,
|
|
90
|
-
|
|
91
|
-
box.setBgFn((text) => theme.bg("customMessageBg", text));
|
|
92
|
-
box.clear();
|
|
93
|
-
const request = args.question?.trim();
|
|
94
|
-
const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
|
|
95
|
-
const title = theme.fg("customMessageText", "Executor → Advisor");
|
|
96
|
-
box.addChild(new Text(request ? `${label} ${title}\n${theme.fg("dim", ` ${request}`)}` : `${label} ${title}`, 0, 0));
|
|
97
|
-
return box;
|
|
123
|
+
renderCall(args, theme, _context) {
|
|
124
|
+
return renderAdvisorCallBox(args.question?.trim(), theme);
|
|
98
125
|
},
|
|
99
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
126
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
100
127
|
const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
101
128
|
box.setBgFn((text) => theme.bg("customMessageBg", text));
|
|
102
129
|
box.clear();
|
|
@@ -112,7 +139,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
112
139
|
lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
|
|
113
140
|
}
|
|
114
141
|
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
115
|
-
if (d?.text) box.addChild(new Markdown(d.text, 0, 0, getMarkdownTheme()));
|
|
142
|
+
if (d?.text) box.addChild(new Markdown(adviceForDisplay(d.text, Boolean(expanded)), 0, 0, getMarkdownTheme()));
|
|
116
143
|
} else {
|
|
117
144
|
if (context.state.timerId) {
|
|
118
145
|
clearInterval(context.state.timerId);
|
|
@@ -126,7 +153,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
126
153
|
}
|
|
127
154
|
const advice = d?.text || textFrom(result.content) || "(Advisor returned no advice.)";
|
|
128
155
|
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
129
|
-
box.addChild(new Markdown(advice, 0, 0, getMarkdownTheme()));
|
|
156
|
+
box.addChild(new Markdown(adviceForDisplay(advice, Boolean(expanded)), 0, 0, getMarkdownTheme()));
|
|
130
157
|
}
|
|
131
158
|
return box;
|
|
132
159
|
},
|
package/src/ui.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Input, fuzzyFilter, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Input, Key, matchesKey, fuzzyFilter, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
2
2
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
@@ -93,3 +93,145 @@ export class SearchableModelSelector implements Component, Focusable {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
export type ContextPreset = { label: string; value: number; description: string };
|
|
98
|
+
|
|
99
|
+
export type AdvisorSettings = {
|
|
100
|
+
contextMaxChars: number;
|
|
101
|
+
effort?: string;
|
|
102
|
+
planGate: boolean;
|
|
103
|
+
failureGate: boolean;
|
|
104
|
+
completionGate: boolean;
|
|
105
|
+
collapseResponses: boolean;
|
|
106
|
+
customRule?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export class AdvisorSettingsSelector implements Component, Focusable {
|
|
110
|
+
private selectedRow = 0;
|
|
111
|
+
private contextIndex: number;
|
|
112
|
+
private effortIndex: number;
|
|
113
|
+
private settings: AdvisorSettings;
|
|
114
|
+
private customInput = new Input();
|
|
115
|
+
private editingCustom = false;
|
|
116
|
+
private _focused = false;
|
|
117
|
+
|
|
118
|
+
public get focused() { return this._focused; }
|
|
119
|
+
public set focused(value: boolean) {
|
|
120
|
+
this._focused = value;
|
|
121
|
+
this.customInput.focused = value && this.editingCustom;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
constructor(private options: {
|
|
125
|
+
tui: any;
|
|
126
|
+
theme: Theme;
|
|
127
|
+
presets: ContextPreset[];
|
|
128
|
+
effortLevels: string[];
|
|
129
|
+
initial: AdvisorSettings;
|
|
130
|
+
onSave: (settings: AdvisorSettings) => void;
|
|
131
|
+
onCancel: () => void;
|
|
132
|
+
}) {
|
|
133
|
+
this.settings = { ...options.initial };
|
|
134
|
+
this.contextIndex = Math.max(0, options.presets.findIndex((preset) => preset.value === this.settings.contextMaxChars));
|
|
135
|
+
this.effortIndex = Math.max(0, options.effortLevels.indexOf(this.settings.effort || "Default (Model Default)"));
|
|
136
|
+
this.customInput.onSubmit = (value) => {
|
|
137
|
+
this.settings.customRule = value.trim() || undefined;
|
|
138
|
+
this.editingCustom = false;
|
|
139
|
+
this.customInput.focused = false;
|
|
140
|
+
this.options.tui.requestRender();
|
|
141
|
+
};
|
|
142
|
+
this.customInput.onEscape = () => {
|
|
143
|
+
this.editingCustom = false;
|
|
144
|
+
this.customInput.focused = false;
|
|
145
|
+
this.options.tui.requestRender();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
invalidate(): void { this.options.tui.requestRender(); }
|
|
150
|
+
|
|
151
|
+
private currentContext() { return this.options.presets[this.contextIndex]; }
|
|
152
|
+
private row(label: string, value: string, index: number) {
|
|
153
|
+
const { theme } = this.options;
|
|
154
|
+
const prefix = index === this.selectedRow ? theme.fg("accent", "›") : " ";
|
|
155
|
+
const text = `${prefix} ${label.padEnd(28)} ${value}`;
|
|
156
|
+
return index === this.selectedRow ? theme.fg("accent", theme.bold(text)) : theme.fg("text", text);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render(width: number): string[] {
|
|
160
|
+
const { theme, presets } = this.options;
|
|
161
|
+
const trackWidth = Math.max(24, Math.min(60, width - 4));
|
|
162
|
+
const positions = presets.map((_, index) => Math.round(index * (trackWidth - 1) / (presets.length - 1)));
|
|
163
|
+
const track = Array.from({ length: trackWidth }, () => "─");
|
|
164
|
+
track[positions[this.contextIndex]] = "▲";
|
|
165
|
+
const labels = Array.from({ length: trackWidth }, () => " ");
|
|
166
|
+
for (let index = 0; index < presets.length; index++) {
|
|
167
|
+
const label = presets[index].label;
|
|
168
|
+
const start = Math.max(0, Math.min(trackWidth - label.length, positions[index] - Math.floor(label.length / 2)));
|
|
169
|
+
for (let char = 0; char < label.length; char++) labels[start + char] = label[char];
|
|
170
|
+
}
|
|
171
|
+
const heading = `Recent history${" ".repeat(Math.max(1, trackWidth - "Recent history".length - "Full branch".length))}Full branch`;
|
|
172
|
+
const onOff = (value: boolean) => value ? "On" : "Off";
|
|
173
|
+
const rows = [
|
|
174
|
+
this.row("Context window", this.currentContext().label, 0),
|
|
175
|
+
this.row("Advisor reasoning", this.options.effortLevels[this.effortIndex], 1),
|
|
176
|
+
this.row("Plan gate", onOff(this.settings.planGate), 2),
|
|
177
|
+
this.row("Failure gate", onOff(this.settings.failureGate), 3),
|
|
178
|
+
this.row("Completion gate", onOff(this.settings.completionGate), 4),
|
|
179
|
+
this.row("Collapse long responses", onOff(this.settings.collapseResponses), 5),
|
|
180
|
+
this.row("Custom invocation", this.settings.customRule || "None", 6),
|
|
181
|
+
];
|
|
182
|
+
if (this.editingCustom) rows.push(` ${this.customInput.render(Math.max(10, width - 6))[0] || ""}`);
|
|
183
|
+
rows.push(this.row("Save changes", "", 7));
|
|
184
|
+
return [
|
|
185
|
+
theme.fg("accent", theme.bold(" Advisor settings")),
|
|
186
|
+
"",
|
|
187
|
+
` ${theme.fg("muted", heading)}`,
|
|
188
|
+
` ${theme.fg("muted", track.join(""))}`,
|
|
189
|
+
` ${theme.fg("text", labels.join(""))}`,
|
|
190
|
+
"",
|
|
191
|
+
...rows.map((line) => ` ${line}`),
|
|
192
|
+
"",
|
|
193
|
+
` ${theme.fg("muted", "↑/↓ select · ←/→ adjust · Enter edits or saves · Esc cancels")}`,
|
|
194
|
+
].map((line) => truncateToWidth(line, width));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
handleInput(keyData: string): void {
|
|
198
|
+
const { tui } = this.options;
|
|
199
|
+
if (this.editingCustom) {
|
|
200
|
+
this.customInput.handleInput(keyData);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (matchesKey(keyData, Key.up)) {
|
|
204
|
+
this.selectedRow = Math.max(0, this.selectedRow - 1);
|
|
205
|
+
} else if (matchesKey(keyData, Key.down)) {
|
|
206
|
+
this.selectedRow = Math.min(7, this.selectedRow + 1);
|
|
207
|
+
} else if (matchesKey(keyData, Key.left)) {
|
|
208
|
+
this.adjust(-1);
|
|
209
|
+
} else if (matchesKey(keyData, Key.right)) {
|
|
210
|
+
this.adjust(1);
|
|
211
|
+
} else if (matchesKey(keyData, Key.enter)) {
|
|
212
|
+
if (this.selectedRow === 6) {
|
|
213
|
+
this.editingCustom = true;
|
|
214
|
+
this.customInput.setValue(this.settings.customRule || "");
|
|
215
|
+
this.customInput.focused = this.focused;
|
|
216
|
+
tui.requestRender();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (this.selectedRow === 7) return this.options.onSave({ ...this.settings, contextMaxChars: this.currentContext().value, effort: this.options.effortLevels[this.effortIndex] });
|
|
220
|
+
this.adjust(1);
|
|
221
|
+
} else if (matchesKey(keyData, Key.escape)) {
|
|
222
|
+
return this.options.onCancel();
|
|
223
|
+
} else return;
|
|
224
|
+
tui.requestRender();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private adjust(direction: number) {
|
|
228
|
+
switch (this.selectedRow) {
|
|
229
|
+
case 0: this.contextIndex = Math.max(0, Math.min(this.options.presets.length - 1, this.contextIndex + direction)); break;
|
|
230
|
+
case 1: this.effortIndex = Math.max(0, Math.min(this.options.effortLevels.length - 1, this.effortIndex + direction)); break;
|
|
231
|
+
case 2: this.settings.planGate = !this.settings.planGate; break;
|
|
232
|
+
case 3: this.settings.failureGate = !this.settings.failureGate; break;
|
|
233
|
+
case 4: this.settings.completionGate = !this.settings.completionGate; break;
|
|
234
|
+
case 5: this.settings.collapseResponses = !this.settings.collapseResponses; break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|