pi-advisor-flow 0.1.3 → 0.1.5
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 +32 -0
- package/package.json +3 -1
- package/src/commands.ts +60 -5
- package/src/config.ts +93 -9
- package/src/conversation.ts +1 -0
- package/src/tools.ts +48 -25
- package/src/ui.ts +143 -1
package/README.md
CHANGED
|
@@ -43,6 +43,13 @@ 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. `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-settings`
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
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`.
|
|
46
53
|
|
|
47
54
|
### `/advisor-models`
|
|
48
55
|
|
|
@@ -53,6 +60,31 @@ Opens an interactive, scrollable fuzzy-search picker in the TUI to choose:
|
|
|
53
60
|
|
|
54
61
|
Saves and persists your configuration to `~/.pi/agent/advisor.json`.
|
|
55
62
|
|
|
63
|
+
### `ask_advisor`
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
### Context configuration
|
|
68
|
+
|
|
69
|
+
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:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"executor": "aikeys/claude-sonnet-5",
|
|
74
|
+
"advisor": "aikeys/claude-fable-5",
|
|
75
|
+
"executorEffort": "high",
|
|
76
|
+
"advisorEffort": "high",
|
|
77
|
+
"contextMaxChars": 25000,
|
|
78
|
+
"advisorPlanGate": true,
|
|
79
|
+
"advisorFailureGate": true,
|
|
80
|
+
"advisorCompletionGate": true,
|
|
81
|
+
"advisorCollapseResponses": false,
|
|
82
|
+
"advisorCustomInvocation": "before changing a production deployment"
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
56
88
|
### `/advisor-off`
|
|
57
89
|
|
|
58
90
|
Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-advisor-flow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+ssh://git@github.com/philipbrembeck/pi-advisor.git"
|
|
8
8
|
},
|
|
9
|
+
"homepage": "https://github.com/philipbrembeck/pi-advisor",
|
|
10
|
+
"author": "Philip Brembeck",
|
|
9
11
|
"description": "Executor/Advisor flow for autonomous pi coding agents",
|
|
10
12
|
"type": "module",
|
|
11
13
|
"main": "extensions/index.ts",
|
package/src/commands.ts
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
advisorCollapseResponsesRef, advisorCompletionGateRef, advisorCustomInvocationRef, advisorFailureGateRef, advisorPlanGateRef,
|
|
3
4
|
advisorRef, advisorEffortRef, executorRef, executorEffortRef,
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
setAdvisorCollapseResponsesRef, setAdvisorCompletionGateRef, setAdvisorCustomInvocationRef, setAdvisorFailureGateRef, setAdvisorPlanGateRef,
|
|
6
|
+
setAdvisorRef, setAdvisorEffortRef, setContextMaxCharsRef, setExecutorRef, setExecutorEffortRef,
|
|
7
|
+
contextMaxCharsRef, loadConfig, saveConfig, parseArgs, splitRef,
|
|
6
8
|
} from "./config.js";
|
|
7
|
-
import { SearchableModelSelector } from "./ui.js";
|
|
9
|
+
import { AdvisorSettingsSelector, SearchableModelSelector, type AdvisorSettings, type ContextPreset } from "./ui.js";
|
|
8
10
|
|
|
9
11
|
const EFFORT_LEVELS = ["Default (Model Default)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
10
12
|
|
|
13
|
+
const CONTEXT_PRESETS: ContextPreset[] = [
|
|
14
|
+
{ label: "0", value: 0, description: "No conversation history. The Advisor receives only its standing instructions." },
|
|
15
|
+
{ label: "10k", value: 10_000, description: "The most recent 10,000 characters of the current branch." },
|
|
16
|
+
{ label: "25k", value: 25_000, description: "The most recent 25,000 characters of the current branch." },
|
|
17
|
+
{ label: "100k", value: 100_000, description: "The most recent 100,000 characters of the current branch." },
|
|
18
|
+
{ label: "200k", value: 200_000, description: "The most recent 200,000 characters of the current branch." },
|
|
19
|
+
{ label: "ALL", value: Number.MAX_SAFE_INTEGER, description: "The complete reconstructed conversation branch. Cost and model context limits apply." },
|
|
20
|
+
];
|
|
21
|
+
|
|
11
22
|
export const registerCommands = (pi: ExtensionAPI) => {
|
|
12
23
|
const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
|
|
13
24
|
|
|
14
25
|
pi.registerCommand("advisor", {
|
|
15
|
-
description: "Enable the Executor/Advisor flow and switch to the configured Executor model",
|
|
26
|
+
description: "Enable the Executor/Advisor flow and switch to the configured Executor model; accepts contextMaxChars=N",
|
|
16
27
|
handler: async (args, ctx) => {
|
|
17
28
|
loadConfig(ctx);
|
|
18
|
-
parseArgs(args);
|
|
29
|
+
const argumentError = parseArgs(args);
|
|
30
|
+
if (argumentError) {
|
|
31
|
+
if (ctx.hasUI) ctx.ui.notify(argumentError, "error");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
19
34
|
const [provider, modelId] = splitRef(executorRef);
|
|
20
35
|
const executor = ctx.modelRegistry.find(provider, modelId);
|
|
21
36
|
if (!executor) return ctx.hasUI ? ctx.ui.notify(`Executor model not found: ${executorRef}`, "error") : undefined;
|
|
@@ -60,6 +75,46 @@ export const registerCommands = (pi: ExtensionAPI) => {
|
|
|
60
75
|
},
|
|
61
76
|
});
|
|
62
77
|
|
|
78
|
+
pi.registerCommand("advisor-settings", {
|
|
79
|
+
description: "Configure Advisor context and reasoning effort",
|
|
80
|
+
handler: async (_args, ctx) => {
|
|
81
|
+
loadConfig(ctx);
|
|
82
|
+
if (!ctx.hasUI) return;
|
|
83
|
+
|
|
84
|
+
const initial: AdvisorSettings = {
|
|
85
|
+
contextMaxChars: contextMaxCharsRef,
|
|
86
|
+
effort: advisorEffortRef,
|
|
87
|
+
planGate: advisorPlanGateRef,
|
|
88
|
+
failureGate: advisorFailureGateRef,
|
|
89
|
+
completionGate: advisorCompletionGateRef,
|
|
90
|
+
collapseResponses: advisorCollapseResponsesRef,
|
|
91
|
+
customRule: advisorCustomInvocationRef,
|
|
92
|
+
};
|
|
93
|
+
const settings = await ctx.ui.custom<AdvisorSettings | undefined>((tui, theme, _keybindings, done) =>
|
|
94
|
+
new AdvisorSettingsSelector({
|
|
95
|
+
tui,
|
|
96
|
+
theme,
|
|
97
|
+
presets: CONTEXT_PRESETS,
|
|
98
|
+
effortLevels: EFFORT_LEVELS,
|
|
99
|
+
initial,
|
|
100
|
+
onSave: done,
|
|
101
|
+
onCancel: () => done(undefined),
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
if (!settings) return;
|
|
105
|
+
|
|
106
|
+
setAdvisorEffortRef(settings.effort === "Default (Model Default)" ? undefined : settings.effort);
|
|
107
|
+
setContextMaxCharsRef(settings.contextMaxChars);
|
|
108
|
+
setAdvisorPlanGateRef(settings.planGate);
|
|
109
|
+
setAdvisorFailureGateRef(settings.failureGate);
|
|
110
|
+
setAdvisorCompletionGateRef(settings.completionGate);
|
|
111
|
+
setAdvisorCollapseResponsesRef(settings.collapseResponses);
|
|
112
|
+
setAdvisorCustomInvocationRef(settings.customRule);
|
|
113
|
+
const path = saveConfig(ctx);
|
|
114
|
+
ctx.ui.notify(`Saved Advisor settings to ${path}`, "info");
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
63
118
|
pi.registerCommand("advisor-off", {
|
|
64
119
|
description: "Disable on-demand Advisor calls; keep the current model",
|
|
65
120
|
handler: async (_args, ctx) => {
|
package/src/config.ts
CHANGED
|
@@ -4,16 +4,33 @@ import { CONFIG_DIR_NAME, type ExtensionContext, getAgentDir } from "@earendil-w
|
|
|
4
4
|
|
|
5
5
|
export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
|
|
6
6
|
export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
|
|
7
|
+
export const DEFAULT_CONTEXT_MAX_CHARS = 15_000;
|
|
8
|
+
// MAX_SAFE_INTEGER represents the complete reconstructed branch (the ALL preset).
|
|
9
|
+
export const MAX_CONTEXT_MAX_CHARS = Number.MAX_SAFE_INTEGER;
|
|
7
10
|
|
|
8
11
|
export let executorRef = FALLBACK_EXECUTOR;
|
|
9
12
|
export let advisorRef = FALLBACK_ADVISOR;
|
|
10
13
|
export let executorEffortRef: string | undefined = undefined;
|
|
11
14
|
export let advisorEffortRef: string | undefined = undefined;
|
|
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;
|
|
12
21
|
|
|
13
22
|
export const setExecutorRef = (ref: string) => { executorRef = ref; };
|
|
14
23
|
export const setAdvisorRef = (ref: string) => { advisorRef = ref; };
|
|
15
24
|
export const setExecutorEffortRef = (effort: string | undefined) => { executorEffortRef = effort; };
|
|
16
25
|
export const setAdvisorEffortRef = (effort: string | undefined) => { advisorEffortRef = effort; };
|
|
26
|
+
export const isValidContextMaxChars = (value: unknown): value is number =>
|
|
27
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_CONTEXT_MAX_CHARS;
|
|
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; };
|
|
17
34
|
|
|
18
35
|
export const splitRef = (ref: string): [string, string] => {
|
|
19
36
|
const i = ref.indexOf("/");
|
|
@@ -25,24 +42,60 @@ export const configPaths = (ctx: ExtensionContext) => [
|
|
|
25
42
|
join(getAgentDir(), "advisor.json"),
|
|
26
43
|
];
|
|
27
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
|
+
|
|
28
73
|
export const loadConfig = (ctx: ExtensionContext) => {
|
|
29
74
|
executorRef = FALLBACK_EXECUTOR;
|
|
30
75
|
advisorRef = FALLBACK_ADVISOR;
|
|
31
76
|
executorEffortRef = undefined;
|
|
32
77
|
advisorEffortRef = undefined;
|
|
78
|
+
contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
|
|
79
|
+
advisorPlanGateRef = true;
|
|
80
|
+
advisorFailureGateRef = true;
|
|
81
|
+
advisorCompletionGateRef = true;
|
|
82
|
+
advisorCustomInvocationRef = undefined;
|
|
83
|
+
advisorCollapseResponsesRef = false;
|
|
33
84
|
for (const path of configPaths(ctx)) {
|
|
34
85
|
if (!path || !existsSync(path)) continue;
|
|
35
86
|
try {
|
|
36
|
-
const config = JSON.parse(readFileSync(path, "utf8"))
|
|
37
|
-
|
|
38
|
-
advisor?: string;
|
|
39
|
-
executorEffort?: string;
|
|
40
|
-
advisorEffort?: string;
|
|
41
|
-
};
|
|
87
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
if (!isValidConfig(config)) throw new TypeError("Invalid advisor configuration");
|
|
42
89
|
if (config.executor) executorRef = config.executor;
|
|
43
90
|
if (config.advisor) advisorRef = config.advisor;
|
|
44
91
|
if (config.executorEffort) executorEffortRef = config.executorEffort;
|
|
45
92
|
if (config.advisorEffort) advisorEffortRef = config.advisorEffort;
|
|
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;
|
|
46
99
|
return path;
|
|
47
100
|
} catch {
|
|
48
101
|
// Ignore malformed config and keep looking for a valid fallback.
|
|
@@ -54,20 +107,51 @@ export const loadConfig = (ctx: ExtensionContext) => {
|
|
|
54
107
|
export const saveConfig = (ctx: ExtensionContext) => {
|
|
55
108
|
const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
|
|
56
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
|
+
}
|
|
57
118
|
const data = {
|
|
119
|
+
...existing,
|
|
58
120
|
executor: executorRef,
|
|
59
121
|
advisor: advisorRef,
|
|
60
122
|
executorEffort: executorEffortRef,
|
|
61
123
|
advisorEffort: advisorEffortRef,
|
|
124
|
+
contextMaxChars: contextMaxCharsRef,
|
|
125
|
+
advisorPlanGate: advisorPlanGateRef,
|
|
126
|
+
advisorFailureGate: advisorFailureGateRef,
|
|
127
|
+
advisorCompletionGate: advisorCompletionGateRef,
|
|
128
|
+
advisorCustomInvocation: advisorCustomInvocationRef,
|
|
129
|
+
advisorCollapseResponses: advisorCollapseResponsesRef,
|
|
62
130
|
};
|
|
63
131
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
64
132
|
return path;
|
|
65
133
|
};
|
|
66
134
|
|
|
67
|
-
export const parseArgs = (args: string) => {
|
|
135
|
+
export const parseArgs = (args: string): string | undefined => {
|
|
136
|
+
let nextExecutor = executorRef;
|
|
137
|
+
let nextAdvisor = advisorRef;
|
|
138
|
+
let nextContextMaxChars = contextMaxCharsRef;
|
|
139
|
+
|
|
68
140
|
for (const token of args.trim().split(/\s+/).filter(Boolean)) {
|
|
69
141
|
const [key, value] = token.split("=");
|
|
70
|
-
if (key === "executor" && value)
|
|
71
|
-
if (key === "advisor" && value)
|
|
142
|
+
if (key === "executor" && value) nextExecutor = value;
|
|
143
|
+
if (key === "advisor" && value) nextAdvisor = value;
|
|
144
|
+
if (key === "contextMaxChars") {
|
|
145
|
+
const parsed = Number(value);
|
|
146
|
+
if (!isValidContextMaxChars(parsed)) {
|
|
147
|
+
return `contextMaxChars must be a non-negative integer no greater than ${MAX_CONTEXT_MAX_CHARS}.`;
|
|
148
|
+
}
|
|
149
|
+
nextContextMaxChars = parsed;
|
|
150
|
+
}
|
|
72
151
|
}
|
|
152
|
+
|
|
153
|
+
executorRef = nextExecutor;
|
|
154
|
+
advisorRef = nextAdvisor;
|
|
155
|
+
contextMaxCharsRef = nextContextMaxChars;
|
|
156
|
+
return undefined;
|
|
73
157
|
};
|
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,47 @@ 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
|
-
|
|
16
|
+
const COLLAPSED_ADVICE_LINES = 12;
|
|
17
|
+
|
|
18
|
+
export const adviceForDisplay = (advice: string, expanded: boolean) => {
|
|
19
|
+
if (!advisorCollapseResponsesRef || expanded) return advice;
|
|
20
|
+
const lines = advice.split("\n");
|
|
21
|
+
if (lines.length <= COLLAPSED_ADVICE_LINES) return advice;
|
|
22
|
+
return `${lines.slice(0, COLLAPSED_ADVICE_LINES).join("\n")}\n\n… (${lines.length - COLLAPSED_ADVICE_LINES} more lines, Ctrl+O to expand)`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const advisorInvocationGuidelines = () => {
|
|
26
|
+
const guidelines: string[] = [];
|
|
27
|
+
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.");
|
|
28
|
+
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.");
|
|
29
|
+
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.");
|
|
30
|
+
if (advisorCustomInvocationRef) guidelines.push(`Also use ask_advisor when: ${advisorCustomInvocationRef}`);
|
|
31
|
+
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.");
|
|
32
|
+
return guidelines;
|
|
33
|
+
};
|
|
12
34
|
|
|
13
35
|
export const ADVISOR_SYSTEM = [
|
|
14
|
-
"You are the Advisor: a senior engineer
|
|
15
|
-
"You
|
|
16
|
-
"the
|
|
36
|
+
"You are the Advisor: a senior engineer giving a brief second opinion to an autonomous coding agent.",
|
|
37
|
+
"You already have the relevant reconstructed conversation context. No question or other input from the Executor is needed for a general review.",
|
|
38
|
+
"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.",
|
|
39
|
+
"The context may be truncated, so state any material uncertainty and make the best recommendation you can from what is present.",
|
|
40
|
+
"You do not act or take over planning. Identify risks, challenge assumptions, and recommend the smallest correct next step. No preamble.",
|
|
17
41
|
].join(" ");
|
|
18
42
|
|
|
19
43
|
export const consult = async (
|
|
20
44
|
ctx: ExtensionContext,
|
|
21
|
-
question
|
|
45
|
+
question?: string,
|
|
22
46
|
signal?: AbortSignal,
|
|
23
47
|
onChunk?: (thinking: string, text: string) => void,
|
|
24
48
|
) => {
|
|
@@ -30,10 +54,10 @@ export const consult = async (
|
|
|
30
54
|
if (!auth.ok) throw new Error((auth as { error: string }).error);
|
|
31
55
|
if (!auth.apiKey) throw new Error(`No API key for ${advisorRef}`);
|
|
32
56
|
|
|
33
|
-
const conversation = recentConversation(ctx);
|
|
57
|
+
const conversation = recentConversation(ctx, contextMaxCharsRef);
|
|
34
58
|
const messages: Message[] = [{
|
|
35
59
|
role: "user",
|
|
36
|
-
content: [{ type: "text", text:
|
|
60
|
+
content: [{ type: "text", text: advisorMessageText(conversation, question) }],
|
|
37
61
|
timestamp: Date.now(),
|
|
38
62
|
}];
|
|
39
63
|
|
|
@@ -71,20 +95,22 @@ export const consult = async (
|
|
|
71
95
|
};
|
|
72
96
|
|
|
73
97
|
export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
98
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
99
|
+
if (!pi.getActiveTools().includes("ask_advisor")) return;
|
|
100
|
+
loadConfig(ctx);
|
|
101
|
+
const guidelines = advisorInvocationGuidelines();
|
|
102
|
+
return guidelines.length > 0 ? { systemPrompt: `${ctx.getSystemPrompt()}\n\nAdvisor invocation settings:\n${guidelines.map((rule) => `- ${rule}`).join("\n")}` } : undefined;
|
|
103
|
+
});
|
|
104
|
+
|
|
74
105
|
pi.registerTool({
|
|
75
106
|
name: "ask_advisor",
|
|
76
107
|
label: "Ask Advisor",
|
|
77
|
-
description: "Consult the on-demand Advisor model for strategic guidance
|
|
78
|
-
promptSnippet: "Consult the Advisor
|
|
108
|
+
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.",
|
|
109
|
+
promptSnippet: "Consult the Advisor using its existing context; omit question unless a specific focus is necessary",
|
|
79
110
|
promptGuidelines: [
|
|
80
|
-
"
|
|
81
|
-
"1. PLAN GATE: You MUST call `ask_advisor` BEFORE selecting or implementing a plan when multiple materially different approaches exist, requirements are ambiguous, or the decision has architectural, security, data-loss, compatibility, or difficult-to-reverse consequences.",
|
|
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
|
-
"For a context-based review without a specific question, call `ask_advisor` with an empty object.",
|
|
85
|
-
"Do NOT use `ask_advisor` for routine decisions outside these three gates.",
|
|
111
|
+
"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
112
|
],
|
|
87
|
-
parameters: Type.Object({ question: Type.Optional(Type.String({ description: "The specific question or decision to get advice on. Omit for
|
|
113
|
+
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
114
|
renderShell: "self",
|
|
89
115
|
renderCall(args, theme, context) {
|
|
90
116
|
const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
@@ -93,13 +119,10 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
93
119
|
const request = args.question?.trim();
|
|
94
120
|
const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
|
|
95
121
|
const title = theme.fg("customMessageText", "Executor → Advisor");
|
|
96
|
-
|
|
97
|
-
? theme.fg("dim", ` ${request}`)
|
|
98
|
-
: theme.fg("dim", " General task review");
|
|
99
|
-
box.addChild(new Text(`${label} ${title}\n${detail}`, 0, 0));
|
|
122
|
+
box.addChild(new Text(request ? `${label} ${title}\n${theme.fg("dim", ` ${request}`)}` : `${label} ${title}`, 0, 0));
|
|
100
123
|
return box;
|
|
101
124
|
},
|
|
102
|
-
renderResult(result, { isPartial }, theme, context) {
|
|
125
|
+
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
103
126
|
const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
104
127
|
box.setBgFn((text) => theme.bg("customMessageBg", text));
|
|
105
128
|
box.clear();
|
|
@@ -115,7 +138,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
115
138
|
lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
|
|
116
139
|
}
|
|
117
140
|
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
118
|
-
if (d?.text) box.addChild(new Markdown(d.text, 0, 0, getMarkdownTheme()));
|
|
141
|
+
if (d?.text) box.addChild(new Markdown(adviceForDisplay(d.text, Boolean(expanded)), 0, 0, getMarkdownTheme()));
|
|
119
142
|
} else {
|
|
120
143
|
if (context.state.timerId) {
|
|
121
144
|
clearInterval(context.state.timerId);
|
|
@@ -129,7 +152,7 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
|
129
152
|
}
|
|
130
153
|
const advice = d?.text || textFrom(result.content) || "(Advisor returned no advice.)";
|
|
131
154
|
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
132
|
-
box.addChild(new Markdown(advice, 0, 0, getMarkdownTheme()));
|
|
155
|
+
box.addChild(new Markdown(adviceForDisplay(advice, Boolean(expanded)), 0, 0, getMarkdownTheme()));
|
|
133
156
|
}
|
|
134
157
|
return box;
|
|
135
158
|
},
|
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
|
+
}
|