@d3ara1n/pi-scout 0.1.1 → 1.0.1

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
@@ -9,6 +9,32 @@ Before each conversation turn, a cheap side agent model analyzes the user's prom
9
9
 
10
10
  Both modules can be independently toggled on/off.
11
11
 
12
+ ## Why model-router is disabled by default
13
+
14
+ Model-router switches the active model role based on task complexity, which can:
15
+
16
+ - **Break prompt caching**: Different models don't share cache, causing cache write costs on each switch
17
+ - **Increase API costs**: Frequent model switching adds ~118% overhead in typical workloads
18
+ - **Reduce performance**: Cache misses mean re-uploading system prompt and tools each time
19
+
20
+ We recommend keeping model-router disabled unless you specifically need it. Enable it via:
21
+
22
+ ```bash
23
+ /scout:model-router on # Temporary (current session)
24
+ ```
25
+
26
+ Or add to `settings.json` for persistent enablement:
27
+
28
+ ```jsonc
29
+ {
30
+ "scout": {
31
+ "modules": {
32
+ "modelRouter": true
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
12
38
  ## How it works
13
39
 
14
40
  ```
@@ -24,10 +50,9 @@ before_agent_start hook fires
24
50
  └─ [model-router] Switches model if a different role is recommended
25
51
  ```
26
52
 
27
- ## Requirements
53
+ ## Dependencies
28
54
 
29
- - **@d3ara1n/pi-model-roles** must be installed and configured
30
- - A cheap role must be defined in `modelRoles` configuration or use default(may be much more expansive) instead
55
+ - [`@d3ara1n/pi-model-roles`](../pi-model-roles) model role resolution
31
56
 
32
57
  ## Installation
33
58
 
@@ -47,7 +72,7 @@ Edit `~/.pi/agent/settings.json`:
47
72
  "maxSelectedSkills": 5,
48
73
  "modules": {
49
74
  "skillRouter": true,
50
- "modelRouter": true
75
+ "modelRouter": false
51
76
  }
52
77
  }
53
78
  }
@@ -56,18 +81,18 @@ Edit `~/.pi/agent/settings.json`:
56
81
  | Field | Default | Description |
57
82
  |-------|---------|-------------|
58
83
  | `enabled` | `true` | Global on/off |
59
- | `sideAgentRole` | `"fast"` | pi-model-roles role for the side agent |
84
+ | `sideAgentRole` | `"utility"` | pi-model-roles role for the side agent |
60
85
  | `maxSelectedSkills` | `5` | Max skills the side agent can select |
61
86
  | `modules.skillRouter` | `true` | Enable/disable skill routing |
62
- | `modules.modelRouter` | `true` | Enable/disable model routing |
87
+ | `modules.modelRouter` | `false` | Enable/disable model routing (disabled by default to avoid cache inefficiency and extra costs) |
63
88
 
64
89
  ## Commands
65
90
 
66
91
  | Command | Description |
67
92
  |---------|-------------|
68
93
  | `/scout` | Show scout status and last decision |
69
- | `/scout skill-router on/off` | Toggle skill-router module |
70
- | `/scout model-router on/off` | Toggle model-router module |
94
+ | `/scout:skill-router on/off` | Toggle skill-router module |
95
+ | `/scout:model-router on/off` | Toggle model-router module |
71
96
 
72
97
  ## Performance
73
98
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-scout",
3
- "version": "0.1.1",
3
+ "version": "1.0.1",
4
+ "type": "module",
4
5
  "description": "Per-turn side agent decision framework for pi — uses a cheap model to select skills and route models before each conversation turn",
5
6
  "main": "src/index.ts",
6
7
  "keywords": [
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import type { ScoutConfig, ScoutDecision } from "./types.ts";
17
17
  import { DEFAULT_CONFIG } from "./types.ts";
18
18
  import { loadScoutConfig } from "./config.ts";
19
19
  import { callSideAgent } from "./side-agent.ts";
20
- import { buildScoutSystemPrompt } from "./scout-prompt.ts";
20
+ import { buildScoutSystemPrompt, buildScoutUserMessage } from "./scout-prompt.ts";
21
21
  import { filterSkillsBlock, resetSkillCache } from "./skill-inject.ts";
22
22
  import { switchToRole } from "./model-switch.ts";
23
23
 
@@ -48,6 +48,9 @@ export default function scoutExtension(pi: ExtensionAPI) {
48
48
  let config: ScoutConfig = DEFAULT_CONFIG;
49
49
  let lastDecision: ScoutDecision | undefined;
50
50
 
51
+ /** Cache of previous turn context for better routing decisions. */
52
+ let prevTurn: { userPrompt: string; assistantSummary: string } | undefined;
53
+
51
54
  function tryGetRolesApi(ctx: ExtensionContext): ModelRolesAPI | undefined {
52
55
  try {
53
56
  return getModelRolesAPI();
@@ -132,10 +135,10 @@ export default function scoutExtension(pi: ExtensionAPI) {
132
135
  parameters: { type: "object", properties: {}, required: [] } as any,
133
136
  async execute() {
134
137
  if (cachedAllSkills.length === 0) {
135
- return { content: [{ type: "text", text: "No skills available." }] };
138
+ return { content: [{ type: "text", text: "No skills available." }], details: undefined as any };
136
139
  }
137
140
  const lines = cachedAllSkills.map((s) => `- **${s.name}**: ${s.description}`);
138
- return { content: [{ type: "text", text: lines.join("\n") }] };
141
+ return { content: [{ type: "text", text: lines.join("\n") }], details: undefined as any };
139
142
  },
140
143
  });
141
144
 
@@ -143,11 +146,35 @@ export default function scoutExtension(pi: ExtensionAPI) {
143
146
  pi.on("session_start", async (_event, ctx) => {
144
147
  config = loadScoutConfig(ctx.cwd);
145
148
  resetSkillCache();
149
+ prevTurn = undefined;
146
150
  });
147
151
 
148
- // ── Clear status at turn start ──────────────────────────────────
149
- pi.on("turn_start", async () => {
150
- // Will be overwritten by before_agent_start if scout runs
152
+ // ── turn_end: cache assistant response for next turn's context ──
153
+ pi.on("turn_end", async (event) => {
154
+ const msg = event.message;
155
+ if (msg.role !== "assistant") return;
156
+
157
+ // Extract text content, skip thinking blocks and tool calls
158
+ const textParts: string[] = [];
159
+ for (const block of msg.content) {
160
+ if ("type" in block && block.type === "text" && "text" in block) {
161
+ textParts.push(block.text);
162
+ }
163
+ }
164
+ const fullText = textParts.join("");
165
+ if (!fullText.trim()) return;
166
+
167
+ // Truncate to avoid bloating the side agent context
168
+ const MAX_SUMMARY = 500;
169
+ const assistantSummary = fullText.length > MAX_SUMMARY
170
+ ? fullText.slice(0, MAX_SUMMARY) + "..."
171
+ : fullText;
172
+
173
+ // before_agent_start already created/updated prevTurn with the user prompt.
174
+ // We just fill in the assistant summary here.
175
+ if (prevTurn) {
176
+ prevTurn.assistantSummary = assistantSummary;
177
+ }
151
178
  });
152
179
 
153
180
  // ── before_agent_start: core scout logic ────────────────────────
@@ -192,10 +219,12 @@ export default function scoutExtension(pi: ExtensionAPI) {
192
219
  .map((s: any) => `- ${s.name}: ${s.description ?? "(no description)"}`)
193
220
  .join("\n");
194
221
 
195
- // 2. Determine current role
222
+ // 2. Determine current role (use getCurrentRole: it recognizes the
223
+ // default role even when model=null, so the router has a real
224
+ // baseline instead of an opaque "unknown".)
196
225
  const currentModel = ctx.model;
197
226
  const currentRole = currentModel
198
- ? (rolesApi.findRoleByModel(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
227
+ ? (rolesApi.getCurrentRole(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
199
228
  : "unknown";
200
229
 
201
230
  // 3. Build roles list
@@ -204,23 +233,37 @@ export default function scoutExtension(pi: ExtensionAPI) {
204
233
  .map(([name, cfg]: [string, any]) => `- ${name}: ${cfg.description ?? "(no description)"}${cfg.model ? ` (model: ${cfg.model})` : " (current model)"}`)
205
234
  .join("\n");
206
235
 
207
- // 4. Call side agent
236
+ // 4. Build user message with conversation context
237
+ const prevTurnContext = prevTurn?.assistantSummary
238
+ ? { userPrompt: prevTurn.userPrompt, assistantSummary: prevTurn.assistantSummary }
239
+ : undefined;
240
+ const userMessage = buildScoutUserMessage(event.prompt, currentRole, prevTurnContext);
241
+
242
+ // Reset prevTurn for the current turn — user prompt now, assistant filled by turn_end
243
+ prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
244
+
245
+ // 5. Call side agent
208
246
  const scoutSystemPrompt = buildScoutSystemPrompt(config, skillsList, rolesList);
209
247
  const decision = await callSideAgent(
210
248
  sideResolved.model,
211
249
  sideResolved.apiKey,
212
250
  sideResolved.headers,
213
251
  scoutSystemPrompt,
214
- event.prompt,
215
- currentRole,
252
+ userMessage,
216
253
  );
217
254
 
255
+ // Enforce module toggles at the application layer, regardless of what
256
+ // the side agent returned. A disabled module's decision field is zeroed
257
+ // so the status bar, /scout command, and apply logic all see clean
258
+ // values (no misleading "→ fast" when model-router is off).
259
+ if (!config.modules.modelRouter) decision.role = null;
260
+ if (!config.modules.skillRouter) decision.skills = [];
218
261
  lastDecision = decision;
219
262
 
220
263
  let systemPrompt = event.systemPrompt;
221
264
  let switchedRole: string | undefined;
222
265
 
223
- // 4. skill-router: filter skills XML to only selected ones
266
+ // 6. skill-router: filter skills XML to only selected ones
224
267
  if (config.modules.skillRouter) {
225
268
  systemPrompt = filterSkillsBlock(
226
269
  systemPrompt,
@@ -229,7 +272,7 @@ export default function scoutExtension(pi: ExtensionAPI) {
229
272
  );
230
273
  }
231
274
 
232
- // 5. model-router: switch model if side agent recommends a different role
275
+ // 7. model-router: switch model if side agent recommends a different role
233
276
  if (config.modules.modelRouter && decision.role && decision.role !== currentRole) {
234
277
  const switched = await switchToRole(pi, decision.role, rolesApi);
235
278
  if (switched) {
@@ -241,10 +284,10 @@ export default function scoutExtension(pi: ExtensionAPI) {
241
284
  }
242
285
  }
243
286
 
244
- // 6. Show result in status bar
287
+ // 8. Show result in status bar
245
288
  ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
246
289
 
247
- // 7. Return modified system prompt
290
+ // 9. Return modified system prompt
248
291
  if (systemPrompt !== event.systemPrompt) {
249
292
  return { systemPrompt };
250
293
  }
@@ -1,24 +1,46 @@
1
1
  /**
2
- * Side agent system prompt — instructs the model to return a structured JSON decision.
2
+ * Side agent prompt construction system prompt and user message.
3
3
  */
4
4
 
5
5
  import type { ScoutConfig } from "./types.ts";
6
6
 
7
+ /** Previous turn context for better routing decisions. */
8
+ export interface PrevTurnContext {
9
+ userPrompt: string;
10
+ assistantSummary: string;
11
+ }
12
+
7
13
  /**
8
14
  * Build the user message for the side agent.
9
- * Only contains per-turn variable content — stable data lives in the system prompt
10
- * so it can be prompt-cached across turns.
15
+ *
16
+ * Includes previous turn context (when available) so the side agent can
17
+ * understand follow-up prompts like "continue", "change that", etc.
11
18
  */
12
19
  export function buildScoutUserMessage(
13
20
  userPrompt: string,
14
21
  currentRole: string,
22
+ prevTurn?: PrevTurnContext,
15
23
  ): string {
16
- return [
17
- `Current role: ${currentRole}`,
18
- ``,
19
- `User prompt:`,
20
- userPrompt,
21
- ].join("\n");
24
+ const parts: string[] = [];
25
+
26
+ parts.push(`Current role: ${currentRole}`);
27
+
28
+ if (prevTurn && (prevTurn.userPrompt || prevTurn.assistantSummary)) {
29
+ parts.push(``);
30
+ parts.push(`## Previous Turn`);
31
+ if (prevTurn.userPrompt) {
32
+ parts.push(`User: ${prevTurn.userPrompt}`);
33
+ }
34
+ if (prevTurn.assistantSummary) {
35
+ parts.push(`Assistant: ${prevTurn.assistantSummary}`);
36
+ }
37
+ }
38
+
39
+ parts.push(``);
40
+ parts.push(`## Current User Prompt`);
41
+ parts.push(userPrompt);
42
+
43
+ return parts.join("\n");
22
44
  }
