pi-advisor-flow 0.1.0
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/LICENSE +21 -0
- package/README.md +80 -0
- package/extensions/index.ts +8 -0
- package/package.json +45 -0
- package/src/commands.ts +70 -0
- package/src/config.ts +73 -0
- package/src/conversation.ts +52 -0
- package/src/tools.ts +141 -0
- package/src/ui.ts +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Philip Brembeck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# pi-advisor
|
|
2
|
+
|
|
3
|
+
An on-demand Advisor model flow for autonomous **Pi** coding agents.
|
|
4
|
+
|
|
5
|
+
This extension introduces a strategic "Executor/Advisor" workflow, inspired by Claudes [Advisor](https://code.claude.com/docs/en/advisor).
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
[Read more about it here](https://philipbrembeck.com/writings/2026/07/blog/2026/07/only-as-much-intelligence-as-you-need).
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the package directly into your global Pi agent environment:
|
|
14
|
+
|
|
15
|
+
### From NPM
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-advisor-flow
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### From Git
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/philipbrembeck/pi-advisor.git
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### From Local Folder (For Development)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install /path/to/pi-advisor
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage & Commands
|
|
34
|
+
|
|
35
|
+
Once installed, the following commands are available inside the Pi terminal:
|
|
36
|
+
|
|
37
|
+
### `/advisor`
|
|
38
|
+
|
|
39
|
+
Enables the Advisor flow. Switches the primary model to the configured Executor model and registers the `ask_advisor` tool.
|
|
40
|
+
|
|
41
|
+
- _Example:_ `/advisor executor=anthropic/claude-sonnet-5 advisor=openai/gpt-5.6-sol`
|
|
42
|
+
|
|
43
|
+
### `/advisor-models`
|
|
44
|
+
|
|
45
|
+
Opens an interactive, scrollable fuzzy-search picker in the TUI to choose:
|
|
46
|
+
|
|
47
|
+
1. Executor Model & Reasoning Effort
|
|
48
|
+
2. Advisor Model & Reasoning Effort
|
|
49
|
+
|
|
50
|
+
Saves and persists your configuration to `~/.pi/agent/advisor.json`.
|
|
51
|
+
|
|
52
|
+
### `/advisor-off`
|
|
53
|
+
|
|
54
|
+
Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
|
|
55
|
+
|
|
56
|
+
## Local Development
|
|
57
|
+
|
|
58
|
+
`pi-advisor` uses Bun for rapid testing and TypeScript. Standard commands apply:
|
|
59
|
+
|
|
60
|
+
### 1. Clone the repository
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone git@github.com:philipbrembeck/pi-advisor.git
|
|
64
|
+
cd pi-advisor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Install dependencies
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bun install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 3. Run type-checks & tests
|
|
74
|
+
|
|
75
|
+
Verify code-splitting correctness and registration logic:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bun test # Run unit tests
|
|
79
|
+
npm run typecheck # Perform strict TS checks
|
|
80
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerAdvisorTool } from "../src/tools.js";
|
|
3
|
+
import { registerCommands } from "../src/commands.js";
|
|
4
|
+
|
|
5
|
+
export default function (pi: ExtensionAPI) {
|
|
6
|
+
registerAdvisorTool(pi);
|
|
7
|
+
registerCommands(pi);
|
|
8
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-advisor-flow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+ssh://git@github.com/philipbrembeck/pi-advisor.git"
|
|
8
|
+
},
|
|
9
|
+
"description": "Executor/Advisor flow for autonomous pi coding agents",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "extensions/index.ts",
|
|
12
|
+
"types": "extensions/index.ts",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions/index.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "^0.80.7",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "^0.80.7",
|
|
33
|
+
"@earendil-works/pi-tui": "^0.80.7",
|
|
34
|
+
"typebox": "^1.1.38"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "^0.80.7",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.80.7",
|
|
39
|
+
"@earendil-works/pi-tui": "^0.80.7",
|
|
40
|
+
"typebox": "^1.1.38",
|
|
41
|
+
"@types/node": "^20.11.0",
|
|
42
|
+
"bun-types": "^1.0.0",
|
|
43
|
+
"typescript": "^5.3.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
advisorRef, advisorEffortRef, executorRef, executorEffortRef,
|
|
4
|
+
setAdvisorRef, setAdvisorEffortRef, setExecutorRef, setExecutorEffortRef,
|
|
5
|
+
loadConfig, saveConfig, parseArgs, splitRef,
|
|
6
|
+
} from "./config.js";
|
|
7
|
+
import { SearchableModelSelector } from "./ui.js";
|
|
8
|
+
|
|
9
|
+
const EFFORT_LEVELS = ["Default (Model Default)", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
10
|
+
|
|
11
|
+
export const registerCommands = (pi: ExtensionAPI) => {
|
|
12
|
+
const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
|
|
13
|
+
|
|
14
|
+
pi.registerCommand("advisor", {
|
|
15
|
+
description: "Enable the Executor/Advisor flow and switch to the configured Executor model",
|
|
16
|
+
handler: async (args, ctx) => {
|
|
17
|
+
loadConfig(ctx);
|
|
18
|
+
parseArgs(args);
|
|
19
|
+
const [provider, modelId] = splitRef(executorRef);
|
|
20
|
+
const executor = ctx.modelRegistry.find(provider, modelId);
|
|
21
|
+
if (!executor) return ctx.hasUI ? ctx.ui.notify(`Executor model not found: ${executorRef}`, "error") : undefined;
|
|
22
|
+
const [ap, am] = splitRef(advisorRef);
|
|
23
|
+
if (!ctx.modelRegistry.find(ap, am)) return ctx.hasUI ? ctx.ui.notify(`Advisor model not found: ${advisorRef}`, "error") : undefined;
|
|
24
|
+
if (!(await pi.setModel(executor))) return ctx.hasUI ? ctx.ui.notify(`No API key for Executor ${executorRef}`, "error") : undefined;
|
|
25
|
+
if (executorEffortRef) pi.setThinkingLevel(executorEffortRef as any);
|
|
26
|
+
if (!flowEnabled()) pi.setActiveTools([...pi.getActiveTools(), "ask_advisor"]);
|
|
27
|
+
if (ctx.hasUI) ctx.ui.notify(`Advisor flow ready — Executor: ${executorRef} (thinking: ${executorEffortRef || "default"}) · Advisor: ${advisorRef} (thinking: ${advisorEffortRef || "default"})`, "info");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.registerCommand("advisor-models", {
|
|
32
|
+
description: "Select and persist the Executor and Advisor models with reasoning levels",
|
|
33
|
+
handler: async (_args, ctx) => {
|
|
34
|
+
if (!ctx.hasUI) return;
|
|
35
|
+
const refs = ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
|
|
36
|
+
|
|
37
|
+
const executor = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
38
|
+
new SearchableModelSelector({ tui, title: "Select Executor Model", allOptions: refs, theme, keybindings, onSelect: done, onCancel: () => done(undefined) })
|
|
39
|
+
);
|
|
40
|
+
if (!executor) return;
|
|
41
|
+
|
|
42
|
+
const executorEffort = await ctx.ui.select("Select Executor Reasoning/Thinking Level", EFFORT_LEVELS);
|
|
43
|
+
if (!executorEffort) return;
|
|
44
|
+
|
|
45
|
+
const advisor = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
46
|
+
new SearchableModelSelector({ tui, title: "Select Advisor Model", allOptions: refs, theme, keybindings, onSelect: done, onCancel: () => done(undefined) })
|
|
47
|
+
);
|
|
48
|
+
if (!advisor) return;
|
|
49
|
+
|
|
50
|
+
const advisorEffort = await ctx.ui.select("Select Advisor Reasoning/Thinking Level", EFFORT_LEVELS);
|
|
51
|
+
if (!advisorEffort) return;
|
|
52
|
+
|
|
53
|
+
setExecutorRef(executor);
|
|
54
|
+
setAdvisorRef(advisor);
|
|
55
|
+
setExecutorEffortRef(executorEffort === "Default (Model Default)" ? undefined : executorEffort);
|
|
56
|
+
setAdvisorEffortRef(advisorEffort === "Default (Model Default)" ? undefined : advisorEffort);
|
|
57
|
+
|
|
58
|
+
const path = saveConfig(ctx);
|
|
59
|
+
ctx.ui.notify(`Saved Executor + Advisor configurations to ${path}`, "info");
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.registerCommand("advisor-off", {
|
|
64
|
+
description: "Disable on-demand Advisor calls; keep the current model",
|
|
65
|
+
handler: async (_args, ctx) => {
|
|
66
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "ask_advisor"));
|
|
67
|
+
if (ctx.hasUI) ctx.ui.notify("Advisor flow disabled. Current model unchanged.", "info");
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR_NAME, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export const FALLBACK_EXECUTOR = "aikeys/claude-sonnet-5";
|
|
6
|
+
export const FALLBACK_ADVISOR = "aikeys/claude-fable-5";
|
|
7
|
+
|
|
8
|
+
export let executorRef = FALLBACK_EXECUTOR;
|
|
9
|
+
export let advisorRef = FALLBACK_ADVISOR;
|
|
10
|
+
export let executorEffortRef: string | undefined = undefined;
|
|
11
|
+
export let advisorEffortRef: string | undefined = undefined;
|
|
12
|
+
|
|
13
|
+
export const setExecutorRef = (ref: string) => { executorRef = ref; };
|
|
14
|
+
export const setAdvisorRef = (ref: string) => { advisorRef = ref; };
|
|
15
|
+
export const setExecutorEffortRef = (effort: string | undefined) => { executorEffortRef = effort; };
|
|
16
|
+
export const setAdvisorEffortRef = (effort: string | undefined) => { advisorEffortRef = effort; };
|
|
17
|
+
|
|
18
|
+
export const splitRef = (ref: string): [string, string] => {
|
|
19
|
+
const i = ref.indexOf("/");
|
|
20
|
+
return i === -1 ? ["aikeys", ref] : [ref.slice(0, i), ref.slice(i + 1)];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const configPaths = (ctx: ExtensionContext) => [
|
|
24
|
+
ctx.isProjectTrusted() ? join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json") : null,
|
|
25
|
+
join(getAgentDir(), "advisor.json"),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const loadConfig = (ctx: ExtensionContext) => {
|
|
29
|
+
executorRef = FALLBACK_EXECUTOR;
|
|
30
|
+
advisorRef = FALLBACK_ADVISOR;
|
|
31
|
+
executorEffortRef = undefined;
|
|
32
|
+
advisorEffortRef = undefined;
|
|
33
|
+
for (const path of configPaths(ctx)) {
|
|
34
|
+
if (!path || !existsSync(path)) continue;
|
|
35
|
+
try {
|
|
36
|
+
const config = JSON.parse(readFileSync(path, "utf8")) as {
|
|
37
|
+
executor?: string;
|
|
38
|
+
advisor?: string;
|
|
39
|
+
executorEffort?: string;
|
|
40
|
+
advisorEffort?: string;
|
|
41
|
+
};
|
|
42
|
+
if (config.executor) executorRef = config.executor;
|
|
43
|
+
if (config.advisor) advisorRef = config.advisor;
|
|
44
|
+
if (config.executorEffort) executorEffortRef = config.executorEffort;
|
|
45
|
+
if (config.advisorEffort) advisorEffortRef = config.advisorEffort;
|
|
46
|
+
return path;
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore malformed config and keep looking for a valid fallback.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const saveConfig = (ctx: ExtensionContext) => {
|
|
55
|
+
const project = join(ctx.cwd, CONFIG_DIR_NAME, "advisor.json");
|
|
56
|
+
const path = ctx.isProjectTrusted() && existsSync(project) ? project : join(getAgentDir(), "advisor.json");
|
|
57
|
+
const data = {
|
|
58
|
+
executor: executorRef,
|
|
59
|
+
advisor: advisorRef,
|
|
60
|
+
executorEffort: executorEffortRef,
|
|
61
|
+
advisorEffort: advisorEffortRef,
|
|
62
|
+
};
|
|
63
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
|
64
|
+
return path;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const parseArgs = (args: string) => {
|
|
68
|
+
for (const token of args.trim().split(/\s+/).filter(Boolean)) {
|
|
69
|
+
const [key, value] = token.split("=");
|
|
70
|
+
if (key === "executor" && value) executorRef = value;
|
|
71
|
+
if (key === "advisor" && value) advisorRef = value;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const textFrom = (content: unknown): string => {
|
|
4
|
+
return (typeof content === "string" ? [content] : Array.isArray(content) ? content : [])
|
|
5
|
+
.map((part: unknown) =>
|
|
6
|
+
typeof part === "string"
|
|
7
|
+
? part
|
|
8
|
+
: (part as { type?: string; text?: string })?.type === "text"
|
|
9
|
+
? (part as { text?: string }).text ?? ""
|
|
10
|
+
: ""
|
|
11
|
+
)
|
|
12
|
+
.join("\n")
|
|
13
|
+
.trim();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const recentConversation = (ctx: ExtensionContext, maxChars = 15000): string => {
|
|
17
|
+
const entries: string[] = [];
|
|
18
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
19
|
+
if (entry.type === "message") {
|
|
20
|
+
const msg = entry.message;
|
|
21
|
+
if (msg.role === "user") {
|
|
22
|
+
const text = textFrom(msg.content);
|
|
23
|
+
if (text) entries.push(`User: ${text}`);
|
|
24
|
+
} else if (msg.role === "assistant") {
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
const text = textFrom(msg.content);
|
|
27
|
+
if (text) parts.push(text);
|
|
28
|
+
if (Array.isArray(msg.content)) {
|
|
29
|
+
for (const part of msg.content) {
|
|
30
|
+
if (part && typeof part === "object" && part.type === "toolCall") {
|
|
31
|
+
const tc = part as { name?: string; arguments?: unknown };
|
|
32
|
+
const argsStr = JSON.stringify(tc.arguments);
|
|
33
|
+
parts.push(`[Tool Call: ${tc.name}(${argsStr})]`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (parts.length > 0) {
|
|
38
|
+
entries.push(`Executor: ${parts.join("\n")}`);
|
|
39
|
+
}
|
|
40
|
+
} else if (msg.role === "toolResult" || (msg as any).role === "tool") {
|
|
41
|
+
const tr = msg as { toolName?: string; isError?: boolean; content?: unknown };
|
|
42
|
+
const text = textFrom(tr.content);
|
|
43
|
+
const status = tr.isError ? "Error " : "";
|
|
44
|
+
entries.push(`[Tool Result for ${tr.toolName || "unknown"}] (${status}output):\n${text}`);
|
|
45
|
+
}
|
|
46
|
+
} else if (entry.type === "compaction") {
|
|
47
|
+
entries.push(`[System Compaction Summary]: ${entry.summary}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const joined = entries.join("\n\n");
|
|
51
|
+
return joined.length > maxChars ? joined.slice(-maxChars) : joined;
|
|
52
|
+
};
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { stream, type Message, type AssistantMessage } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { advisorRef, advisorEffortRef, loadConfig, splitRef } from "./config.js";
|
|
6
|
+
import { recentConversation, textFrom } from "./conversation.js";
|
|
7
|
+
|
|
8
|
+
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
|
|
10
|
+
export const ADVISOR_SYSTEM = [
|
|
11
|
+
"You are the Advisor: a senior engineer consulted by an autonomous coding agent.",
|
|
12
|
+
"You do not act — you advise. Give concise, high-signal guidance: identify risks,",
|
|
13
|
+
"the smallest correct next step, and wrong assumptions. No preamble.",
|
|
14
|
+
].join(" ");
|
|
15
|
+
|
|
16
|
+
export const consult = async (
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
question: string,
|
|
19
|
+
signal?: AbortSignal,
|
|
20
|
+
onChunk?: (thinking: string, text: string) => void,
|
|
21
|
+
) => {
|
|
22
|
+
loadConfig(ctx);
|
|
23
|
+
const [provider, modelId] = splitRef(advisorRef);
|
|
24
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
25
|
+
if (!model) throw new Error(`Advisor model not found: ${advisorRef}`);
|
|
26
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
27
|
+
if (!auth.ok) throw new Error((auth as { error: string }).error);
|
|
28
|
+
if (!auth.apiKey) throw new Error(`No API key for ${advisorRef}`);
|
|
29
|
+
|
|
30
|
+
const conversation = recentConversation(ctx);
|
|
31
|
+
const messages: Message[] = [{
|
|
32
|
+
role: "user",
|
|
33
|
+
content: [{ type: "text", text: `${conversation ? `<conversation>\n${conversation}\n</conversation>\n\n` : ""}Question from the Executor:\n${question}` }],
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
}];
|
|
36
|
+
|
|
37
|
+
let thinkingText = "";
|
|
38
|
+
let responseText = "";
|
|
39
|
+
|
|
40
|
+
const eventStream = stream(model, { systemPrompt: ADVISOR_SYSTEM, messages }, {
|
|
41
|
+
apiKey: auth.apiKey,
|
|
42
|
+
headers: auth.headers,
|
|
43
|
+
env: auth.env,
|
|
44
|
+
signal,
|
|
45
|
+
reasoning: advisorEffortRef as any,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
for await (const event of eventStream) {
|
|
49
|
+
if (event.type === "thinking_delta") {
|
|
50
|
+
thinkingText += event.delta;
|
|
51
|
+
onChunk?.(thinkingText, responseText);
|
|
52
|
+
} else if (event.type === "text_delta") {
|
|
53
|
+
responseText += event.delta;
|
|
54
|
+
onChunk?.(thinkingText, responseText);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const response = await eventStream.result();
|
|
59
|
+
const lastAssistant = [response].find((m): m is AssistantMessage => m.role === "assistant");
|
|
60
|
+
const advice = lastAssistant?.content
|
|
61
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
62
|
+
.map((part) => part.text)
|
|
63
|
+
.join("\n")
|
|
64
|
+
.trim() || responseText;
|
|
65
|
+
|
|
66
|
+
if (!advice) throw new Error("Advisor returned no advice.");
|
|
67
|
+
return { advice, thinkingText };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const registerAdvisorTool = (pi: ExtensionAPI) => {
|
|
71
|
+
pi.registerTool({
|
|
72
|
+
name: "ask_advisor",
|
|
73
|
+
label: "Ask Advisor",
|
|
74
|
+
description: "Consult the on-demand Advisor model for strategic guidance, a second opinion, or a sanity check.",
|
|
75
|
+
promptSnippet: "Consult the Advisor model for a second opinion",
|
|
76
|
+
promptGuidelines: [
|
|
77
|
+
"You MUST call `ask_advisor` in each of these scenarios:",
|
|
78
|
+
"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.",
|
|
79
|
+
"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.",
|
|
80
|
+
"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.",
|
|
81
|
+
"Do NOT use `ask_advisor` for routine decisions outside these three gates.",
|
|
82
|
+
],
|
|
83
|
+
parameters: Type.Object({ question: Type.String({ description: "The specific question or decision to get advice on." }) }),
|
|
84
|
+
renderShell: "self",
|
|
85
|
+
renderCall(args, theme, context) {
|
|
86
|
+
const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1);
|
|
87
|
+
box.setBgFn((text) => `\x1b[48;2;25;32;45m${text}\x1b[0m`);
|
|
88
|
+
box.clear();
|
|
89
|
+
if (!context.state.timerId) {
|
|
90
|
+
context.state.timerId = setInterval(() => context.invalidate(), 80);
|
|
91
|
+
}
|
|
92
|
+
const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
|
|
93
|
+
box.addChild(new Text(`${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", `· ${args.question}`)}`, 0, 0));
|
|
94
|
+
return box;
|
|
95
|
+
},
|
|
96
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
97
|
+
const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1);
|
|
98
|
+
box.setBgFn((text) => `\x1b[48;2;25;32;45m${text}\x1b[0m`);
|
|
99
|
+
box.clear();
|
|
100
|
+
if (isPartial) {
|
|
101
|
+
if (!context.state.timerId) {
|
|
102
|
+
context.state.timerId = setInterval(() => context.invalidate(), 80);
|
|
103
|
+
}
|
|
104
|
+
const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
|
|
105
|
+
const d = result.details as { thinking?: string; text?: string } | undefined;
|
|
106
|
+
const lines: string[] = [`${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`];
|
|
107
|
+
if (d?.thinking) {
|
|
108
|
+
const snippet = d.thinking.length > 200 ? d.thinking.slice(-200) : d.thinking;
|
|
109
|
+
lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
|
|
110
|
+
}
|
|
111
|
+
if (d?.text) lines.push(theme.fg("text", d.text));
|
|
112
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
113
|
+
} else {
|
|
114
|
+
if (context.state.timerId) {
|
|
115
|
+
clearInterval(context.state.timerId);
|
|
116
|
+
delete context.state.timerId;
|
|
117
|
+
}
|
|
118
|
+
const d = result.details as { thinking?: string; text?: string } | undefined;
|
|
119
|
+
const lines: string[] = [theme.fg("warning", theme.bold("◆ ADVISOR"))];
|
|
120
|
+
if (d?.thinking) {
|
|
121
|
+
lines.push(theme.fg("thinkingText", ` 💭 ${d.thinking.replace(/\n/g, " ").slice(0, 300)}${d.thinking.length > 300 ? "…" : ""}`));
|
|
122
|
+
}
|
|
123
|
+
lines.push(textFrom(result.content) || "(Advisor returned no advice.)");
|
|
124
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
125
|
+
}
|
|
126
|
+
return box;
|
|
127
|
+
},
|
|
128
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
129
|
+
const { advice, thinkingText } = await consult(ctx, params.question, signal, (t, tx) => {
|
|
130
|
+
onUpdate?.({
|
|
131
|
+
content: [{ type: "text", text: tx }],
|
|
132
|
+
details: { thinking: t, text: tx, advisor: advisorRef, question: params.question },
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: "text", text: `Advisor (${advisorRef})\n\n${advice}` }],
|
|
137
|
+
details: { thinking: thinkingText, text: advice, advisor: advisorRef, question: params.question },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
};
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Input, fuzzyFilter, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export class SearchableModelSelector implements Component, Focusable {
|
|
6
|
+
private tui: any;
|
|
7
|
+
private searchInput: Input;
|
|
8
|
+
private allOptions: string[];
|
|
9
|
+
private filteredOptions: string[];
|
|
10
|
+
private selectedIndex = 0;
|
|
11
|
+
private title: string;
|
|
12
|
+
private onSelect: (value: string) => void;
|
|
13
|
+
private onCancel: () => void;
|
|
14
|
+
private theme: Theme;
|
|
15
|
+
private keybindings: any;
|
|
16
|
+
private _focused = false;
|
|
17
|
+
|
|
18
|
+
public get focused(): boolean { return this._focused; }
|
|
19
|
+
public set focused(val: boolean) {
|
|
20
|
+
this._focused = val;
|
|
21
|
+
this.searchInput.focused = val;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
constructor(options: {
|
|
25
|
+
tui: any;
|
|
26
|
+
title: string;
|
|
27
|
+
allOptions: string[];
|
|
28
|
+
theme: Theme;
|
|
29
|
+
keybindings: any;
|
|
30
|
+
onSelect: (value: string) => void;
|
|
31
|
+
onCancel: () => void;
|
|
32
|
+
}) {
|
|
33
|
+
this.tui = options.tui;
|
|
34
|
+
this.title = options.title;
|
|
35
|
+
this.allOptions = options.allOptions;
|
|
36
|
+
this.theme = options.theme;
|
|
37
|
+
this.keybindings = options.keybindings;
|
|
38
|
+
this.onSelect = options.onSelect;
|
|
39
|
+
this.onCancel = options.onCancel;
|
|
40
|
+
this.searchInput = new Input();
|
|
41
|
+
this.filteredOptions = this.allOptions;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
invalidate(): void { this.searchInput.invalidate(); }
|
|
45
|
+
|
|
46
|
+
render(width: number): string[] {
|
|
47
|
+
const lines: string[] = ["═".repeat(width)];
|
|
48
|
+
lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
|
|
49
|
+
const inputLines = this.searchInput.render(width - 12);
|
|
50
|
+
lines.push(` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`);
|
|
51
|
+
lines.push("");
|
|
52
|
+
|
|
53
|
+
const query = this.searchInput.getValue().trim();
|
|
54
|
+
this.filteredOptions = query ? fuzzyFilter(this.allOptions, query, (item) => item) : this.allOptions;
|
|
55
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredOptions.length - 1));
|
|
56
|
+
|
|
57
|
+
const maxVisible = 10;
|
|
58
|
+
const total = this.filteredOptions.length;
|
|
59
|
+
if (total === 0) {
|
|
60
|
+
lines.push(" " + this.theme.fg("muted", "No matching models found."));
|
|
61
|
+
} else {
|
|
62
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
|
|
63
|
+
const endIndex = Math.min(startIndex + maxVisible, total);
|
|
64
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
65
|
+
const item = this.filteredOptions[i];
|
|
66
|
+
if (i === this.selectedIndex) lines.push(` ${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", item)}`);
|
|
67
|
+
else lines.push(` ${this.theme.fg("text", item)}`);
|
|
68
|
+
}
|
|
69
|
+
if (total > maxVisible) lines.push(" " + this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`));
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(` ${this.theme.fg("muted", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`);
|
|
73
|
+
lines.push("═".repeat(width));
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleInput(keyData: string): void {
|
|
78
|
+
const kb = this.keybindings;
|
|
79
|
+
if (kb.matches(keyData, "tui.select.up") || keyData === "\u001b[A") {
|
|
80
|
+
if (this.filteredOptions.length > 0) this.selectedIndex = this.selectedIndex === 0 ? this.filteredOptions.length - 1 : this.selectedIndex - 1;
|
|
81
|
+
this.tui.requestRender();
|
|
82
|
+
} else if (kb.matches(keyData, "tui.select.down") || keyData === "\u001b[B") {
|
|
83
|
+
if (this.filteredOptions.length > 0) this.selectedIndex = this.selectedIndex === this.filteredOptions.length - 1 ? 0 : this.selectedIndex + 1;
|
|
84
|
+
this.tui.requestRender();
|
|
85
|
+
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
|
|
86
|
+
if (this.filteredOptions.length > 0) this.onSelect(this.filteredOptions[this.selectedIndex]);
|
|
87
|
+
} else if (kb.matches(keyData, "tui.select.cancel") || keyData === "\u001b") {
|
|
88
|
+
this.onCancel();
|
|
89
|
+
} else {
|
|
90
|
+
this.searchInput.handleInput(keyData);
|
|
91
|
+
this.selectedIndex = 0;
|
|
92
|
+
this.tui.requestRender();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|