@d3ara1n/pi-scout 0.1.0 → 1.0.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/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
  ```
@@ -27,7 +53,7 @@ before_agent_start hook fires
27
53
  ## Requirements
28
54
 
29
55
  - **@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
56
+ - A cheap role must be defined in `modelRoles` configuration or use the default (may be much more expensive) instead
31
57
 
32
58
  ## Installation
33
59
 
@@ -47,7 +73,7 @@ Edit `~/.pi/agent/settings.json`:
47
73
  "maxSelectedSkills": 5,
48
74
  "modules": {
49
75
  "skillRouter": true,
50
- "modelRouter": true
76
+ "modelRouter": false
51
77
  }
52
78
  }
53
79
  }
@@ -56,18 +82,18 @@ Edit `~/.pi/agent/settings.json`:
56
82
  | Field | Default | Description |
57
83
  |-------|---------|-------------|
58
84
  | `enabled` | `true` | Global on/off |
59
- | `sideAgentRole` | `"fast"` | pi-model-roles role for the side agent |
85
+ | `sideAgentRole` | `"utility"` | pi-model-roles role for the side agent |
60
86
  | `maxSelectedSkills` | `5` | Max skills the side agent can select |
61
87
  | `modules.skillRouter` | `true` | Enable/disable skill routing |
62
- | `modules.modelRouter` | `true` | Enable/disable model routing |
88
+ | `modules.modelRouter` | `false` | Enable/disable model routing (disabled by default to avoid cache inefficiency and extra costs) |
63
89
 
64
90
  ## Commands
65
91
 
66
92
  | Command | Description |
67
93
  |---------|-------------|
68
94
  | `/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 |
95
+ | `/scout:skill-router on/off` | Toggle skill-router module |
96
+ | `/scout:model-router on/off` | Toggle model-router module |
71
97
 
72
98
  ## Performance
