@d3ara1n/pi-scout 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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @d3ara1n/pi-scout
2
+
3
+ Per-turn side agent decision framework for [pi](https://github.com/earendil-works/pi).
4
+
5
+ Before each conversation turn, a cheap side agent model analyzes the user's prompt and makes routing decisions:
6
+
7
+ 1. **skill-router** — Selects which skills to activate and injects their full content (replacing pi's default skill metadata list)
8
+ 2. **model-router** — Automatically switches the active model role based on task complexity
9
+
10
+ Both modules can be independently toggled on/off.
11
+
12
+ ## How it works
13
+
14
+ ```
15
+ User sends prompt
16
+
17
+
18
+ before_agent_start hook fires
19
+
20
+ ├─ Side agent (cheap model) analyzes prompt + available skills + current role
21
+ ├─ Returns: { skills: [...], role: "...", reasoning: "..." }
22
+
23
+ ├─ [skill-router] Strips <available_skills> XML, injects selected skill SKILL.md content
24
+ └─ [model-router] Switches model if a different role is recommended
25
+ ```
26
+
27
+ ## Requirements
28
+
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
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pi extension add @d3ara1n/pi-scout
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Edit `~/.pi/agent/settings.json`:
41
+
42
+ ```jsonc
43
+ {
44
+ "scout": {
45
+ "enabled": true,
46
+ "sideAgentRole": "fast",
47
+ "maxSelectedSkills": 5,
48
+ "modules": {
49
+ "skillRouter": true,
50
+ "modelRouter": true
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ | Field | Default | Description |
57
+ |-------|---------|-------------|
58
+ | `enabled` | `true` | Global on/off |
59
+ | `sideAgentRole` | `"fast"` | pi-model-roles role for the side agent |
60
+ | `maxSelectedSkills` | `5` | Max skills the side agent can select |
61
+ | `modules.skillRouter` | `true` | Enable/disable skill routing |
62
+ | `modules.modelRouter` | `true` | Enable/disable model routing |
63
+
64
+ ## Commands
65
+
66
+ | Command | Description |
67
+ |---------|-------------|
68
+ | `/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 |
71
+
72
+ ## Performance
73
+
74
+ Side agent adds ~0.5–2s latency per turn. Output is limited to 256 tokens.
75
+
76
+ ## License
77
+
78
+ MIT
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@d3ara1n/pi-scout",
3
+ "version": "0.1.0",
4
+ "description": "Per-turn side agent decision framework for pi — uses a cheap model to select skills and route models before each conversation turn",
5
+ "main": "src/index.ts",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi"
9
+ ],
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-ai": "*",
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@d3ara1n/pi-model-roles": "*"
14
+ },
15
+ "peerDependenciesMeta": {
16
+ "@earendil-works/pi-ai": {
17
+ "optional": true
18
+ },
19
+ "@earendil-works/pi-coding-agent": {
20
+ "optional": true
21
+ },
22
+ "@d3ara1n/pi-model-roles": {
23
+ "optional": true
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "@d3ara1n/pi-model-roles": "^0.1.0"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/d3ara1n/pi-extensions",
37
+ "directory": "packages/pi-scout"
38
+ },
39
+ "license": "MIT"
40
+ }
package/src/config.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Read scout configuration from settings files.
3
+ *
4
+ * Global (~/.pi/agent/settings.json) + project (.pi/settings.json),
5
+ * project overrides global.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { ScoutConfig } from "./types.ts";
12
+ import { DEFAULT_CONFIG } from "./types.ts";
13
+
14
+ function getAgentDir(): string {
15
+ const envDir = process.env.PI_AGENT_DIR;
16
+ if (envDir) return envDir;
17
+ return path.join(os.homedir(), ".pi", "agent");
18
+ }
19
+
20
+ function readSettingsFile(filePath: string): any {
21
+ try {
22
+ if (!fs.existsSync(filePath)) return {};
23
+ const content = fs.readFileSync(filePath, "utf-8");
24
+ const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
25
+ return JSON.parse(stripped);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function merge(target: any, source: any): any {
32
+ if (!source || typeof source !== "object") return target;
33
+ if (!target || typeof target !== "object") return source;
34
+ const result = { ...target };
35
+ for (const key of Object.keys(source)) {
36
+ if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
37
+ result[key] = merge(result[key], source[key]);
38
+ } else {
39
+ result[key] = source[key];
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Load scout config from merged settings.
47
+ * @param cwd - Project working directory
48
+ */
49
+ export function loadScoutConfig(cwd?: string): ScoutConfig {
50
+ const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
51
+ const projectSettings = cwd
52
+ ? readSettingsFile(path.join(cwd, ".pi", "settings.json"))
53
+ : {};
54
+ const settings = merge(globalSettings, projectSettings);
55
+
56
+ const raw = settings?.scout;
57
+ if (!raw) return DEFAULT_CONFIG;
58
+
59
+ return {
60
+ enabled: raw.enabled ?? DEFAULT_CONFIG.enabled,
61
+ sideAgentRole: raw.sideAgentRole ?? DEFAULT_CONFIG.sideAgentRole,
62
+ maxSelectedSkills: raw.maxSelectedSkills ?? DEFAULT_CONFIG.maxSelectedSkills,
63
+ modules: {
64
+ skillRouter: raw.modules?.skillRouter ?? DEFAULT_CONFIG.modules.skillRouter,
65
+ modelRouter: raw.modules?.modelRouter ?? DEFAULT_CONFIG.modules.modelRouter,
66
+ },
67
+ };
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * pi-scout — Per-turn side agent decision framework.
3
+ *
4
+ * Before each conversation turn, a cheap side agent model analyzes the user prompt
5
+ * and decides:
6
+ * 1. Which skills to inject (skill-router module)
7
+ * 2. Whether to switch model roles (model-router module)
8
+ *
9
+ * Both modules can be independently toggled via /scout:* commands.
10
+ * Scout progress and results are shown in the status bar.
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
15
+ import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
16
+ import type { ScoutConfig, ScoutDecision } from "./types.ts";
17
+ import { DEFAULT_CONFIG } from "./types.ts";
18
+ import { loadScoutConfig } from "./config.ts";
19
+ import { callSideAgent } from "./side-agent.ts";
20
+ import { buildScoutSystemPrompt } from "./scout-prompt.ts";
21
+ import { filterSkillsBlock, resetSkillCache } from "./skill-inject.ts";
22
+ import { switchToRole } from "./model-switch.ts";
23
+
24
+ const STATUS_KEY = "scout";
25
+
26
+ /** Build a one-line status summary from a scout decision. */
27
+ function formatDecisionStatus(decision: ScoutDecision, theme: any): string {
28
+ const parts: string[] = [];
29
+
30
+ if (decision.skills.length > 0) {
31
+ const names = decision.skills.length <= 3
32
+ ? decision.skills.join(", ")
33
+ : `${decision.skills.slice(0, 2).join(", ")} +${decision.skills.length - 2}`;
34
+ parts.push(theme.fg("accent", `skills: ${names}`));
35
+ }
36
+ if (decision.role) {
37
+ parts.push(theme.fg("warning", `→ ${decision.role}`));
38
+ }
39
+
40
+ if (parts.length === 0) {
41
+ return theme.fg("dim", "✓ scout: no changes");
42
+ }
43
+
44
+ return theme.fg("success", "✓ scout:") + " " + parts.join(" | ");
45
+ }
46
+
47
+ export default function scoutExtension(pi: ExtensionAPI) {
48
+ let config: ScoutConfig = DEFAULT_CONFIG;
49
+ let lastDecision: ScoutDecision | undefined;
50
+
51
+ function tryGetRolesApi(ctx: ExtensionContext): ModelRolesAPI | undefined {
52
+ try {
53
+ return getModelRolesAPI();
54
+ } catch {
55
+ ctx.ui.notify(
56
+ "pi-model-roles not loaded. Ensure @d3ara1n/pi-model-roles is in extensions and restart.",
57
+ "error",
58
+ );
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ // ── /scout — show status ────────────────────────────────────────
64
+ pi.registerCommand("scout", {
65
+ description: "Show scout status and last decision",
66
+ handler: async (_args, ctx) => {
67
+ const rolesApi = tryGetRolesApi(ctx);
68
+ const sideRole = rolesApi?.getRole(config.sideAgentRole);
69
+ const theme = ctx.ui.theme;
70
+ const lines = [
71
+ `Scout: ${config.enabled ? "enabled" : "disabled"}`,
72
+ `Side agent role: ${config.sideAgentRole} (${sideRole?.model ?? "current model"})`,
73
+ ``,
74
+ `Modules:`,
75
+ ` skill-router: ${config.modules.skillRouter ? "on" : "off"}`,
76
+ ` model-router: ${config.modules.modelRouter ? "on" : "off"}`,
77
+ ];
78
+
79
+ if (lastDecision) {
80
+ lines.push(``);
81
+ lines.push(`Last decision:`);
82
+ lines.push(` skills: ${lastDecision.skills.length > 0 ? lastDecision.skills.join(", ") : "(none)"}`);
83
+ lines.push(` role: ${lastDecision.role ?? "(no change)"}`);
84
+ lines.push(` reasoning: ${lastDecision.reasoning}`);
85
+ }
86
+
87
+ ctx.ui.notify(lines.join("\n"), "info");
88
+ },
89
+ });
90
+
91
+ // ── /scout:skill-router on/off ──────────────────────────────────
92
+ pi.registerCommand("scout:skill-router", {
93
+ description: "Toggle skill-router module (on/off)",
94
+ handler: async (args, ctx) => {
95
+ const value = (args ?? "").trim().toLowerCase();
96
+ if (value === "on") {
97
+ config.modules.skillRouter = true;
98
+ ctx.ui.notify("Scout: skill-router enabled", "info");
99
+ } else if (value === "off") {
100
+ config.modules.skillRouter = false;
101
+ ctx.ui.notify("Scout: skill-router disabled", "info");
102
+ } else {
103
+ ctx.ui.notify("Usage: /scout:skill-router on|off", "info");
104
+ }
105
+ },
106
+ });
107
+
108
+ // ── /scout:model-router on/off ──────────────────────────────────
109
+ pi.registerCommand("scout:model-router", {
110
+ description: "Toggle model-router module (on/off)",
111
+ handler: async (args, ctx) => {
112
+ const value = (args ?? "").trim().toLowerCase();
113
+ if (value === "on") {
114
+ config.modules.modelRouter = true;
115
+ ctx.ui.notify("Scout: model-router enabled", "info");
116
+ } else if (value === "off") {
117
+ config.modules.modelRouter = false;
118
+ ctx.ui.notify("Scout: model-router disabled", "info");
119
+ } else {
120
+ ctx.ui.notify("Usage: /scout:model-router on|off", "info");
121
+ }
122
+ },
123
+ });
124
+
125
+ // ── list_skills tool ───────────────────────────────────────────
126
+ let cachedAllSkills: Array<{ name: string; description: string; filePath: string }> = [];
127
+
128
+ pi.registerTool({
129
+ name: "list_skills",
130
+ label: "List all skills",
131
+ description: "List all available skills with name and description. Use this when the user asks what skills are installed or you need to discover skills beyond those currently active.",
132
+ parameters: { type: "object", properties: {}, required: [] } as any,
133
+ async execute() {
134
+ if (cachedAllSkills.length === 0) {
135
+ return { content: [{ type: "text", text: "No skills available." }] };
136
+ }
137
+ const lines = cachedAllSkills.map((s) => `- **${s.name}**: ${s.description}`);
138
+ return { content: [{ type: "text", text: lines.join("\n") }] };
139
+ },
140
+ });
141
+
142
+ // ── session_start: load config ──────────────────────────────────
143
+ pi.on("session_start", async (_event, ctx) => {
144
+ config = loadScoutConfig(ctx.cwd);
145
+ resetSkillCache();
146
+ });
147
+
148
+ // ── Clear status at turn start ──────────────────────────────────
149
+ pi.on("turn_start", async () => {
150
+ // Will be overwritten by before_agent_start if scout runs
151
+ });
152
+
153
+ // ── before_agent_start: core scout logic ────────────────────────
154
+ pi.on("before_agent_start", async (event, ctx) => {
155
+ if (!config.enabled) return;
156
+ if (!config.modules.skillRouter && !config.modules.modelRouter) return;
157
+
158
+ let rolesApi: ModelRolesAPI;
159
+ try {
160
+ rolesApi = getModelRolesAPI();
161
+ } catch {
162
+ console.warn("[pi-scout] pi-model-roles not initialized — skipping scout");
163
+ return;
164
+ }
165
+
166
+ const theme = ctx.ui.theme;
167
+
168
+ // Show "Scouting..." indicator
169
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("accent", "◎") + theme.fg("dim", " Scouting..."));
170
+
171
+ // Resolve side agent model
172
+ const sideResolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
173
+ if (!sideResolved.model) {
174
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", "◎ scout: side model unavailable"));
175
+ console.warn(`[pi-scout] Side agent role "${config.sideAgentRole}" not available — skipping`);
176
+ return;
177
+ }
178
+
179
+ // Update status: resolving
180
+ ctx.ui.setStatus(STATUS_KEY, theme.fg("accent", "◎") + theme.fg("dim", ` Scouting via ${sideResolved.model.provider}/${sideResolved.model.id}...`));
181
+
182
+ // 1. Get available skills from systemPromptOptions
183
+ const skills = event.systemPromptOptions?.skills ?? [];
184
+ if (cachedAllSkills.length === 0 && skills.length > 0) {
185
+ cachedAllSkills = skills.map((s: any) => ({
186
+ name: s.name,
187
+ description: s.description ?? "",
188
+ filePath: s.filePath,
189
+ }));
190
+ }
191
+ const skillsList = skills
192
+ .map((s: any) => `- ${s.name}: ${s.description ?? "(no description)"}`)
193
+ .join("\n");
194
+
195
+ // 2. Determine current role
196
+ const currentModel = ctx.model;
197
+ const currentRole = currentModel
198
+ ? (rolesApi.findRoleByModel(`${currentModel.provider}/${currentModel.id}`) ?? "unknown")
199
+ : "unknown";
200
+
201
+ // 3. Call side agent
202
+ const scoutSystemPrompt = buildScoutSystemPrompt(config);
203
+ const visibleRoles = rolesApi.getVisibleRoles();
204
+ const rolesList = Object.entries(visibleRoles)
205
+ .map(([name, cfg]: [string, any]) => `- ${name}: ${cfg.description ?? "(no description)"}${cfg.model ? ` (model: ${cfg.model})` : " (current model)"}`)
206
+ .join("\n");
207
+ const decision = await callSideAgent(
208
+ sideResolved.model,
209
+ sideResolved.apiKey,
210
+ sideResolved.headers,
211
+ scoutSystemPrompt,
212
+ event.prompt,
213
+ skillsList,
214
+ currentRole,
215
+ rolesList,
216
+ );
217
+
218
+ lastDecision = decision;
219
+
220
+ let systemPrompt = event.systemPrompt;
221
+ let switchedRole: string | undefined;
222
+
223
+ // 4. skill-router: filter skills XML to only selected ones
224
+ if (config.modules.skillRouter) {
225
+ systemPrompt = filterSkillsBlock(
226
+ systemPrompt,
227
+ decision.skills,
228
+ skills.map((s: any) => ({ name: s.name, description: s.description ?? "", filePath: s.filePath })),
229
+ );
230
+ }
231
+
232
+ // 5. model-router: switch model if side agent recommends a different role
233
+ if (config.modules.modelRouter && decision.role && decision.role !== currentRole) {
234
+ const switched = await switchToRole(pi, decision.role, rolesApi);
235
+ if (switched) {
236
+ switchedRole = decision.role;
237
+ const newModel = await rolesApi.resolveRoleAsync(decision.role);
238
+ if (newModel?.model) {
239
+ systemPrompt += `\n\n<current_model>${newModel.model.provider}/${newModel.model.id} (role: ${decision.role})</current_model>`;
240
+ }
241
+ }
242
+ }
243
+
244
+ // 6. Show result in status bar
245
+ ctx.ui.setStatus(STATUS_KEY, formatDecisionStatus(decision, theme));
246
+
247
+ // 7. Return modified system prompt
248
+ if (systemPrompt !== event.systemPrompt) {
249
+ return { systemPrompt };
250
+ }
251
+ });
252
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Model role switching logic.
3
+ *
4
+ * Caller only needs to check resolved.model — it's always a real model or undefined.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
9
+
10
+ /**
11
+ * Switch the active model to the given role.
12
+ * @returns true if the switch was successful
13
+ */
14
+ export async function switchToRole(
15
+ pi: ExtensionAPI,
16
+ roleName: string,
17
+ rolesApi: ModelRolesAPI,
18
+ ): Promise<boolean> {
19
+ const resolved = await rolesApi.resolveRoleAsync(roleName);
20
+
21
+ if (!resolved.model) {
22
+ console.warn(`[pi-scout] Role "${roleName}" could not be resolved — model not available`);
23
+ return false;
24
+ }
25
+
26
+ const success = await pi.setModel(resolved.model);
27
+ if (!success) {
28
+ console.warn(`[pi-scout] setModel() returned false for role "${roleName}" — no API key?`);
29
+ return false;
30
+ }
31
+
32
+ if (resolved.config.thinking) {
33
+ pi.setThinkingLevel(resolved.config.thinking);
34
+ }
35
+
36
+ return true;
37
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Side agent system prompt — instructs the model to return a structured JSON decision.
3
+ */
4
+
5
+ import type { ScoutConfig } from "./types.ts";
6
+
7
+ /**
8
+ * Build the user message for the side agent.
9
+ */
10
+ export function buildScoutUserMessage(
11
+ userPrompt: string,
12
+ skillsList: string,
13
+ currentRole: string,
14
+ rolesList: string,
15
+ ): 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");
28
+ }
29
+
30
+ /**
31
+ * Build the system prompt for the side agent.
32
+ */
33
+ export function buildScoutSystemPrompt(config: ScoutConfig): string {
34
+ const parts: string[] = [];
35
+
36
+ parts.push(`You are a scout. Analyze the user's request and decide which skills and model role to use.`);
37
+ parts.push(``);
38
+ parts.push(`## Response Format`);
39
+ parts.push(`Respond with ONLY a JSON object, no markdown, no explanation outside the JSON:`);
40
+ parts.push(`{`);
41
+ parts.push(` "skills": ["skill-name-1", "skill-name-2"],`);
42
+ parts.push(` "role": "role-name-or-null",`);
43
+ parts.push(` "reasoning": "one sentence explanation"`);
44
+ parts.push(`}`);
45
+ parts.push(``);
46
+ parts.push(`## Rules`);
47
+ parts.push(`- Select at most ${config.maxSelectedSkills} skills. Select 0 if none are relevant.`);
48
+ parts.push(`- Only select skills that will materially help with the task.`);
49
+ parts.push(`- If the task is trivial (simple question, acknowledgment), select 0 skills.`);
50
+ parts.push(`- "role" should be null if the current role is appropriate.`);
51
+ parts.push(`- Only suggest a role change when the task clearly benefits from a different model.`);
52
+ parts.push(`- Be conservative: prefer fewer skills and no role change when uncertain.`);
53
+
54
+ if (!config.modules.modelRouter) {
55
+ parts.push(`- IMPORTANT: model routing is disabled. Always return role: null.`);
56
+ }
57
+
58
+ if (!config.modules.skillRouter) {
59
+ parts.push(`- IMPORTANT: skill routing is disabled. Always return skills: [].`);
60
+ }
61
+
62
+ return parts.join("\n");
63
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Side agent invocation logic.
3
+ *
4
+ * Calls the side agent model using pi-ai's complete() function
5
+ * and parses the JSON decision response.
6
+ */
7
+
8
+ import { complete } from "@earendil-works/pi-ai";
9
+ import type { ScoutDecision } from "./types.ts";
10
+ import { buildScoutUserMessage } from "./scout-prompt.ts";
11
+
12
+ /** Minimal type for side agent context — avoids importing pi-ai types directly. */
13
+ interface SideAgentContext {
14
+ systemPrompt?: string;
15
+ messages: Array<{ role: string; content: string }>;
16
+ }
17
+
18
+ /**
19
+ * Call the side agent and return its decision.
20
+ *
21
+ * @param sideModel - The Model instance to use (from pi-model-roles "side" role)
22
+ * @param apiKey - API key for the side model
23
+ * @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
28
+ * @returns Parsed ScoutDecision, or a safe fallback on error
29
+ */
30
+ export async function callSideAgent(
31
+ sideModel: any,
32
+ apiKey: string | undefined,
33
+ headers: Record<string, string> | undefined,
34
+ systemPrompt: string,
35
+ userPrompt: string,
36
+ skillsList: string,
37
+ currentRole: string,
38
+ rolesList: string,
39
+ ): Promise<ScoutDecision> {
40
+ const fallback: ScoutDecision = { skills: [], role: null, reasoning: "side agent error" };
41
+
42
+ const context: SideAgentContext = {
43
+ systemPrompt,
44
+ messages: [
45
+ {
46
+ role: "user",
47
+ content: buildScoutUserMessage(userPrompt, skillsList, currentRole, rolesList),
48
+ },
49
+ ],
50
+ };
51
+
52
+ const options: Record<string, any> = {
53
+ maxTokens: 256,
54
+ };
55
+
56
+ if (apiKey) options.apiKey = apiKey;
57
+ if (headers) options.headers = headers;
58
+
59
+ try {
60
+ const result = await complete(sideModel, context, options);
61
+ const text = result.content
62
+ ?.filter((block: any) => block.type === "text")
63
+ ?.map((block: any) => block.text)
64
+ ?.join("") ?? "";
65
+
66
+ return parseDecision(text);
67
+ } catch (err) {
68
+ console.warn("[pi-scout] Side agent call failed:", err);
69
+ return fallback;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Parse the side agent's JSON response into a ScoutDecision.
75
+ * Tolerant of markdown wrapping, extra whitespace, etc.
76
+ */
77
+ function parseDecision(raw: string): ScoutDecision {
78
+ const fallback: ScoutDecision = { skills: [], role: null, reasoning: "parse error" };
79
+
80
+ // Strip markdown code fences if present
81
+ let text = raw.trim();
82
+ if (text.startsWith("```")) {
83
+ text = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
84
+ }
85
+
86
+ try {
87
+ const parsed = JSON.parse(text);
88
+
89
+ return {
90
+ skills: Array.isArray(parsed.skills)
91
+ ? parsed.skills.filter((s: any) => typeof s === "string")
92
+ : [],
93
+ role: typeof parsed.role === "string" && parsed.role !== "null"
94
+ ? parsed.role
95
+ : null,
96
+ reasoning: typeof parsed.reasoning === "string"
97
+ ? parsed.reasoning
98
+ : "no reasoning provided",
99
+ };
100
+ } catch {
101
+ console.warn("[pi-scout] Failed to parse side agent response:", raw.slice(0, 200));
102
+ return fallback;
103
+ }
104
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Skill interception and injection with description caching.
3
+ *
4
+ * Replaces pi's default skills section (verbose intro + all skills)
5
+ * with a compact version containing only scout-selected skills.
6
+ *
7
+ * Description caching: skills already shown in a previous turn omit
8
+ * their description (the LLM already has it in conversation history).
9
+ * This significantly reduces per-turn token usage for recurring skills.
10
+ */
11
+
12
+ /** Match pi's entire skills section: intro paragraph + XML block. */
13
+ const SKILLS_SECTION_RE = /\n\nThe following skills provide specialized instructions[\s\S]*?<\/available_skills>/;
14
+
15
+ /** Track skill names already shown to the LLM in this session. */
16
+ let shownSkills: Set<string> = new Set();
17
+
18
+ /** Reset the cache — called on session_start. */
19
+ export function resetSkillCache(): void {
20
+ shownSkills = new Set();
21
+ }
22
+
23
+ /**
24
+ * Replace pi's default skills section with a compact, cached version.
25
+ *
26
+ * - First appearance of a skill: includes description
27
+ * - Subsequent appearances: description omitted (LLM already has it)
28
+ * - No skills selected: entire section removed
29
+ *
30
+ * @param systemPrompt - Full system prompt
31
+ * @param selectedSkills - Skill names chosen by the side agent
32
+ * @param allSkills - All loaded skills with their metadata
33
+ * @returns Modified system prompt
34
+ */
35
+ export function filterSkillsBlock(
36
+ systemPrompt: string,
37
+ selectedSkills: string[],
38
+ allSkills: Array<{ name: string; description: string; filePath: string }>,
39
+ ): string {
40
+ if (selectedSkills.length === 0) {
41
+ return systemPrompt.replace(SKILLS_SECTION_RE, "");
42
+ }
43
+
44
+ const skillMap = new Map(allSkills.map((s) => [s.name, s]));
45
+ const entries: string[] = [];
46
+ const newlyShown: string[] = [];
47
+
48
+ for (const name of selectedSkills) {
49
+ const skill = skillMap.get(name);
50
+ if (!skill) continue;
51
+
52
+ if (shownSkills.has(name)) {
53
+ // Already introduced — compact form
54
+ entries.push(` <skill name="${esc(skill.name)}" location="${esc(skill.filePath)}" />`);
55
+ } else {
56
+ // First time — include description
57
+ entries.push(` <skill name="${esc(skill.name)}" location="${esc(skill.filePath)}">${esc(skill.description)}</skill>`);
58
+ newlyShown.push(name);
59
+ }
60
+ }
61
+
62
+ if (entries.length === 0) {
63
+ return systemPrompt.replace(SKILLS_SECTION_RE, "");
64
+ }
65
+
66
+ // Update cache
67
+ for (const name of newlyShown) {
68
+ shownSkills.add(name);
69
+ }
70
+
71
+ const compact = `\n\nActive skills (use \`read\` to load a skill's file):\n<available_skills>\n${entries.join("\n")}\n</available_skills>`;
72
+
73
+ return systemPrompt.replace(SKILLS_SECTION_RE, compact);
74
+ }
75
+
76
+ function esc(str: string): string {
77
+ return str
78
+ .replace(/&/g, "&amp;")
79
+ .replace(/</g, "&lt;")
80
+ .replace(/>/g, "&gt;")
81
+ .replace(/"/g, "&quot;")
82
+ .replace(/'/g, "&apos;");
83
+ }
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared types for pi-scout.
3
+ */
4
+
5
+ /** Configuration for the scout extension, stored in settings.json. */
6
+ export interface ScoutConfig {
7
+ /** Whether scout is enabled globally */
8
+ enabled: boolean;
9
+ /** pi-model-roles role name to use for the side agent */
10
+ sideAgentRole: string;
11
+ /** Maximum number of skills the side agent can select */
12
+ maxSelectedSkills: number;
13
+ /** Module toggles */
14
+ modules: {
15
+ skillRouter: boolean;
16
+ modelRouter: boolean;
17
+ };
18
+ }
19
+
20
+ /** Decision returned by the side agent. */
21
+ export interface ScoutDecision {
22
+ /** Selected skill names */
23
+ skills: string[];
24
+ /** Suggested role name, or null if no change */
25
+ role: string | null;
26
+ /** Brief reasoning */
27
+ reasoning: string;
28
+ }
29
+
30
+ export const DEFAULT_CONFIG: ScoutConfig = {
31
+ enabled: true,
32
+ sideAgentRole: "fast",
33
+ maxSelectedSkills: 5,
34
+ modules: {
35
+ skillRouter: true,
36
+ modelRouter: true,
37
+ },
38
+ };