ultimate-pi 0.2.7 → 0.3.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/.agents/skills/harness-eval/SKILL.md +1 -1
- package/.agents/skills/harness-governor/SKILL.md +2 -2
- package/.agents/skills/harness-spec/SKILL.md +1 -1
- package/.pi/PACKAGING.md +3 -2
- package/.pi/extensions/custom-header.ts +0 -17
- package/.pi/extensions/pi-model-router-harness.ts +42 -0
- package/.pi/extensions/policy-gate.ts +18 -0
- package/.pi/extensions/provider-payload-sanitize.ts +66 -0
- package/.pi/extensions/sentrux-rules-sync.ts +0 -18
- package/.pi/harness/README.md +3 -2
- package/.pi/harness/docs/adrs/0004-defer-ci-agent-smoke.md +1 -1
- package/.pi/harness/docs/adrs/0006-sentrux-dual-layer.md +1 -1
- package/.pi/harness/docs/adrs/0009-sentrux-rules-lifecycle.md +2 -2
- package/.pi/harness/evals/smoke/README.md +1 -1
- package/.pi/harness/evolution/README.md +1 -1
- package/.pi/harness/evolution/chaos-drill.md +1 -1
- package/.pi/prompts/harness-setup.md +42 -35
- package/.pi/scripts/README.md +25 -9
- package/.pi/scripts/harness-cli-verify.sh +4 -2
- package/.pi/scripts/harness-seed-project-contracts.mjs +49 -0
- package/.pi/scripts/harness-sync-model-router.mjs +84 -0
- package/.pi/scripts/harness-verify.mjs +5 -3
- package/.pi/scripts/sentrux-rules-sync.mjs +2 -2
- package/.pi/scripts/vendor-sync-pi-model-router.sh +47 -0
- package/.pi/settings.example.json +0 -1
- package/.sentrux/rules.toml +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +1 -1
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/biome.json +2 -1
- package/package.json +9 -10
- package/vendor/pi-model-router/.prettierignore +4 -0
- package/vendor/pi-model-router/.prettierrc +5 -0
- package/vendor/pi-model-router/AGENTS.md +39 -0
- package/vendor/pi-model-router/LICENSE +21 -0
- package/vendor/pi-model-router/README.md +99 -0
- package/vendor/pi-model-router/UPSTREAM_PIN.md +8 -0
- package/vendor/pi-model-router/docs/ARCHITECTURE.md +54 -0
- package/vendor/pi-model-router/extensions/commands.ts +720 -0
- package/vendor/pi-model-router/extensions/config.ts +348 -0
- package/vendor/pi-model-router/extensions/constants.ts +1 -0
- package/vendor/pi-model-router/extensions/index.ts +457 -0
- package/vendor/pi-model-router/extensions/provider.ts +529 -0
- package/vendor/pi-model-router/extensions/routing.ts +416 -0
- package/vendor/pi-model-router/extensions/state.ts +49 -0
- package/vendor/pi-model-router/extensions/types.ts +86 -0
- package/vendor/pi-model-router/extensions/ui.ts +130 -0
- package/vendor/pi-model-router/model-router.example.json +48 -0
- package/vendor/pi-model-router/package.json +48 -0
- package/vendor/pi-model-router/tsconfig.json +16 -0
- package/.pi/extensions/model-router-bootstrap.ts +0 -174
- package/.sentrux/.harness-rules-meta.json +0 -5
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { streamSimple, type Context, type Message } from '@mariozechner/pi-ai';
|
|
2
|
+
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
3
|
+
import type {
|
|
4
|
+
RouterTier,
|
|
5
|
+
RouterPhase,
|
|
6
|
+
RouterProfile,
|
|
7
|
+
RoutingDecision,
|
|
8
|
+
RoutingRule,
|
|
9
|
+
RouterThinkingByTier,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
import { parseCanonicalModelRef, isRouterTier } from './config.js';
|
|
12
|
+
|
|
13
|
+
export const extractTextFromContent = (
|
|
14
|
+
content: string | Message['content'],
|
|
15
|
+
): string => {
|
|
16
|
+
if (typeof content === 'string') {
|
|
17
|
+
return content;
|
|
18
|
+
}
|
|
19
|
+
return content
|
|
20
|
+
.map((part) => {
|
|
21
|
+
if (part.type === 'text') return part.text;
|
|
22
|
+
if (part.type === 'thinking') return part.thinking;
|
|
23
|
+
if (part.type === 'toolCall')
|
|
24
|
+
return `${part.name} ${JSON.stringify(part.arguments)}`;
|
|
25
|
+
return '';
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join('\n');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const getLastUserText = (context: Context): string => {
|
|
32
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
33
|
+
const message = context.messages[i];
|
|
34
|
+
if (message.role === 'user') {
|
|
35
|
+
return extractTextFromContent(message.content).trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const getRecentConversationText = (
|
|
42
|
+
context: Context,
|
|
43
|
+
limit = 6,
|
|
44
|
+
): string => {
|
|
45
|
+
return context.messages
|
|
46
|
+
.slice(-limit)
|
|
47
|
+
.map((message) => extractTextFromContent(message.content).trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join('\n')
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const countToolResults = (context: Context): number => {
|
|
54
|
+
return context.messages.filter((message) => message.role === 'toolResult')
|
|
55
|
+
.length;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const countWords = (text: string): number => {
|
|
59
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const hasImageAttachment = (context: Context): boolean => {
|
|
63
|
+
return context.messages.some(
|
|
64
|
+
(message) =>
|
|
65
|
+
Array.isArray(message.content) &&
|
|
66
|
+
message.content.some((part) => part.type === 'image'),
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const containsAny = (text: string, keywords: string[]): boolean => {
|
|
71
|
+
return keywords.some((keyword) => text.includes(keyword));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const phaseForTier = (tier: RouterTier): RouterPhase => {
|
|
75
|
+
if (tier === 'high') return 'planning';
|
|
76
|
+
if (tier === 'medium') return 'implementation';
|
|
77
|
+
return 'lightweight';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const buildRoutingDecision = (
|
|
81
|
+
profileName: string,
|
|
82
|
+
profile: RouterProfile,
|
|
83
|
+
tier: RouterTier,
|
|
84
|
+
phase: RouterPhase,
|
|
85
|
+
reasoning: string,
|
|
86
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
87
|
+
isClassifier?: boolean,
|
|
88
|
+
): RoutingDecision => {
|
|
89
|
+
const routed = profile[tier];
|
|
90
|
+
const { provider, modelId } = parseCanonicalModelRef(routed.model);
|
|
91
|
+
const baseThinking =
|
|
92
|
+
routed.thinking ??
|
|
93
|
+
(tier === 'high' ? 'high' : tier === 'low' ? 'low' : 'medium');
|
|
94
|
+
const effectiveThinking = thinkingOverrides?.[tier] ?? baseThinking;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
profile: profileName,
|
|
98
|
+
tier,
|
|
99
|
+
phase,
|
|
100
|
+
targetProvider: provider,
|
|
101
|
+
targetModelId: modelId,
|
|
102
|
+
targetLabel: routed.model,
|
|
103
|
+
reasoning,
|
|
104
|
+
thinking: effectiveThinking,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
isClassifier,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const decideRouting = (
|
|
111
|
+
context: Context,
|
|
112
|
+
profileName: string,
|
|
113
|
+
profile: RouterProfile,
|
|
114
|
+
previousDecision: RoutingDecision | undefined,
|
|
115
|
+
pinnedTier?: RouterTier,
|
|
116
|
+
thinkingOverrides?: RouterThinkingByTier,
|
|
117
|
+
phaseBias = 0.5,
|
|
118
|
+
rules?: RoutingRule[],
|
|
119
|
+
isBudgetExceeded = false,
|
|
120
|
+
): RoutingDecision => {
|
|
121
|
+
const prompt = getLastUserText(context).toLowerCase();
|
|
122
|
+
const recentConversation = getRecentConversationText(context);
|
|
123
|
+
const toolResultCount = countToolResults(context);
|
|
124
|
+
const wordCount = countWords(prompt);
|
|
125
|
+
const multiLinePrompt = prompt.split('\n').length >= 4;
|
|
126
|
+
|
|
127
|
+
const explicitHighHints = [
|
|
128
|
+
'best',
|
|
129
|
+
'deep',
|
|
130
|
+
'deeply',
|
|
131
|
+
'carefully',
|
|
132
|
+
'thoroughly',
|
|
133
|
+
'robust',
|
|
134
|
+
'comprehensive',
|
|
135
|
+
'step by step',
|
|
136
|
+
'think hard',
|
|
137
|
+
'highest quality',
|
|
138
|
+
];
|
|
139
|
+
const explicitLowHints = [
|
|
140
|
+
'fast',
|
|
141
|
+
'cheap',
|
|
142
|
+
'quick',
|
|
143
|
+
'quickly',
|
|
144
|
+
'brief',
|
|
145
|
+
'briefly',
|
|
146
|
+
'one sentence',
|
|
147
|
+
'one line',
|
|
148
|
+
'tiny',
|
|
149
|
+
'small',
|
|
150
|
+
];
|
|
151
|
+
const planningKeywords = [
|
|
152
|
+
'plan',
|
|
153
|
+
'planning',
|
|
154
|
+
'architecture',
|
|
155
|
+
'architect',
|
|
156
|
+
'design',
|
|
157
|
+
'tradeoff',
|
|
158
|
+
'trade-off',
|
|
159
|
+
'research',
|
|
160
|
+
'investigate',
|
|
161
|
+
'root cause',
|
|
162
|
+
'analyze',
|
|
163
|
+
'analysis',
|
|
164
|
+
'migration',
|
|
165
|
+
'strategy',
|
|
166
|
+
'compare',
|
|
167
|
+
'options',
|
|
168
|
+
'approach',
|
|
169
|
+
];
|
|
170
|
+
const summaryKeywords = [
|
|
171
|
+
'summarize',
|
|
172
|
+
'summary',
|
|
173
|
+
'changelog',
|
|
174
|
+
'rewrite',
|
|
175
|
+
'reformat',
|
|
176
|
+
'format',
|
|
177
|
+
'rename',
|
|
178
|
+
'explain briefly',
|
|
179
|
+
'recap',
|
|
180
|
+
'tl;dr',
|
|
181
|
+
];
|
|
182
|
+
const implementationKeywords = [
|
|
183
|
+
'implement',
|
|
184
|
+
'code',
|
|
185
|
+
'fix',
|
|
186
|
+
'update',
|
|
187
|
+
'edit',
|
|
188
|
+
'write',
|
|
189
|
+
'refactor',
|
|
190
|
+
'add tests',
|
|
191
|
+
'patch',
|
|
192
|
+
'change',
|
|
193
|
+
'apply',
|
|
194
|
+
'continue',
|
|
195
|
+
'resume',
|
|
196
|
+
'make the changes',
|
|
197
|
+
'go ahead',
|
|
198
|
+
];
|
|
199
|
+
const lookupKeywords = [
|
|
200
|
+
'where is',
|
|
201
|
+
'which file',
|
|
202
|
+
'show me',
|
|
203
|
+
'list',
|
|
204
|
+
'what files',
|
|
205
|
+
'find',
|
|
206
|
+
'grep',
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
let phase: RouterPhase = previousDecision?.phase ?? 'implementation';
|
|
210
|
+
let tier: RouterTier = 'medium';
|
|
211
|
+
let reasoning = 'Defaulted to medium tier for general coding work.';
|
|
212
|
+
let isRuleMatched = false;
|
|
213
|
+
|
|
214
|
+
if (pinnedTier) {
|
|
215
|
+
phase = phaseForTier(pinnedTier);
|
|
216
|
+
tier = pinnedTier;
|
|
217
|
+
reasoning = `Pinned to ${pinnedTier} tier via /router-pin.`;
|
|
218
|
+
} else {
|
|
219
|
+
// Check custom rules first
|
|
220
|
+
if (rules) {
|
|
221
|
+
for (const rule of rules) {
|
|
222
|
+
const matches = Array.isArray(rule.matches)
|
|
223
|
+
? rule.matches
|
|
224
|
+
: [rule.matches];
|
|
225
|
+
if (containsAny(prompt, matches)) {
|
|
226
|
+
tier = rule.tier;
|
|
227
|
+
phase = phaseForTier(tier);
|
|
228
|
+
reasoning =
|
|
229
|
+
rule.reason ??
|
|
230
|
+
`Matched custom routing rule for: ${matches.join(', ')}`;
|
|
231
|
+
isRuleMatched = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!isRuleMatched) {
|
|
238
|
+
// Sticky phase adjustments
|
|
239
|
+
const highThreshold = Math.max(
|
|
240
|
+
40,
|
|
241
|
+
120 - (previousDecision?.phase === 'planning' ? phaseBias * 80 : 0),
|
|
242
|
+
);
|
|
243
|
+
const lowThreshold = Math.max(
|
|
244
|
+
4,
|
|
245
|
+
12 -
|
|
246
|
+
(previousDecision?.phase === 'implementation' ||
|
|
247
|
+
previousDecision?.phase === 'planning'
|
|
248
|
+
? phaseBias * 8
|
|
249
|
+
: 0),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (containsAny(prompt, explicitHighHints)) {
|
|
253
|
+
phase = 'planning';
|
|
254
|
+
tier = 'high';
|
|
255
|
+
reasoning =
|
|
256
|
+
'Detected an explicit request for deeper or higher-quality reasoning.';
|
|
257
|
+
} else if (containsAny(prompt, explicitLowHints)) {
|
|
258
|
+
phase = 'lightweight';
|
|
259
|
+
tier = 'low';
|
|
260
|
+
reasoning =
|
|
261
|
+
'Detected an explicit request for a faster or lighter response.';
|
|
262
|
+
} else if (containsAny(prompt, summaryKeywords)) {
|
|
263
|
+
phase = 'lightweight';
|
|
264
|
+
tier = 'low';
|
|
265
|
+
reasoning = 'Detected summary or lightweight transformation keywords.';
|
|
266
|
+
} else if (
|
|
267
|
+
containsAny(prompt, planningKeywords) ||
|
|
268
|
+
prompt.startsWith('why ') ||
|
|
269
|
+
wordCount >= highThreshold ||
|
|
270
|
+
multiLinePrompt
|
|
271
|
+
) {
|
|
272
|
+
phase = 'planning';
|
|
273
|
+
tier = 'high';
|
|
274
|
+
reasoning =
|
|
275
|
+
previousDecision?.phase === 'planning'
|
|
276
|
+
? 'Continued planning phase based on complexity or keywords.'
|
|
277
|
+
: 'Detected planning, broad analysis, or a high-complexity request.';
|
|
278
|
+
} else if (containsAny(prompt, implementationKeywords)) {
|
|
279
|
+
phase = 'implementation';
|
|
280
|
+
tier = 'medium';
|
|
281
|
+
reasoning =
|
|
282
|
+
'Detected implementation-oriented work with bounded execution scope.';
|
|
283
|
+
} else if (
|
|
284
|
+
containsAny(prompt, lookupKeywords) &&
|
|
285
|
+
wordCount <= 24 &&
|
|
286
|
+
toolResultCount === 0
|
|
287
|
+
) {
|
|
288
|
+
phase = 'lightweight';
|
|
289
|
+
tier = 'low';
|
|
290
|
+
reasoning = 'Detected a short read-only lookup request.';
|
|
291
|
+
} else if (
|
|
292
|
+
previousDecision?.phase === 'planning' &&
|
|
293
|
+
toolResultCount === 0 &&
|
|
294
|
+
wordCount > lowThreshold
|
|
295
|
+
) {
|
|
296
|
+
phase = 'planning';
|
|
297
|
+
tier = 'high';
|
|
298
|
+
reasoning =
|
|
299
|
+
'Kept the planning-phase bias because the conversation still looks exploratory.';
|
|
300
|
+
} else if (
|
|
301
|
+
toolResultCount > 0 ||
|
|
302
|
+
previousDecision?.phase === 'implementation' ||
|
|
303
|
+
recentConversation.includes('plan:')
|
|
304
|
+
) {
|
|
305
|
+
phase = 'implementation';
|
|
306
|
+
tier = 'medium';
|
|
307
|
+
reasoning =
|
|
308
|
+
'Detected active implementation work from prior tools or recent plan execution context.';
|
|
309
|
+
} else if (wordCount <= lowThreshold) {
|
|
310
|
+
phase = 'lightweight';
|
|
311
|
+
tier = 'low';
|
|
312
|
+
reasoning = 'Detected a short bounded request.';
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let isBudgetForced = false;
|
|
318
|
+
if (isBudgetExceeded && tier === 'high') {
|
|
319
|
+
tier = 'medium';
|
|
320
|
+
phase = 'implementation';
|
|
321
|
+
reasoning = `Budget exceeded. Downgraded from high to medium tier. (Original: ${reasoning})`;
|
|
322
|
+
isBudgetForced = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const decision = buildRoutingDecision(
|
|
326
|
+
profileName,
|
|
327
|
+
profile,
|
|
328
|
+
tier,
|
|
329
|
+
phase,
|
|
330
|
+
reasoning,
|
|
331
|
+
thinkingOverrides,
|
|
332
|
+
false,
|
|
333
|
+
);
|
|
334
|
+
decision.isRuleMatched = isRuleMatched;
|
|
335
|
+
decision.isBudgetForced = isBudgetForced;
|
|
336
|
+
return decision;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export const runClassifier = async (
|
|
340
|
+
classifierModelRef: string,
|
|
341
|
+
modelRegistry: ExtensionContext['modelRegistry'],
|
|
342
|
+
context: Context,
|
|
343
|
+
currentPhase?: RouterPhase,
|
|
344
|
+
): Promise<{ tier: RouterTier; reasoning: string } | undefined> => {
|
|
345
|
+
try {
|
|
346
|
+
const { provider, modelId } = parseCanonicalModelRef(classifierModelRef);
|
|
347
|
+
const model = modelRegistry.find(provider, modelId);
|
|
348
|
+
if (!model) return undefined;
|
|
349
|
+
|
|
350
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
351
|
+
if (!auth.ok || !auth.apiKey) return undefined;
|
|
352
|
+
const apiKey = auth.apiKey;
|
|
353
|
+
const headers = auth.headers;
|
|
354
|
+
|
|
355
|
+
const promptText = getLastUserText(context);
|
|
356
|
+
const historyText = getRecentConversationText(context, 4);
|
|
357
|
+
|
|
358
|
+
const classifierPrompt = `You are a model router classifier. Your job is to categorize the user's latest request into one of three tiers: "high", "medium", or "low".
|
|
359
|
+
|
|
360
|
+
Tiers:
|
|
361
|
+
- high: Architecture, design, planning, tradeoff analysis, broad debugging, large refactors, codebase research.
|
|
362
|
+
- medium: Implementation of a known plan, multi-file edits, normal coding work, focused debugging, tests/fixes.
|
|
363
|
+
- low: Summaries, changelogs, formatting, quick explanations, small bounded transforms, simple read-only lookup.
|
|
364
|
+
|
|
365
|
+
${currentPhase ? `Current conversation phase: ${currentPhase}\n` : ''}
|
|
366
|
+
Recent history:
|
|
367
|
+
${historyText}
|
|
368
|
+
|
|
369
|
+
Latest user message:
|
|
370
|
+
${promptText}
|
|
371
|
+
|
|
372
|
+
Return your decision in exactly two lines:
|
|
373
|
+
Tier: [high|medium|low]
|
|
374
|
+
Reasoning: [one short sentence]
|
|
375
|
+
|
|
376
|
+
${currentPhase === 'planning' ? 'Consider that the conversation is currently in a planning phase. Bias toward "high" unless the request is clearly a simple implementation or summary.' : ''}
|
|
377
|
+
${currentPhase === 'implementation' ? 'Consider that the conversation is currently in an implementation phase. Bias toward "medium" unless the request is clearly planning or a simple summary.' : ''}`;
|
|
378
|
+
|
|
379
|
+
const classifierContext: Context = {
|
|
380
|
+
...context,
|
|
381
|
+
messages: [{ role: 'user', content: classifierPrompt, timestamp: Date.now() }],
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const stream = streamSimple(model, classifierContext, { apiKey, headers });
|
|
385
|
+
let fullText = '';
|
|
386
|
+
for await (const event of stream) {
|
|
387
|
+
if (
|
|
388
|
+
event.type === 'text_delta' &&
|
|
389
|
+
typeof (event as any).delta === 'string'
|
|
390
|
+
) {
|
|
391
|
+
fullText += (event as any).delta;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const lines = fullText.trim().split('\n');
|
|
396
|
+
const tierLine = lines.find((l) => l.toLowerCase().startsWith('tier:'));
|
|
397
|
+
const reasoningLine = lines.find((l) =>
|
|
398
|
+
l.toLowerCase().startsWith('reasoning:'),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
if (tierLine) {
|
|
402
|
+
const tierValue = tierLine.split(':')[1].trim().toLowerCase();
|
|
403
|
+
if (isRouterTier(tierValue)) {
|
|
404
|
+
return {
|
|
405
|
+
tier: tierValue,
|
|
406
|
+
reasoning: reasoningLine
|
|
407
|
+
? reasoningLine.split(':')[1].trim()
|
|
408
|
+
: 'Classifier decision.',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
// Ignore classifier errors and fall back to heuristics
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RouterPinByProfile,
|
|
3
|
+
RouterThinkingByProfile,
|
|
4
|
+
RoutingDecision,
|
|
5
|
+
RouterPersistedState,
|
|
6
|
+
} from './types.js';
|
|
7
|
+
|
|
8
|
+
export const isRouterPersistedState = (
|
|
9
|
+
value: unknown,
|
|
10
|
+
): value is RouterPersistedState => {
|
|
11
|
+
if (typeof value !== 'object' || value === null) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const v = value as any;
|
|
15
|
+
return (
|
|
16
|
+
typeof v.enabled === 'boolean' &&
|
|
17
|
+
typeof v.selectedProfile === 'string' &&
|
|
18
|
+
typeof v.timestamp === 'number'
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const buildPersistedState = (
|
|
23
|
+
routerEnabled: boolean,
|
|
24
|
+
selectedProfile: string,
|
|
25
|
+
pinnedTierByProfile: RouterPinByProfile,
|
|
26
|
+
thinkingByProfile: RouterThinkingByProfile,
|
|
27
|
+
debugEnabled: boolean,
|
|
28
|
+
widgetEnabled: boolean,
|
|
29
|
+
debugHistory: RoutingDecision[],
|
|
30
|
+
lastDecision: RoutingDecision | undefined,
|
|
31
|
+
lastNonRouterModel: string | undefined,
|
|
32
|
+
accumulatedCost: number,
|
|
33
|
+
): RouterPersistedState => {
|
|
34
|
+
return {
|
|
35
|
+
enabled: routerEnabled,
|
|
36
|
+
selectedProfile,
|
|
37
|
+
pinTier: pinnedTierByProfile[selectedProfile],
|
|
38
|
+
pinByProfile: { ...pinnedTierByProfile },
|
|
39
|
+
thinkingByProfile: { ...thinkingByProfile },
|
|
40
|
+
debugEnabled,
|
|
41
|
+
widgetEnabled,
|
|
42
|
+
debugHistory,
|
|
43
|
+
lastPhase: lastDecision?.phase,
|
|
44
|
+
lastDecision,
|
|
45
|
+
lastNonRouterModel,
|
|
46
|
+
accumulatedCost,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ThinkingLevel } from '@mariozechner/pi-agent-core';
|
|
2
|
+
|
|
3
|
+
export type RouterTier = 'high' | 'medium' | 'low';
|
|
4
|
+
export type RouterPin = RouterTier | 'auto';
|
|
5
|
+
export type RouterPhase = 'planning' | 'implementation' | 'lightweight';
|
|
6
|
+
export type RouterPinByProfile = Partial<Record<string, RouterTier>>;
|
|
7
|
+
export type RouterThinkingByTier = Partial<Record<RouterTier, ThinkingLevel>>;
|
|
8
|
+
export type RouterThinkingByProfile = Record<string, RouterThinkingByTier>;
|
|
9
|
+
|
|
10
|
+
export interface RoutingRule {
|
|
11
|
+
matches: string | string[];
|
|
12
|
+
tier: RouterTier;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RoutedTierConfig {
|
|
17
|
+
model: string;
|
|
18
|
+
thinking?: ThinkingLevel;
|
|
19
|
+
fallbacks?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouterProfile {
|
|
23
|
+
high: RoutedTierConfig;
|
|
24
|
+
medium: RoutedTierConfig;
|
|
25
|
+
low: RoutedTierConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RouterConfig {
|
|
29
|
+
defaultProfile?: string;
|
|
30
|
+
debug?: boolean;
|
|
31
|
+
classifierModel?: string;
|
|
32
|
+
phaseBias?: number;
|
|
33
|
+
largeContextThreshold?: number;
|
|
34
|
+
maxSessionBudget?: number;
|
|
35
|
+
rules?: RoutingRule[];
|
|
36
|
+
profiles: Record<string, RouterProfile>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RoutingDecision {
|
|
40
|
+
profile: string;
|
|
41
|
+
tier: RouterTier;
|
|
42
|
+
phase: RouterPhase;
|
|
43
|
+
targetProvider: string;
|
|
44
|
+
targetModelId: string;
|
|
45
|
+
targetLabel: string;
|
|
46
|
+
reasoning: string;
|
|
47
|
+
thinking: ThinkingLevel;
|
|
48
|
+
timestamp: number;
|
|
49
|
+
isClassifier?: boolean;
|
|
50
|
+
isFallback?: boolean;
|
|
51
|
+
isContextTriggered?: boolean;
|
|
52
|
+
isBudgetForced?: boolean;
|
|
53
|
+
isRuleMatched?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RouterPersistedState {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
selectedProfile: string;
|
|
59
|
+
pinTier?: RouterTier;
|
|
60
|
+
pinByProfile?: RouterPinByProfile;
|
|
61
|
+
thinkingByProfile?: RouterThinkingByProfile;
|
|
62
|
+
debugEnabled?: boolean;
|
|
63
|
+
widgetEnabled?: boolean;
|
|
64
|
+
debugHistory?: RoutingDecision[];
|
|
65
|
+
lastPhase?: RouterPhase;
|
|
66
|
+
lastDecision?: RoutingDecision;
|
|
67
|
+
lastNonRouterModel?: string;
|
|
68
|
+
accumulatedCost?: number;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConfigLoadResult {
|
|
73
|
+
config: RouterConfig;
|
|
74
|
+
warnings: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ParsedConfigFile {
|
|
78
|
+
config: Partial<RouterConfig>;
|
|
79
|
+
warnings: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CustomSessionEntry {
|
|
83
|
+
type: string;
|
|
84
|
+
customType?: string;
|
|
85
|
+
data?: unknown;
|
|
86
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import type {
|
|
3
|
+
RoutingDecision,
|
|
4
|
+
RouterConfig,
|
|
5
|
+
RouterPinByProfile,
|
|
6
|
+
RouterThinkingByProfile,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
const getEffectiveThinking = (
|
|
10
|
+
thinkingByProfile: RouterThinkingByProfile,
|
|
11
|
+
profileName: string,
|
|
12
|
+
decision: RoutingDecision,
|
|
13
|
+
) => thinkingByProfile[profileName]?.[decision.tier] ?? decision.thinking;
|
|
14
|
+
|
|
15
|
+
const getDecisionFlags = (decision: RoutingDecision): string[] => {
|
|
16
|
+
const flags: string[] = [];
|
|
17
|
+
if (decision.isFallback) flags.push('fallback');
|
|
18
|
+
if (decision.isContextTriggered) flags.push('context');
|
|
19
|
+
if (decision.isBudgetForced) flags.push('budget-limit');
|
|
20
|
+
if (decision.isRuleMatched) flags.push('rule');
|
|
21
|
+
return flags;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const formatDecision = (decision: RoutingDecision): string => {
|
|
25
|
+
return `${decision.profile}: ${decision.tier} -> ${decision.targetProvider}/${decision.targetModelId} [${decision.thinking}] (${decision.reasoning})`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const formatPinSummary = (
|
|
29
|
+
pinnedTierByProfile: RouterPinByProfile,
|
|
30
|
+
): string => {
|
|
31
|
+
const entries = Object.entries(pinnedTierByProfile)
|
|
32
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
33
|
+
.map(([profile, tier]) => `${profile}:${tier}`);
|
|
34
|
+
return entries.length > 0 ? entries.join(', ') : 'none';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const formatThinkingSummary = (
|
|
38
|
+
thinkingByProfile: RouterThinkingByProfile,
|
|
39
|
+
): string => {
|
|
40
|
+
const entries = Object.entries(thinkingByProfile)
|
|
41
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
42
|
+
.map(([profile, tierMap]) => {
|
|
43
|
+
const tiers = Object.entries(tierMap)
|
|
44
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
45
|
+
.map(([tier, level]) => `${tier}:${level}`);
|
|
46
|
+
return `${profile}(${tiers.join(',')})`;
|
|
47
|
+
});
|
|
48
|
+
return entries.length > 0 ? entries.join(', ') : 'none';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const formatModelRef = (ref: string | undefined): string => {
|
|
52
|
+
return ref ?? 'none';
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const updateStatus = (
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
routerEnabled: boolean,
|
|
58
|
+
selectedProfile: string,
|
|
59
|
+
pinnedTierByProfile: RouterPinByProfile,
|
|
60
|
+
thinkingByProfile: RouterThinkingByProfile,
|
|
61
|
+
lastDecision: RoutingDecision | undefined,
|
|
62
|
+
lastNonRouterModel: string | undefined,
|
|
63
|
+
accumulatedCost: number,
|
|
64
|
+
widgetEnabled: boolean,
|
|
65
|
+
currentConfig: RouterConfig,
|
|
66
|
+
) => {
|
|
67
|
+
const activeRouterProfile = routerEnabled ? selectedProfile : undefined;
|
|
68
|
+
const statusProfile = selectedProfile;
|
|
69
|
+
const activePin = pinnedTierByProfile[statusProfile];
|
|
70
|
+
const pinLabel = activePin ? ` [pin:${activePin}]` : '';
|
|
71
|
+
|
|
72
|
+
let statusText: string;
|
|
73
|
+
if (activeRouterProfile) {
|
|
74
|
+
const matchesProfile =
|
|
75
|
+
lastDecision && lastDecision.profile === activeRouterProfile;
|
|
76
|
+
const matchesPin = activePin ? lastDecision?.tier === activePin : true;
|
|
77
|
+
|
|
78
|
+
if (lastDecision && matchesProfile && matchesPin) {
|
|
79
|
+
const effectiveThinking = getEffectiveThinking(
|
|
80
|
+
thinkingByProfile,
|
|
81
|
+
activeRouterProfile,
|
|
82
|
+
lastDecision,
|
|
83
|
+
);
|
|
84
|
+
statusText = `router:${activeRouterProfile}${pinLabel} -> ${lastDecision.tier} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${effectiveThinking})`;
|
|
85
|
+
} else {
|
|
86
|
+
statusText = `router:${activeRouterProfile}${pinLabel} -> waiting`;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
statusText = `router:off (${selectedProfile}${pinLabel}) -> ${formatModelRef(lastNonRouterModel)}`;
|
|
90
|
+
}
|
|
91
|
+
ctx.ui.setStatus('router', ctx.ui.theme.fg('dim', statusText));
|
|
92
|
+
|
|
93
|
+
if (!widgetEnabled) {
|
|
94
|
+
ctx.ui.setWidget('router', undefined);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const widgetLines = [
|
|
99
|
+
`Router: ${routerEnabled ? 'enabled' : 'disabled'}`,
|
|
100
|
+
`Profile: ${statusProfile}${activeRouterProfile ? ' (active)' : ''}`,
|
|
101
|
+
`Pin: ${activePin ?? 'auto'}`,
|
|
102
|
+
`Cost: $${accumulatedCost.toFixed(4)}` +
|
|
103
|
+
(currentConfig.maxSessionBudget
|
|
104
|
+
? ` / $${currentConfig.maxSessionBudget.toFixed(2)}`
|
|
105
|
+
: ''),
|
|
106
|
+
];
|
|
107
|
+
if (lastDecision && lastDecision.profile === statusProfile) {
|
|
108
|
+
const effectiveThinking = getEffectiveThinking(
|
|
109
|
+
thinkingByProfile,
|
|
110
|
+
statusProfile,
|
|
111
|
+
lastDecision,
|
|
112
|
+
);
|
|
113
|
+
const flags = getDecisionFlags(lastDecision);
|
|
114
|
+
const flagsStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
|
|
115
|
+
|
|
116
|
+
widgetLines.push(
|
|
117
|
+
`Route: ${lastDecision.tier}${flagsStr} -> ${lastDecision.targetProvider}/${lastDecision.targetModelId} (${effectiveThinking})`,
|
|
118
|
+
`Phase: ${lastDecision.phase}`,
|
|
119
|
+
);
|
|
120
|
+
} else if (!routerEnabled && lastNonRouterModel) {
|
|
121
|
+
widgetLines.push(`Fallback: ${lastNonRouterModel}`);
|
|
122
|
+
}
|
|
123
|
+
if (Object.keys(pinnedTierByProfile).length > 1) {
|
|
124
|
+
widgetLines.push(`Pins: ${formatPinSummary(pinnedTierByProfile)}`);
|
|
125
|
+
}
|
|
126
|
+
ctx.ui.setWidget(
|
|
127
|
+
'router',
|
|
128
|
+
widgetLines.map((line) => ctx.ui.theme.fg('dim', line)),
|
|
129
|
+
);
|
|
130
|
+
};
|