73
99
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-scout",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
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 ────────────────────────
@@ -198,29 +225,43 @@ export default function scoutExtension(pi: ExtensionAPI) {
198
225
  ? (rolesApi.findRoleByModel(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
199
226
  : "unknown";
200
227
 
201
- // 3. Call side agent
202
- const scoutSystemPrompt = buildScoutSystemPrompt(config);
228
+ // 3. Build roles list
203
229
  const visibleRoles = rolesApi.getVisibleRoles();
204
230
  const rolesList = Object.entries(visibleRoles)
205
231
  .map(([name, cfg]: [string, any]) => `- ${name}: ${cfg.description ?? "(no description)"}${cfg.model ? ` (model: ${cfg.model})` : " (current model)"}`)
206
232
  .join("\n");
233
+
234
+ // 4. Build user message with conversation context
235
+ const prevTurnContext = prevTurn?.assistantSummary
236
+ ? { userPrompt: prevTurn.userPrompt, assistantSummary: prevTurn.assistantSummary }
237
+ : undefined;
238
+ const userMessage = buildScoutUserMessage(event.prompt, currentRole, prevTurnContext);
239
+
240
+ // Reset prevTurn for the current turn — user prompt now, assistant filled by turn_end
241
+ prevTurn = { userPrompt: event.prompt, assistantSummary: "" };
242
+
243
+ // 5. Call side agent
244
+ const scoutSystemPrompt = buildScoutSystemPrompt(config, skillsList, rolesList);
207
245
  const decision = await callSideAgent(
208
246
  sideResolved.model,
209
247
  sideResolved.apiKey,
210
248
  sideResolved.headers,
211
249
  scoutSystemPrompt,
212
- event.prompt,
213
- skillsList,
214
- currentRole,
215
- rolesList,
250
+ userMessage,
216
251
  );
217
252
 
253
+ // Enforce module toggles at the application layer, regardless of what
254
+ // the side agent returned. A disabled module's decision field is zeroed
255
+ // so the status bar, /scout command, and apply logic all see clean
256
+ // values (no misleading "→ fast" when model-router is off).
257
+ if (!config.modules.modelRouter) decision.role = null;
258
+ if (!config.modules.skillRouter) decision.skills = [];
218
259
  lastDecision = decision;
219
260
 
220
261
  let systemPrompt = event.systemPrompt;
221
262
  let switchedRole: string | undefined;
222
263
 
223
- // 4. skill-router: filter skills XML to only selected ones
264
+ // 6. skill-router: filter skills XML to only selected ones
224
265
  if (config.modules.skillRouter) {
225
266
  systemPrompt = filterSkillsBlock(
226
267
  systemPrompt,
@@ -229,7 +270,7 @@ export default function scoutExtension(pi: ExtensionAPI) {
229
270
  );
230
271
  }
231
272
 
232
- // 5. model-router: switch model if side agent recommends a different role
273
+ // 7. model-router: switch model if side agent recommends a different role
233
274
  if (config.modules.modelRouter && decision.role && decision.role !== currentRole) {
234
275
  const switched = await switchToRole(pi, decision.role, rolesApi);
235
276
  if (switched) {
@@ -241,10 +282,10 @@ export default function scoutExtension(pi: ExtensionAPI) {
241
282
  }
242
283
  }
243
284
 
244
- // 6. Show result in status bar
285
+ // 8. Show result in status bar
245
286
  ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
246
287
 
247
- // 7. Return modified system prompt
288
+ // 9. Return modified system prompt
248
289
  if (systemPrompt !== event.systemPrompt) {
249
290
  return { systemPrompt };
250
291
  }
@@ -1,36 +1,61 @@
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.
15
+ *
16
+ * Includes previous turn context (when available) so the side agent can
17
+ * understand follow-up prompts like "continue", "change that", etc.
9
18
  */
10
19
  export function buildScoutUserMessage(
11
20
  userPrompt: string,
12
- skillsList: string,
13
21
  currentRole: string,
14
- rolesList: string,
22
+ prevTurn?: PrevTurnContext,
15
23
  ): string {
16
- return [
17
- `User prompt:`,
18
- userPrompt,
19
- ``,
20
- `Available skills:`,
21
- skillsList || "(none)",
22
- ``,
23
- `Current role: ${currentRole}`,
24
- ``,
25
- `Available roles:`,
26
- rolesList,
27
- ].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");
28
44
  }
29
45
 
30
46
  /**
31
47
  * Build the system prompt for the side agent.
48
+ *
49
+ * Stable per-session data (skills, roles) is embedded here rather than in the
50
+ * user message so that the entire system prompt forms a large, cacheable prefix.
51
+ * This is critical for Anthropic which requires a 1024-token minimum for
52
+ * prompt caching to activate.
32
53
  */
33
- export function buildScoutSystemPrompt(config: ScoutConfig): string {
54
+ export function buildScoutSystemPrompt(
55
+ config: ScoutConfig,
56
+ skillsList: string,
57
+ rolesList: string,
58
+ ): string {
34
59
  const parts: string[] = [];
35
60
 
36
61
  parts.push(`You are a scout. Analyze the user's request and decide which skills and model role to use.`);
@@ -50,13 +75,24 @@ export function buildScoutSystemPrompt(config: ScoutConfig): string {
50
75
  parts.push(`- "role" should be null if the current role is appropriate.`);
51
76
  parts.push(`- Only suggest a role change when the task clearly benefits from a different model.`);
52
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").`);
53
79
 
54
- if (!config.modules.modelRouter) {
55
- 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)");
56
90
  }
57
91
 
58
- if (!config.modules.skillRouter) {
59
- 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);
60
96
  }
61
97
 
62
98
  return parts.join("\n");
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
  /**
@@ -21,10 +20,8 @@ interface SideAgentContext {
21
20
  * @param sideModel - The Model instance to use (from pi-model-roles "side" role)
22
21
  * @param apiKey - API key for the side model
23
22
  * @param headers - Custom headers for the side model
24
- * @param systemPrompt - Scout system prompt
25
- * @param userPrompt - The user's original prompt text
26
- * @param skillsList - Formatted list of available skills for the prompt
27
- * @param currentRole - Current active role name
23
+ * @param systemPrompt - Scout system prompt (includes skills/roles for cache friendliness)
24
+ * @param userMessage - Fully assembled user message (includes context, current role, user prompt)
28
25
  * @returns Parsed ScoutDecision, or a safe fallback on error
29
26
  */
30
27
  export async function callSideAgent(
@@ -32,10 +29,7 @@ export async function callSideAgent(
32
29
  apiKey: string | undefined,
33
30
  headers: Record<string, string> | undefined,
34
31
  systemPrompt: string,
35
- userPrompt: string,
36
- skillsList: string,
37
- currentRole: string,
38
- rolesList: string,
32
+ userMessage: string,
39
33
  ): Promise<ScoutDecision> {
40
34
  const fallback: ScoutDecision = { skills: [], role: null, reasoning: "side agent error" };
41
35
 
@@ -44,13 +38,15 @@ export async function callSideAgent(
44
38
  messages: [
45
39
  {
46
40
  role: "user",
47
- content: buildScoutUserMessage(userPrompt, skillsList, currentRole, rolesList),
41
+ content: userMessage,
42
+ timestamp: Date.now(),
48
43
  },
49
44
  ],
50
45
  };
51
46
 
52
47
  const options: Record<string, any> = {
53
48
  maxTokens: 256,
49
+ cacheRetention: "short",
54
50
  };
55
51
 
56
52
  if (apiKey) options.apiKey = apiKey;
package/src/types.ts CHANGED
@@ -29,10 +29,10 @@ export interface ScoutDecision {
29
29
 
30
30
  export const DEFAULT_CONFIG: ScoutConfig = {
31
31
  enabled: true,
32
- sideAgentRole: "fast",
32
+ sideAgentRole: "utility",
33
33
  maxSelectedSkills: 5,
34
34
  modules: {
35
35
  skillRouter: true,
36
- modelRouter: true,
36
+ modelRouter: false,
37
37
  },
38
38
  };