@phi-code-admin/phi-code 0.57.8 → 0.58.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/extensions/phi/init.ts +1 -1
- package/extensions/phi/skill-loader.ts +69 -299
- package/extensions/phi/smart-router.ts +37 -154
- package/package.json +7 -5
package/extensions/phi/init.ts
CHANGED
|
@@ -620,7 +620,7 @@ _Edit this file to customize Phi Code's behavior for your project._
|
|
|
620
620
|
"\n\n**Local providers** (start the server):\n" +
|
|
621
621
|
" • Ollama: `ollama serve` (default port 11434)\n" +
|
|
622
622
|
" • LM Studio: Start server in app (default port 1234)\n" +
|
|
623
|
-
"\n💡
|
|
623
|
+
"\n💡 Options: Alibaba Coding Plan (cloud), OpenAI, Anthropic, or Ollama/LM Studio (local)", "error");
|
|
624
624
|
return;
|
|
625
625
|
}
|
|
626
626
|
|
|
@@ -1,348 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skill Loader Extension - Dynamic skill loading and context injection
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Uses sigma-skills SkillScanner and SkillLoader for skill discovery and matching.
|
|
5
|
+
* Skills are folders containing SKILL.md files with specialized knowledge.
|
|
6
|
+
* When skill-related keywords are detected in user input, the model is notified
|
|
7
|
+
* and can load the full skill content via the `read` tool.
|
|
8
|
+
*
|
|
9
|
+
* Discovery locations (in priority order):
|
|
10
|
+
* 1. .phi/skills/ (project-local, highest priority)
|
|
11
|
+
* 2. ~/.phi/agent/skills/ (global user skills)
|
|
12
|
+
* 3. Bundled skills shipped with the package (lowest priority)
|
|
8
13
|
*
|
|
9
14
|
* Features:
|
|
10
15
|
* - Automatic skill discovery at startup
|
|
11
16
|
* - Keyword-based skill detection and loading
|
|
12
17
|
* - /skills command to list available skills
|
|
13
|
-
* - Contextual skill
|
|
14
|
-
*
|
|
15
|
-
* Usage:
|
|
16
|
-
* 1. Copy to packages/coding-agent/extensions/phi/skill-loader.ts
|
|
17
|
-
* 2. Create skill directories with SKILL.md files
|
|
18
|
-
* 3. Skills auto-load when relevant keywords are detected
|
|
18
|
+
* - Contextual skill notification via ui.notify
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { ExtensionAPI, ExtensionContext } from "phi-code";
|
|
22
|
-
import {
|
|
22
|
+
import { SkillScanner, SkillLoader } from "sigma-skills";
|
|
23
|
+
import type { SkillsConfig } from "sigma-skills";
|
|
23
24
|
import { join } from "node:path";
|
|
24
25
|
import { homedir } from "node:os";
|
|
25
26
|
|
|
26
|
-
interface Skill {
|
|
27
|
-
name: string;
|
|
28
|
-
path: string;
|
|
29
|
-
content: string;
|
|
30
|
-
keywords: string[];
|
|
31
|
-
description: string;
|
|
32
|
-
source: "global" | "local" | "bundled";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
27
|
export default function skillLoaderExtension(pi: ExtensionAPI) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Extract keywords and description from SKILL.md content
|
|
44
|
-
*/
|
|
45
|
-
function parseSkillContent(content: string): { keywords: string[]; description: string } {
|
|
46
|
-
const lines = content.split('\n');
|
|
47
|
-
const keywords: string[] = [];
|
|
48
|
-
let description = "";
|
|
49
|
-
|
|
50
|
-
// Look for keywords in frontmatter or explicit sections
|
|
51
|
-
let inFrontmatter = false;
|
|
52
|
-
let foundKeywords = false;
|
|
53
|
-
|
|
54
|
-
for (const line of lines) {
|
|
55
|
-
const trimmed = line.trim();
|
|
56
|
-
|
|
57
|
-
// YAML frontmatter
|
|
58
|
-
if (trimmed === '---') {
|
|
59
|
-
inFrontmatter = !inFrontmatter;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (inFrontmatter) {
|
|
64
|
-
if (trimmed.startsWith('keywords:') || trimmed.startsWith('tags:')) {
|
|
65
|
-
const keywordLine = trimmed.split(':')[1];
|
|
66
|
-
if (keywordLine) {
|
|
67
|
-
// Parse YAML array or comma-separated
|
|
68
|
-
const parsed = keywordLine
|
|
69
|
-
.replace(/[\[\]]/g, '')
|
|
70
|
-
.split(',')
|
|
71
|
-
.map(k => k.trim().toLowerCase())
|
|
72
|
-
.filter(k => k.length > 0);
|
|
73
|
-
keywords.push(...parsed);
|
|
74
|
-
foundKeywords = true;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (trimmed.startsWith('description:')) {
|
|
78
|
-
description = trimmed.split(':', 2)[1]?.trim() || "";
|
|
79
|
-
}
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Look for explicit keywords section
|
|
84
|
-
if (trimmed.toLowerCase().includes('keywords:') || trimmed.toLowerCase().includes('tags:')) {
|
|
85
|
-
const keywordText = trimmed.split(':')[1];
|
|
86
|
-
if (keywordText) {
|
|
87
|
-
const parsed = keywordText
|
|
88
|
-
.split(',')
|
|
89
|
-
.map(k => k.trim().toLowerCase())
|
|
90
|
-
.filter(k => k.length > 0);
|
|
91
|
-
keywords.push(...parsed);
|
|
92
|
-
foundKeywords = true;
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Use first heading as description if none found
|
|
98
|
-
if (!description && trimmed.startsWith('#')) {
|
|
99
|
-
description = trimmed.replace(/^#+\s*/, '').trim();
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// If no explicit keywords, derive from content
|
|
104
|
-
if (!foundKeywords) {
|
|
105
|
-
const contentLower = content.toLowerCase();
|
|
106
|
-
const commonKeywords = [
|
|
107
|
-
'docker', 'kubernetes', 'aws', 'azure', 'gcp', 'terraform',
|
|
108
|
-
'python', 'javascript', 'typescript', 'react', 'node',
|
|
109
|
-
'git', 'github', 'gitlab', 'ci/cd', 'devops',
|
|
110
|
-
'database', 'postgresql', 'mysql', 'mongodb', 'redis',
|
|
111
|
-
'api', 'rest', 'graphql', 'microservices',
|
|
112
|
-
'security', 'auth', 'oauth', 'jwt',
|
|
113
|
-
'test', 'testing', 'unit', 'integration',
|
|
114
|
-
'deploy', 'deployment', 'production'
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
for (const keyword of commonKeywords) {
|
|
118
|
-
if (contentLower.includes(keyword)) {
|
|
119
|
-
keywords.push(keyword);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Also include skill directory name as keyword
|
|
124
|
-
const skillName = description.toLowerCase().replace(/\s+/g, '-');
|
|
125
|
-
if (skillName) keywords.push(skillName);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { keywords, description };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Load skills from a directory
|
|
133
|
-
*/
|
|
134
|
-
async function loadSkillsFromDirectory(directory: string, source: "global" | "local" | "bundled"): Promise<Skill[]> {
|
|
135
|
-
const skills: Skill[] = [];
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
await access(directory);
|
|
139
|
-
const entries = await readdir(directory);
|
|
140
|
-
|
|
141
|
-
for (const entry of entries) {
|
|
142
|
-
const skillPath = join(directory, entry);
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
const skillStat = await stat(skillPath);
|
|
146
|
-
if (!skillStat.isDirectory()) continue;
|
|
147
|
-
|
|
148
|
-
const skillFilePath = join(skillPath, "SKILL.md");
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
await access(skillFilePath);
|
|
152
|
-
const content = await readFile(skillFilePath, 'utf-8');
|
|
153
|
-
const { keywords, description } = parseSkillContent(content);
|
|
154
|
-
|
|
155
|
-
skills.push({
|
|
156
|
-
name: entry,
|
|
157
|
-
path: skillPath,
|
|
158
|
-
content,
|
|
159
|
-
keywords,
|
|
160
|
-
description: description || entry,
|
|
161
|
-
source
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
} catch {
|
|
165
|
-
// No SKILL.md file, skip this directory
|
|
166
|
-
}
|
|
167
|
-
} catch {
|
|
168
|
-
// Can't access directory, skip
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
} catch {
|
|
172
|
-
// Directory doesn't exist, return empty array
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return skills;
|
|
176
|
-
}
|
|
28
|
+
const config: SkillsConfig = {
|
|
29
|
+
globalDir: join(homedir(), ".phi", "agent", "skills"),
|
|
30
|
+
projectDir: join(process.cwd(), ".phi", "skills"),
|
|
31
|
+
bundledDir: join(__dirname, "..", "..", "..", "skills"),
|
|
32
|
+
autoInject: true,
|
|
33
|
+
};
|
|
177
34
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*/
|
|
181
|
-
async function loadAllSkills(): Promise<void> {
|
|
182
|
-
const bundledSkills = await loadSkillsFromDirectory(bundledSkillsDir, "bundled");
|
|
183
|
-
const globalSkills = await loadSkillsFromDirectory(globalSkillsDir, "global");
|
|
184
|
-
const localSkills = await loadSkillsFromDirectory(localSkillsDir, "local");
|
|
185
|
-
|
|
186
|
-
// Merge: local > global > bundled (local overrides global overrides bundled)
|
|
187
|
-
const seen = new Set<string>();
|
|
188
|
-
availableSkills = [];
|
|
189
|
-
for (const skills of [localSkills, globalSkills, bundledSkills]) {
|
|
190
|
-
for (const skill of skills) {
|
|
191
|
-
if (!seen.has(skill.name)) {
|
|
192
|
-
seen.add(skill.name);
|
|
193
|
-
availableSkills.push(skill);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
console.log(`Loaded ${availableSkills.length} skills (${bundledSkills.length} bundled, ${globalSkills.length} global, ${localSkills.length} local)`);
|
|
199
|
-
}
|
|
35
|
+
const scanner = new SkillScanner(config);
|
|
36
|
+
const loader = new SkillLoader(scanner);
|
|
200
37
|
|
|
201
|
-
|
|
202
|
-
* Find relevant skills for input text
|
|
203
|
-
*/
|
|
204
|
-
function findRelevantSkills(text: string): Skill[] {
|
|
205
|
-
const textLower = text.toLowerCase();
|
|
206
|
-
const relevantSkills: Array<{ skill: Skill; matchCount: number }> = [];
|
|
38
|
+
// ─── Input Event: Match skills to user input ─────────────────────
|
|
207
39
|
|
|
208
|
-
for (const skill of availableSkills) {
|
|
209
|
-
let matches = 0;
|
|
210
|
-
|
|
211
|
-
// Check for keyword matches
|
|
212
|
-
for (const keyword of skill.keywords) {
|
|
213
|
-
if (textLower.includes(keyword)) {
|
|
214
|
-
matches++;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Check for skill name match
|
|
219
|
-
if (textLower.includes(skill.name.toLowerCase())) {
|
|
220
|
-
matches += 2; // Name match gets higher weight
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (matches > 0) {
|
|
224
|
-
relevantSkills.push({ skill, matchCount: matches });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Sort by match count (most relevant first) and return top 3
|
|
229
|
-
return relevantSkills
|
|
230
|
-
.sort((a, b) => b.matchCount - a.matchCount)
|
|
231
|
-
.slice(0, 3)
|
|
232
|
-
.map(item => item.skill);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Input interceptor for skill detection
|
|
237
|
-
*/
|
|
238
40
|
pi.on("input", async (event, ctx) => {
|
|
239
|
-
// Skip if this is an extension-generated message
|
|
240
41
|
if (event.source === "extension") {
|
|
241
42
|
return { action: "continue" };
|
|
242
43
|
}
|
|
243
44
|
|
|
244
|
-
|
|
245
|
-
const relevantSkills = findRelevantSkills(event.text);
|
|
246
|
-
|
|
247
|
-
if (relevantSkills.length > 0) {
|
|
248
|
-
// Inject skill content into context
|
|
249
|
-
for (const skill of relevantSkills) {
|
|
250
|
-
const skillMessage = `Skill Context: ${skill.name}
|
|
251
|
-
|
|
252
|
-
${skill.content}
|
|
253
|
-
|
|
254
|
-
---
|
|
255
|
-
This skill was automatically loaded based on your request. Use this knowledge to assist with the task.`;
|
|
45
|
+
const matches = loader.findRelevantSkills(event.text);
|
|
256
46
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
47
|
+
if (matches.length > 0) {
|
|
48
|
+
// Notify about top matches (max 3)
|
|
49
|
+
const topMatches = matches.slice(0, 3);
|
|
50
|
+
for (const match of topMatches) {
|
|
51
|
+
ctx.ui.notify(
|
|
52
|
+
`📚 Relevant skill: **${match.skill.name}** — ${match.skill.description}\nUse \`read ${match.skill.path}/SKILL.md\` for full content.`,
|
|
53
|
+
"info"
|
|
54
|
+
);
|
|
260
55
|
}
|
|
261
56
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
ctx.ui.notify(`🧠 Loaded ${relevantSkills.length} skill(s): ${skillNames}`, "info");
|
|
57
|
+
const skillNames = topMatches.map(m => m.skill.name).join(", ");
|
|
58
|
+
ctx.ui.notify(`🧠 Loaded ${topMatches.length} skill(s): ${skillNames}`, "info");
|
|
265
59
|
}
|
|
266
60
|
|
|
267
61
|
return { action: "continue" };
|
|
268
62
|
});
|
|
269
63
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
*/
|
|
64
|
+
// ─── /skills Command ─────────────────────────────────────────────
|
|
65
|
+
|
|
273
66
|
pi.registerCommand("skills", {
|
|
274
|
-
description: "List available skills or show skill
|
|
275
|
-
getArgumentCompletions: (prefix) => {
|
|
276
|
-
const skillNames = availableSkills.map(s => s.name);
|
|
277
|
-
const filtered = skillNames.filter(name => name.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
278
|
-
return filtered.length > 0 ? filtered.map(name => ({ value: name, label: name })) : null;
|
|
279
|
-
},
|
|
67
|
+
description: "List available skills or show details for a specific skill",
|
|
280
68
|
handler: async (args, ctx) => {
|
|
281
|
-
const
|
|
69
|
+
const query = args.trim();
|
|
282
70
|
|
|
283
|
-
if (!
|
|
71
|
+
if (!query) {
|
|
284
72
|
// List all skills
|
|
285
|
-
|
|
286
|
-
|
|
73
|
+
const skills = loader.listSkills();
|
|
74
|
+
|
|
75
|
+
if (skills.length === 0) {
|
|
76
|
+
ctx.ui.notify(
|
|
77
|
+
"No skills found. Create skill directories with SKILL.md files in:\n" +
|
|
78
|
+
`- \`${config.projectDir}\` (project-local)\n` +
|
|
79
|
+
`- \`${config.globalDir}\` (global)\n` +
|
|
80
|
+
"Or install bundled skills via `/phi-init`.",
|
|
81
|
+
"info"
|
|
82
|
+
);
|
|
287
83
|
return;
|
|
288
84
|
}
|
|
289
85
|
|
|
290
|
-
let message =
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const localSkills = availableSkills.filter(s => s.source === "local");
|
|
295
|
-
|
|
296
|
-
if (globalSkills.length > 0) {
|
|
297
|
-
message += "**Global Skills:**\n";
|
|
298
|
-
globalSkills.forEach(skill => {
|
|
299
|
-
const keywords = skill.keywords.slice(0, 3).join(", ");
|
|
300
|
-
message += `- **${skill.name}** - ${skill.description}\n`;
|
|
301
|
-
message += ` Keywords: ${keywords}\n\n`;
|
|
302
|
-
});
|
|
86
|
+
let message = `**📚 Available Skills (${skills.length}):**\n\n`;
|
|
87
|
+
for (const skill of skills) {
|
|
88
|
+
message += ` **${skill.name}** — ${skill.description}\n`;
|
|
89
|
+
message += ` 📁 \`${skill.path}\`\n`;
|
|
303
90
|
}
|
|
304
|
-
|
|
305
|
-
if (localSkills.length > 0) {
|
|
306
|
-
message += "**Project Skills:**\n";
|
|
307
|
-
localSkills.forEach(skill => {
|
|
308
|
-
const keywords = skill.keywords.slice(0, 3).join(", ");
|
|
309
|
-
message += `- **${skill.name}** - ${skill.description}\n`;
|
|
310
|
-
message += ` Keywords: ${keywords}\n\n`;
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
message += "\nUse `/skills <name>` to view a specific skill.";
|
|
315
|
-
|
|
91
|
+
message += `\nUse \`/skills <name>\` for details.`;
|
|
316
92
|
ctx.ui.notify(message, "info");
|
|
317
|
-
|
|
318
93
|
} else {
|
|
319
94
|
// Show specific skill
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
ctx.ui.notify(
|
|
324
|
-
|
|
95
|
+
const content = loader.getSkillContext(query);
|
|
96
|
+
if (content) {
|
|
97
|
+
const skill = loader.listSkills().find(s => s.name === query);
|
|
98
|
+
ctx.ui.notify(
|
|
99
|
+
`**📚 Skill: ${query}**\n\n` +
|
|
100
|
+
`Path: \`${skill?.path || "unknown"}\`\n` +
|
|
101
|
+
`Keywords: ${skill?.keywords.slice(0, 10).join(", ") || "none"}\n\n` +
|
|
102
|
+
`---\n\n${content.slice(0, 2000)}${content.length > 2000 ? "\n\n... (truncated, use `read` for full content)" : ""}`,
|
|
103
|
+
"info"
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
ctx.ui.notify(`Skill "${query}" not found. Use \`/skills\` to list available skills.`, "warning");
|
|
325
107
|
}
|
|
326
|
-
|
|
327
|
-
const message = `🧠 **Skill: ${skill.name}**
|
|
328
|
-
|
|
329
|
-
**Description:** ${skill.description}
|
|
330
|
-
**Source:** ${skill.source}
|
|
331
|
-
**Path:** ${skill.path}
|
|
332
|
-
**Keywords:** ${skill.keywords.join(", ")}
|
|
333
|
-
|
|
334
|
-
**Content Preview:**
|
|
335
|
-
${skill.content.slice(0, 500)}${skill.content.length > 500 ? "..." : ""}`;
|
|
336
|
-
|
|
337
|
-
ctx.ui.notify(message, "info");
|
|
338
108
|
}
|
|
339
109
|
},
|
|
340
110
|
});
|
|
341
111
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
*/
|
|
112
|
+
// ─── Session Start: Load skills ──────────────────────────────────
|
|
113
|
+
|
|
345
114
|
pi.on("session_start", async (_event, _ctx) => {
|
|
346
|
-
|
|
115
|
+
const skills = loader.listSkills();
|
|
116
|
+
console.log(`[skill-loader] Loaded ${skills.length} skills from 3 locations`);
|
|
347
117
|
});
|
|
348
|
-
}
|
|
118
|
+
}
|
|
@@ -1,105 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Smart Router Extension - Intelligent model routing for different task types
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - debug: fix, bug, error, crash → qwen3-max-2026-01-23
|
|
7
|
-
* - explore: read, analyze, explain, understand → kimi-k2.5
|
|
8
|
-
* - plan: plan, design, architect, spec → qwen3-max-2026-01-23
|
|
9
|
-
* - test: test, verify, validate, check → kimi-k2.5
|
|
10
|
-
* - review: review, audit, quality, security → qwen3.5-plus
|
|
4
|
+
* Uses sigma-agents SmartRouter for task classification and model recommendation.
|
|
5
|
+
* Analyzes user input keywords and suggests the optimal model per task category.
|
|
11
6
|
*
|
|
12
|
-
* Configuration: ~/.phi/agent/routing.json
|
|
7
|
+
* Configuration: ~/.phi/agent/routing.json
|
|
13
8
|
* Command: /routing — show config, enable/disable, test, reload
|
|
14
9
|
*/
|
|
15
10
|
|
|
16
11
|
import type { ExtensionAPI } from "phi-code";
|
|
12
|
+
import { SmartRouter } from "sigma-agents";
|
|
13
|
+
import type { RoutingConfig, TaskCategory } from "sigma-agents";
|
|
17
14
|
import { readFile, mkdir, writeFile, access } from "node:fs/promises";
|
|
18
15
|
import { join } from "node:path";
|
|
19
16
|
import { homedir } from "node:os";
|
|
20
17
|
|
|
21
|
-
// ───
|
|
18
|
+
// ─── Extension Config ────────────────────────────────────────────────────
|
|
22
19
|
|
|
23
|
-
interface
|
|
24
|
-
description: string;
|
|
25
|
-
keywords: string[];
|
|
26
|
-
preferredModel: string;
|
|
27
|
-
fallback: string;
|
|
28
|
-
agent: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface RoutingConfig {
|
|
32
|
-
routes: Record<string, RouteEntry>;
|
|
33
|
-
default: { model: string; agent: string | null };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface FullConfig {
|
|
37
|
-
routing: RoutingConfig;
|
|
20
|
+
interface ExtensionConfig {
|
|
38
21
|
enabled: boolean;
|
|
39
22
|
notifyOnRecommendation: boolean;
|
|
40
23
|
}
|
|
41
24
|
|
|
42
|
-
// ─── Defaults (aligned with config/routing.json and default-models.json) ──
|
|
43
|
-
|
|
44
|
-
const DEFAULT_ROUTING: RoutingConfig = {
|
|
45
|
-
routes: {
|
|
46
|
-
code: {
|
|
47
|
-
description: "Code generation, implementation, refactoring",
|
|
48
|
-
keywords: ["implement", "create", "build", "refactor", "write", "add", "modify", "update", "generate", "code", "develop", "function", "class"],
|
|
49
|
-
preferredModel: "qwen3-coder-plus",
|
|
50
|
-
fallback: "qwen3.5-plus",
|
|
51
|
-
agent: "code",
|
|
52
|
-
},
|
|
53
|
-
debug: {
|
|
54
|
-
description: "Debugging, fixing, error resolution",
|
|
55
|
-
keywords: ["fix", "bug", "error", "debug", "crash", "broken", "failing", "issue", "troubleshoot", "repair", "solve"],
|
|
56
|
-
preferredModel: "qwen3-max-2026-01-23",
|
|
57
|
-
fallback: "qwen3.5-plus",
|
|
58
|
-
agent: "code",
|
|
59
|
-
},
|
|
60
|
-
explore: {
|
|
61
|
-
description: "Code reading, analysis, understanding",
|
|
62
|
-
keywords: ["read", "analyze", "explain", "understand", "find", "search", "look", "show", "what", "how", "explore", "examine"],
|
|
63
|
-
preferredModel: "kimi-k2.5",
|
|
64
|
-
fallback: "glm-4.7",
|
|
65
|
-
agent: "explore",
|
|
66
|
-
},
|
|
67
|
-
plan: {
|
|
68
|
-
description: "Architecture, design, planning",
|
|
69
|
-
keywords: ["plan", "design", "architect", "spec", "structure", "organize", "strategy", "approach", "roadmap"],
|
|
70
|
-
preferredModel: "qwen3-max-2026-01-23",
|
|
71
|
-
fallback: "qwen3.5-plus",
|
|
72
|
-
agent: "plan",
|
|
73
|
-
},
|
|
74
|
-
test: {
|
|
75
|
-
description: "Testing, validation, verification",
|
|
76
|
-
keywords: ["test", "verify", "validate", "check", "assert", "coverage", "unit", "integration", "e2e"],
|
|
77
|
-
preferredModel: "kimi-k2.5",
|
|
78
|
-
fallback: "glm-4.7",
|
|
79
|
-
agent: "test",
|
|
80
|
-
},
|
|
81
|
-
review: {
|
|
82
|
-
description: "Code review, quality assessment",
|
|
83
|
-
keywords: ["review", "audit", "quality", "security", "improve", "optimize", "refine", "critique"],
|
|
84
|
-
preferredModel: "qwen3.5-plus",
|
|
85
|
-
fallback: "qwen3-max-2026-01-23",
|
|
86
|
-
agent: "review",
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
default: {
|
|
90
|
-
model: "qwen3.5-plus",
|
|
91
|
-
agent: null,
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
|
|
95
25
|
// ─── Extension ───────────────────────────────────────────────────────────
|
|
96
26
|
|
|
97
27
|
export default function smartRouterExtension(pi: ExtensionAPI) {
|
|
98
28
|
const configDir = join(homedir(), ".phi", "agent");
|
|
99
29
|
const configPath = join(configDir, "routing.json");
|
|
100
30
|
|
|
101
|
-
let
|
|
102
|
-
|
|
31
|
+
let router = new SmartRouter(SmartRouter.defaultConfig());
|
|
32
|
+
let extConfig: ExtensionConfig = {
|
|
103
33
|
enabled: true,
|
|
104
34
|
notifyOnRecommendation: true,
|
|
105
35
|
};
|
|
@@ -109,99 +39,54 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
|
|
|
109
39
|
*/
|
|
110
40
|
async function loadConfig() {
|
|
111
41
|
try {
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
const userConfig = JSON.parse(text);
|
|
115
|
-
|
|
116
|
-
// Support both flat format (routes at top level) and wrapped format
|
|
117
|
-
if (userConfig.routes) {
|
|
118
|
-
config.routing = {
|
|
119
|
-
routes: { ...DEFAULT_ROUTING.routes, ...userConfig.routes },
|
|
120
|
-
default: userConfig.default || DEFAULT_ROUTING.default,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (typeof userConfig.enabled === "boolean") config.enabled = userConfig.enabled;
|
|
125
|
-
if (typeof userConfig.notifyOnRecommendation === "boolean") config.notifyOnRecommendation = userConfig.notifyOnRecommendation;
|
|
42
|
+
const config = await SmartRouter.loadConfig(configPath);
|
|
43
|
+
router = new SmartRouter(config);
|
|
126
44
|
} catch {
|
|
127
45
|
// No config file — use defaults, and save them for reference
|
|
128
46
|
try {
|
|
129
47
|
await mkdir(configDir, { recursive: true });
|
|
130
|
-
|
|
48
|
+
const defaultConfig = SmartRouter.defaultConfig();
|
|
49
|
+
await writeFile(configPath, JSON.stringify(defaultConfig, null, 2), "utf-8");
|
|
131
50
|
} catch {
|
|
132
51
|
// Can't write, that's fine
|
|
133
52
|
}
|
|
134
53
|
}
|
|
135
54
|
}
|
|
136
55
|
|
|
137
|
-
/**
|
|
138
|
-
* Analyze input text to classify task type
|
|
139
|
-
*/
|
|
140
|
-
function classifyTask(text: string): { category: string | null; confidence: number; matches: string[]; route: RouteEntry | null } {
|
|
141
|
-
const lower = text.toLowerCase();
|
|
142
|
-
const results: Array<{ category: string; confidence: number; matches: string[]; route: RouteEntry }> = [];
|
|
143
|
-
|
|
144
|
-
for (const [category, route] of Object.entries(config.routing.routes)) {
|
|
145
|
-
const matches: string[] = [];
|
|
146
|
-
|
|
147
|
-
for (const keyword of route.keywords) {
|
|
148
|
-
if (lower.includes(keyword.toLowerCase())) {
|
|
149
|
-
matches.push(keyword);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (matches.length > 0) {
|
|
154
|
-
// Confidence = weighted match ratio
|
|
155
|
-
// More matches = higher confidence, but cap at 95%
|
|
156
|
-
const ratio = matches.length / route.keywords.length;
|
|
157
|
-
const confidence = Math.min(95, Math.round(ratio * 100 + matches.length * 5));
|
|
158
|
-
results.push({ category, confidence, matches, route });
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (results.length === 0) {
|
|
163
|
-
return { category: null, confidence: 0, matches: [], route: null };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Highest confidence wins
|
|
167
|
-
results.sort((a, b) => b.confidence - a.confidence);
|
|
168
|
-
return results[0];
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── Input Event ─────────────────────────────────────────────────
|
|
172
|
-
|
|
173
56
|
/**
|
|
174
57
|
* Resolve a model name to an available model.
|
|
175
58
|
* If the preferred model exists in the registry, use it.
|
|
176
59
|
* Otherwise, fall back to the current model.
|
|
177
60
|
*/
|
|
178
61
|
function resolveModel(preferredModel: string, ctx: any): string {
|
|
179
|
-
// Check if model exists in registry
|
|
180
62
|
try {
|
|
181
63
|
const available = ctx.modelRegistry?.getAvailable?.() || [];
|
|
182
64
|
if (available.some((m: any) => m.id === preferredModel)) {
|
|
183
65
|
return preferredModel;
|
|
184
66
|
}
|
|
185
|
-
// Model not available — use current model
|
|
186
67
|
return ctx.model?.id || preferredModel;
|
|
187
68
|
} catch {
|
|
188
69
|
return ctx.model?.id || preferredModel;
|
|
189
70
|
}
|
|
190
71
|
}
|
|
191
72
|
|
|
73
|
+
// ─── Input Event ─────────────────────────────────────────────────
|
|
74
|
+
|
|
192
75
|
pi.on("input", async (event, ctx) => {
|
|
193
|
-
if (!
|
|
76
|
+
if (!extConfig.enabled || event.source === "extension") {
|
|
194
77
|
return { action: "continue" };
|
|
195
78
|
}
|
|
196
79
|
|
|
197
|
-
const
|
|
80
|
+
const recommendation = router.getRecommendation(event.text);
|
|
198
81
|
|
|
199
|
-
if (
|
|
200
|
-
if (
|
|
201
|
-
const resolved = resolveModel(
|
|
202
|
-
const suffix = resolved !==
|
|
82
|
+
if (recommendation.category !== "general") {
|
|
83
|
+
if (extConfig.notifyOnRecommendation) {
|
|
84
|
+
const resolved = resolveModel(recommendation.model, ctx);
|
|
85
|
+
const suffix = resolved !== recommendation.model ? ` (→ ${resolved})` : "";
|
|
86
|
+
const route = (router as any).config?.routes?.[recommendation.category];
|
|
87
|
+
const desc = route?.keywords?.slice(0, 3)?.join(", ") || recommendation.category;
|
|
203
88
|
ctx.ui.notify(
|
|
204
|
-
`🔀 ${
|
|
89
|
+
`🔀 ${recommendation.category} → \`${recommendation.model}\`${suffix} (${desc})`,
|
|
205
90
|
"info"
|
|
206
91
|
);
|
|
207
92
|
}
|
|
@@ -218,17 +103,17 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
|
|
|
218
103
|
const arg = args.trim().toLowerCase();
|
|
219
104
|
|
|
220
105
|
if (!arg) {
|
|
221
|
-
|
|
222
|
-
let output = `**🔀 Smart Router
|
|
223
|
-
output += `Status: ${
|
|
224
|
-
output += `Notifications: ${
|
|
106
|
+
const routingConfig = (router as any).config as RoutingConfig;
|
|
107
|
+
let output = `**🔀 Smart Router** (powered by sigma-agents)\n\n`;
|
|
108
|
+
output += `Status: ${extConfig.enabled ? "✅ Enabled" : "❌ Disabled"}\n`;
|
|
109
|
+
output += `Notifications: ${extConfig.notifyOnRecommendation ? "On" : "Off"}\n\n`;
|
|
225
110
|
|
|
226
111
|
output += `**Routes:**\n`;
|
|
227
|
-
for (const [cat, route] of Object.entries(
|
|
228
|
-
output += ` **${cat}** → \`${route.preferredModel}\` (fallback: \`${route.fallback}\`) [agent: ${route.agent}]\n`;
|
|
112
|
+
for (const [cat, route] of Object.entries(routingConfig.routes)) {
|
|
113
|
+
output += ` **${cat}** → \`${route.preferredModel}\` (fallback: \`${route.fallback}\`) [agent: ${route.agent || "none"}]\n`;
|
|
229
114
|
output += ` Keywords: ${route.keywords.slice(0, 6).join(", ")}${route.keywords.length > 6 ? "..." : ""}\n`;
|
|
230
115
|
}
|
|
231
|
-
output += `\n **default** → \`${
|
|
116
|
+
output += `\n **default** → \`${routingConfig.default.model}\`\n`;
|
|
232
117
|
|
|
233
118
|
output += `\nConfig: \`${configPath}\``;
|
|
234
119
|
output += `\nCommands: \`/routing enable|disable|notify-on|notify-off|reload|test\``;
|
|
@@ -239,19 +124,19 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
|
|
|
239
124
|
|
|
240
125
|
switch (arg) {
|
|
241
126
|
case "enable":
|
|
242
|
-
|
|
127
|
+
extConfig.enabled = true;
|
|
243
128
|
ctx.ui.notify("✅ Smart routing enabled.", "info");
|
|
244
129
|
break;
|
|
245
130
|
case "disable":
|
|
246
|
-
|
|
131
|
+
extConfig.enabled = false;
|
|
247
132
|
ctx.ui.notify("❌ Smart routing disabled.", "info");
|
|
248
133
|
break;
|
|
249
134
|
case "notify-on":
|
|
250
|
-
|
|
135
|
+
extConfig.notifyOnRecommendation = true;
|
|
251
136
|
ctx.ui.notify("🔔 Routing notifications enabled.", "info");
|
|
252
137
|
break;
|
|
253
138
|
case "notify-off":
|
|
254
|
-
|
|
139
|
+
extConfig.notifyOnRecommendation = false;
|
|
255
140
|
ctx.ui.notify("🔕 Routing notifications disabled.", "info");
|
|
256
141
|
break;
|
|
257
142
|
case "reload":
|
|
@@ -271,10 +156,8 @@ export default function smartRouterExtension(pi: ExtensionAPI) {
|
|
|
271
156
|
|
|
272
157
|
let output = "**🧪 Routing Test:**\n\n";
|
|
273
158
|
for (const input of tests) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
const tag = result.category || "default";
|
|
277
|
-
output += `"${input}"\n → **${tag}** (${result.confidence}%) → \`${model}\`\n\n`;
|
|
159
|
+
const rec = router.getRecommendation(input);
|
|
160
|
+
output += `"${input}"\n → **${rec.category}** → \`${rec.model}\`\n\n`;
|
|
278
161
|
}
|
|
279
162
|
ctx.ui.notify(output, "info");
|
|
280
163
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phi-code-admin/phi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"piConfig": {
|
|
@@ -43,10 +43,6 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@mariozechner/jiti": "^2.6.2",
|
|
46
|
-
"phi-code-agent": "^0.56.3",
|
|
47
|
-
"phi-code-ai": "^0.56.3",
|
|
48
|
-
"phi-code-tui": "^0.56.3",
|
|
49
|
-
"sigma-memory": "^0.1.0",
|
|
50
46
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
|
51
47
|
"chalk": "^5.5.0",
|
|
52
48
|
"cli-highlight": "^2.1.11",
|
|
@@ -58,7 +54,13 @@
|
|
|
58
54
|
"ignore": "^7.0.5",
|
|
59
55
|
"marked": "^15.0.12",
|
|
60
56
|
"minimatch": "^10.2.3",
|
|
57
|
+
"phi-code-agent": "^0.56.3",
|
|
58
|
+
"phi-code-ai": "^0.56.3",
|
|
59
|
+
"phi-code-tui": "^0.56.3",
|
|
61
60
|
"proper-lockfile": "^4.1.2",
|
|
61
|
+
"sigma-agents": "^0.1.1",
|
|
62
|
+
"sigma-memory": "^0.1.0",
|
|
63
|
+
"sigma-skills": "^0.1.0",
|
|
62
64
|
"strip-ansi": "^7.1.0",
|
|
63
65
|
"undici": "^7.19.1",
|
|
64
66
|
"yaml": "^2.8.2"
|