@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 +78 -0
- package/package.json +40 -0
- package/src/config.ts +68 -0
- package/src/index.ts +252 -0
- package/src/model-switch.ts +37 -0
- package/src/scout-prompt.ts +63 -0
- package/src/side-agent.ts +104 -0
- package/src/skill-inject.ts +83 -0
- package/src/types.ts +38 -0
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, "&")
|
|
79
|
+
.replace(/</g, "<")
|
|
80
|
+
.replace(/>/g, ">")
|
|
81
|
+
.replace(/"/g, """)
|
|
82
|
+
.replace(/'/g, "'");
|
|
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
|
+
};
|