23
45
 
24
46
  /**
@@ -53,22 +75,25 @@ export function buildScoutSystemPrompt(
53
75
  parts.push(`- "role" should be null if the current role is appropriate.`);
54
76
  parts.push(`- Only suggest a role change when the task clearly benefits from a different model.`);
55
77
  parts.push(`- Be conservative: prefer fewer skills and no role change when uncertain.`);
78
+ parts.push(`- Use the Previous Turn context to understand follow-up requests (e.g. "continue", "change that", "no, the other one").`);
56
79
 
57
- if (!config.modules.modelRouter) {
58
- parts.push(`- IMPORTANT: model routing is disabled. Always return role: null.`);
80
+ // Stable prefix ends here. Sections below are injected conditionally per
81
+ // module toggle: a disabled module contributes no candidates, so the side
82
+ // agent has nothing to choose from for it (and the application layer zeros
83
+ // out its field regardless). The longer section (skills) comes first so
84
+ // toggling the later module (roles) only invalidates the cache tail —
85
+ // Anthropic prefix-cache matches the longest common prefix.
86
+ if (config.modules.skillRouter) {
87
+ parts.push(``);
88
+ parts.push(`## Available Skills`);
89
+ parts.push(skillsList || "(none)");
59
90
  }
60
91
 
61
- if (!config.modules.skillRouter) {
62
- parts.push(`- IMPORTANT: skill routing is disabled. Always return skills: [].`);
92
+ if (config.modules.modelRouter) {
93
+ parts.push(``);
94
+ parts.push(`## Available Roles`);
95
+ parts.push(rolesList);
63
96
  }
64
97
 
65
- parts.push(``);
66
- parts.push(`## Available Skills`);
67
- parts.push(skillsList || "(none)");
68
-
69
- parts.push(``);
70
- parts.push(`## Available Roles`);
71
- parts.push(rolesList);
72
-
73
98
  return parts.join("\n");
74
99
  }
package/src/side-agent.ts CHANGED
@@ -7,12 +7,11 @@
7
7
 
8
8
  import { complete } from "@earendil-works/pi-ai";
9
9
  import type { ScoutDecision } from "./types.ts";
10
- import { buildScoutUserMessage } from "./scout-prompt.ts";
11
10
 
12
- /** Minimal type for side agent context — avoids importing pi-ai types directly. */
11
+ /** Minimal type for side agent context — matches pi-ai's Context interface. */
13
12
  interface SideAgentContext {
14
13
  systemPrompt?: string;
15
- messages: Array<{ role: string; content: string }>;
14
+ messages: Array<{ role: "user"; content: string; timestamp: number }>;
16
15
  }
