@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 +33 -8
- package/package.json +2 -1
- package/src/index.ts +58 -15
- package/src/scout-prompt.ts +46 -21
- package/src/side-agent.ts +6 -8
- package/src/types.ts +1 -1
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
|
-
##
|
|
53
|
+
## Dependencies
|
|
28
54
|
|
|
29
|
-
-
|
|
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":
|
|
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` | `"
|
|
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` | `
|
|
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
|
|
70
|
-
| `/scout
|
|
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
|
|
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
|
-
// ──
|
|
149
|
-
pi.on("
|
|
150
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
287
|
+
// 8. Show result in status bar
|
|
245
288
|
ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
|
|
246
289
|
|
|
247
|
-
//
|
|
290
|
+
// 9. Return modified system prompt
|
|
248
291
|
if (systemPrompt !== event.systemPrompt) {
|
|
249
292
|
return { systemPrompt };
|
|
250
293
|
}
|
package/src/scout-prompt.ts
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Side agent
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 (
|
|
62
|
-
parts.push(
|
|
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 —
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
41
|
+
content: userMessage,
|
|
42
|
+
timestamp: Date.now(),
|
|
45
43
|
},
|
|
46
44
|
],
|
|
47
45
|
};
|