pi-advisor-flow 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # pi-advisor
2
2
 
3
+ <div align="center">
4
+ <img src="https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/screenshot.png" alt="Pi Advisor Flow Screenshot" width="600">
5
+ </div>
6
+
3
7
  An on-demand Advisor model flow for autonomous **Pi** coding agents.
4
8
 
5
9
  This extension introduces a strategic "Executor/Advisor" workflow, inspired by Claudes [Advisor](https://code.claude.com/docs/en/advisor).
@@ -39,6 +43,7 @@ Once installed, the following commands are available inside the Pi terminal:
39
43
  Enables the Advisor flow. Switches the primary model to the configured Executor model and registers the `ask_advisor` tool.
40
44
 
41
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. The default is 15,000; the maximum is 1,000,000. Larger values increase request cost and can exceed the advisor model's context window.
42
47
 
43
48
  ### `/advisor-models`
44
49
 
@@ -49,6 +54,22 @@ Opens an interactive, scrollable fuzzy-search picker in the TUI to choose:
49
54
 
50
55
  Saves and persists your configuration to `~/.pi/agent/advisor.json`.
51
56
 
57
+ ### `ask_advisor`
58
+
59
+ 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.
60
+
61
+ ### Context configuration
62
+
63
+ The selected configuration is saved as `advisor.json` in the Pi agent directory (or an existing trusted project configuration). Set `contextMaxChars` there to increase the reconstructed conversation limit for all consultations:
64
+
65
+ ```json
66
+ {
67
+ "contextMaxChars": 30000
68
+ }
69
+ ```
70
+
71
+ `contextMaxChars` must be a positive integer up to 1,000,000. Its default is 15,000.
72
+
52
73
  ### `/advisor-off`
53
74
 
54
75
  Disables the Advisor flow, removing the `ask_advisor` tool from the active session.
package/package.json CHANGED
@@ -1,17 +1,23 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
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",
12
14
  "types": "extensions/index.ts",
13
15
  "keywords": [
14
- "pi-package"
16
+ "pi-package",
17
+ "pi-extension",
18
+ "pi-coding-agent",
19
+ "advisor",
20
+ "pi-advisor"
15
21
  ],
16
22
  "files": [
17
23
  "extensions",
@@ -21,7 +27,8 @@
21
27
  "pi": {
22
28
  "extensions": [
23
29
  "./extensions/index.ts"
24
- ]
30
+ ],
31
+ "image": "https://raw.githubusercontent.com/philipbrembeck/pi-advisor/refs/heads/main/assets/hero.png"
25
32
  },
26
33
  "scripts": {
27
34
  "test": "bun test",
package/src/commands.ts CHANGED
@@ -12,10 +12,14 @@ export const registerCommands = (pi: ExtensionAPI) => {
12
12
  const flowEnabled = () => pi.getActiveTools().includes("ask_advisor");
13
13
 
14
14
  pi.registerCommand("advisor", {
15
- description: "Enable the Executor/Advisor flow and switch to the configured Executor model",
15
+ description: "Enable the Executor/Advisor flow and switch to the configured Executor model; accepts contextMaxChars=N",
16
16
  handler: async (args, ctx) => {
17
17
  loadConfig(ctx);
18
- parseArgs(args);
18
+ const argumentError = parseArgs(args);
19
+ if (argumentError) {
20
+ if (ctx.hasUI) ctx.ui.notify(argumentError, "error");
21
+ return;
22
+ }
19
23
  const [provider, modelId] = splitRef(executorRef);
20
24
  const executor = ctx.modelRegistry.find(provider, modelId);
21
25
  if (!executor) return ctx.hasUI ? ctx.ui.notify(`Executor model not found: ${executorRef}`, "error") : undefined;
package/src/config.ts CHANGED
@@ -4,16 +4,22 @@ 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
+ export const MAX_CONTEXT_MAX_CHARS = 1_000_000;
7
9
 
8
10
  export let executorRef = FALLBACK_EXECUTOR;
9
11
  export let advisorRef = FALLBACK_ADVISOR;
10
12
  export let executorEffortRef: string | undefined = undefined;
11
13
  export let advisorEffortRef: string | undefined = undefined;
14
+ export let contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
12
15
 
13
16
  export const setExecutorRef = (ref: string) => { executorRef = ref; };
14
17
  export const setAdvisorRef = (ref: string) => { advisorRef = ref; };
15
18
  export const setExecutorEffortRef = (effort: string | undefined) => { executorEffortRef = effort; };
16
19
  export const setAdvisorEffortRef = (effort: string | undefined) => { advisorEffortRef = effort; };
20
+ export const isValidContextMaxChars = (value: unknown): value is number =>
21
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= MAX_CONTEXT_MAX_CHARS;
22
+ export const setContextMaxCharsRef = (value: number) => { contextMaxCharsRef = value; };
17
23
 
18
24
  export const splitRef = (ref: string): [string, string] => {
19
25
  const i = ref.indexOf("/");
@@ -30,6 +36,7 @@ export const loadConfig = (ctx: ExtensionContext) => {
30
36
  advisorRef = FALLBACK_ADVISOR;
31
37
  executorEffortRef = undefined;
32
38
  advisorEffortRef = undefined;
39
+ contextMaxCharsRef = DEFAULT_CONTEXT_MAX_CHARS;
33
40
  for (const path of configPaths(ctx)) {
34
41
  if (!path || !existsSync(path)) continue;
35
42
  try {
@@ -38,11 +45,13 @@ export const loadConfig = (ctx: ExtensionContext) => {
38
45
  advisor?: string;
39
46
  executorEffort?: string;
40
47
  advisorEffort?: string;
48
+ contextMaxChars?: number;
41
49
  };
42
50
  if (config.executor) executorRef = config.executor;
43
51
  if (config.advisor) advisorRef = config.advisor;
44
52
  if (config.executorEffort) executorEffortRef = config.executorEffort;
45
53
  if (config.advisorEffort) advisorEffortRef = config.advisorEffort;
54
+ if (isValidContextMaxChars(config.contextMaxChars)) contextMaxCharsRef = config.contextMaxChars;
46
55
  return path;
47
56
  } catch {
48
57
  // Ignore malformed config and keep looking for a valid fallback.
@@ -59,15 +68,32 @@ export const saveConfig = (ctx: ExtensionContext) => {
59
68
  advisor: advisorRef,
60
69
  executorEffort: executorEffortRef,
61
70
  advisorEffort: advisorEffortRef,
71
+ contextMaxChars: contextMaxCharsRef,
62
72
  };
63
73
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
64
74
  return path;
65
75
  };
66
76
 
67
- export const parseArgs = (args: string) => {
77
+ export const parseArgs = (args: string): string | undefined => {
78
+ let nextExecutor = executorRef;
79
+ let nextAdvisor = advisorRef;
80
+ let nextContextMaxChars = contextMaxCharsRef;
81
+
68
82
  for (const token of args.trim().split(/\s+/).filter(Boolean)) {
69
83
  const [key, value] = token.split("=");
70
- if (key === "executor" && value) executorRef = value;
71
- if (key === "advisor" && value) advisorRef = value;
84
+ if (key === "executor" && value) nextExecutor = value;
85
+ if (key === "advisor" && value) nextAdvisor = value;
86
+ if (key === "contextMaxChars") {
87
+ const parsed = Number(value);
88
+ if (!isValidContextMaxChars(parsed)) {
89
+ return `contextMaxChars must be a positive integer no greater than ${MAX_CONTEXT_MAX_CHARS}.`;
90
+ }
91
+ nextContextMaxChars = parsed;
92
+ }
72
93
  }
94
+
95
+ executorRef = nextExecutor;
96
+ advisorRef = nextAdvisor;
97
+ contextMaxCharsRef = nextContextMaxChars;
98
+ return undefined;
73
99
  };
package/src/tools.ts CHANGED
@@ -1,16 +1,19 @@
1
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";
2
+ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
- import { advisorRef, advisorEffortRef, loadConfig, splitRef } from "./config.js";
5
+ import { advisorRef, advisorEffortRef, contextMaxCharsRef, loadConfig, splitRef } from "./config.js";
6
6
  import { recentConversation, textFrom } from "./conversation.js";
7
7
 
8
8
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
+ export const DEFAULT_ADVISOR_REQUEST = "Review the current task and conversation context. Identify the highest-risk assumption, the smallest correct next step, and any important validation.";
10
+
11
+ export const resolveAdvisorRequest = (question?: string) => question?.trim() || DEFAULT_ADVISOR_REQUEST;
9
12
 
10
13
  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
+ "You are the Advisor: a senior engineer giving a brief second opinion to an autonomous coding agent.",
15
+ "You do not act or take over planning. Help the Executor validate its own proposed direction:",
16
+ "identify risks, challenge assumptions, and recommend the smallest correct next step. No preamble.",
14
17
  ].join(" ");
15
18
 
16
19
  export const consult = async (
@@ -27,10 +30,10 @@ export const consult = async (
27
30
  if (!auth.ok) throw new Error((auth as { error: string }).error);
28
31
  if (!auth.apiKey) throw new Error(`No API key for ${advisorRef}`);
29
32
 
30
- const conversation = recentConversation(ctx);
33
+ const conversation = recentConversation(ctx, contextMaxCharsRef);
31
34
  const messages: Message[] = [{
32
35
  role: "user",
33
- content: [{ type: "text", text: `${conversation ? `<conversation>\n${conversation}\n</conversation>\n\n` : ""}Question from the Executor:\n${question}` }],
36
+ content: [{ type: "text", text: `${conversation ? `<conversation>\n${conversation}\n</conversation>\n\n` : ""}Request from the Executor:\n${question}` }],
34
37
  timestamp: Date.now(),
35
38
  }];
36
39
 
@@ -71,31 +74,31 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
71
74
  pi.registerTool({
72
75
  name: "ask_advisor",
73
76
  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",
77
+ description: "Consult the on-demand Advisor model for strategic guidance. Call with no arguments for a general review of the current task and conversation, or provide question for targeted advice.",
78
+ promptSnippet: "Consult the Advisor for targeted advice, or call with no arguments for a general task and conversation review",
76
79
  promptGuidelines: [
77
80
  "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.",
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.",
79
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.",
80
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.",
81
85
  "Do NOT use `ask_advisor` for routine decisions outside these three gates.",
82
86
  ],
83
- parameters: Type.Object({ question: Type.String({ description: "The specific question or decision to get advice on." }) }),
87
+ parameters: Type.Object({ question: Type.Optional(Type.String({ description: "The specific question or decision to get advice on. Omit for a general contextual review." })) }),
84
88
  renderShell: "self",
85
89
  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`);
90
+ const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
91
+ box.setBgFn((text) => theme.bg("customMessageBg", text));
88
92
  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));
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));
94
97
  return box;
95
98
  },
96
99
  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`);
100
+ const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
101
+ box.setBgFn((text) => theme.bg("customMessageBg", text));
99
102
  box.clear();
100
103
  if (isPartial) {
101
104
  if (!context.state.timerId) {
@@ -108,33 +111,36 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
108
111
  const snippet = d.thinking.length > 200 ? d.thinking.slice(-200) : d.thinking;
109
112
  lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
110
113
  }
111
- if (d?.text) lines.push(theme.fg("text", d.text));
112
114
  box.addChild(new Text(lines.join("\n"), 0, 0));
115
+ if (d?.text) box.addChild(new Markdown(d.text, 0, 0, getMarkdownTheme()));
113
116
  } else {
114
117
  if (context.state.timerId) {
115
118
  clearInterval(context.state.timerId);
116
119
  delete context.state.timerId;
117
120
  }
118
- const d = result.details as { thinking?: string; text?: string } | undefined;
119
- const lines: string[] = [theme.fg("warning", theme.bold("◆ ADVISOR"))];
121
+ const d = result.details as { thinking?: string; text?: string; advisor?: string } | undefined;
122
+ const lines: string[] = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
123
+ if (d?.advisor) lines.push(theme.fg("dim", ` ${d.advisor}`));
120
124
  if (d?.thinking) {
121
125
  lines.push(theme.fg("thinkingText", ` 💭 ${d.thinking.replace(/\n/g, " ").slice(0, 300)}${d.thinking.length > 300 ? "…" : ""}`));
122
126
  }
123
- lines.push(textFrom(result.content) || "(Advisor returned no advice.)");
127
+ const advice = d?.text || textFrom(result.content) || "(Advisor returned no advice.)";
124
128
  box.addChild(new Text(lines.join("\n"), 0, 0));
129
+ box.addChild(new Markdown(advice, 0, 0, getMarkdownTheme()));
125
130
  }
126
131
  return box;
127
132
  },
128
133
  async execute(_id, params, signal, onUpdate, ctx) {
129
- const { advice, thinkingText } = await consult(ctx, params.question, signal, (t, tx) => {
134
+ const question = resolveAdvisorRequest(params.question);
135
+ const { advice, thinkingText } = await consult(ctx, question, signal, (t, tx) => {
130
136
  onUpdate?.({
131
137
  content: [{ type: "text", text: tx }],
132
- details: { thinking: t, text: tx, advisor: advisorRef, question: params.question },
138
+ details: { thinking: t, text: tx, advisor: advisorRef, question },
133
139
  });
134
140
  });
135
141
  return {
136
142
  content: [{ type: "text", text: `Advisor (${advisorRef})\n\n${advice}` }],
137
- details: { thinking: thinkingText, text: advice, advisor: advisorRef, question: params.question },
143
+ details: { thinking: thinkingText, text: advice, advisor: advisorRef, question },
138
144
  };
139
145
  },
140
146
  });