17
16
 
18
17
  /**
@@ -22,8 +21,7 @@ interface SideAgentContext {
22
21
  * @param apiKey - API key for the side model
23
22
  * @param headers - Custom headers for the side model
24
23
  * @param systemPrompt - Scout system prompt (includes skills/roles for cache friendliness)
25
- * @param userPrompt - The user's original prompt text
26
- * @param currentRole - Current active role name
24
+ * @param userMessage - Fully assembled user message (includes context, current role, user prompt)
27
25
  * @returns Parsed ScoutDecision, or a safe fallback on error
28
26
  */
29
27
  export async function callSideAgent(
@@ -31,8 +29,7 @@ export async function callSideAgent(
31
29
  apiKey: string | undefined,
32
30
  headers: Record<string, string> | undefined,
33
31
  systemPrompt: string,
34
- userPrompt: string,
35
- currentRole: string,
32
+ userMessage: string,
36
33
  ): Promise<ScoutDecision> {
37
34
  const fallback: ScoutDecision = { skills: [], role: null, reasoning: "side agent error" };
38
35
 
@@ -41,7 +38,8 @@ export async function callSideAgent(
41
38
  messages: [
42
39
  {
43
40
  role: "user",
44
- content: buildScoutUserMessage(userPrompt, currentRole),
41
+ content: userMessage,
42
+ timestamp: Date.now(),
45
43
  },
46
44
  ],
47
45
  };
package/src/types.ts CHANGED
@@ -33,6 +33,6 @@ export const DEFAULT_CONFIG: ScoutConfig = {
33
33
  maxSelectedSkills: 5,
34
34
  modules: {
35
35
  skillRouter: true,
36
- modelRouter: true,
36
+ modelRouter: false,
37
37
  },
38
38
  };