@alexeiled/pi-model-router 0.6.5 → 0.7.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.
@@ -7,7 +7,6 @@ import type { AutocompleteItem } from '@earendil-works/pi-tui';
7
7
  import {
8
8
  getUnsupportedTiers,
9
9
  isRouterPinValue,
10
- isRouterTier,
11
10
  isThinkingLevel,
12
11
  parseCanonicalModelRef,
13
12
  profileNames,
@@ -21,7 +20,6 @@ import type {
21
20
  RouterConfig,
22
21
  RouterPinByProfile,
23
22
  RouterThinkingByProfile,
24
- RouterTier,
25
23
  RoutingDecision,
26
24
  } from './types';
27
25
  import {
@@ -34,6 +32,42 @@ import {
34
32
  formatThinkingSummary,
35
33
  } from './ui';
36
34
 
35
+ /** One verb per concern; state is shown by the verb that changes it. */
36
+ const VERBS = [
37
+ { name: 'pin', desc: 'Pin the active profile to a tier, or auto' },
38
+ { name: 'thinking', desc: 'Override thinking for every tier, or auto' },
39
+ { name: 'log', desc: 'Recent decisions and Jev stats; on, off or clear' },
40
+ { name: 'widget', desc: 'Toggle the status widget' },
41
+ { name: 'off', desc: 'Leave the router and restore the previous model' },
42
+ { name: 'reload', desc: 'Reload model-router.json' },
43
+ { name: 'help', desc: 'Show usage' },
44
+ ] as const;
45
+
46
+ /** Removed verbs answer with the replacement instead of acting. */
47
+ const RETIRED_VERBS: Record<string, string> = {
48
+ status: '/router',
49
+ profile: '/router <profile>',
50
+ disable: '/router off',
51
+ fix: '/router pin <tier>',
52
+ debug: '/router log',
53
+ '?': '/router help',
54
+ };
55
+
56
+ const LOG_ACTIONS = ['on', 'off', 'clear'] as const;
57
+ const THINKING_VALUES = ['auto', ...THINKING_LEVELS] as const;
58
+
59
+ const USAGE = [
60
+ '/router status',
61
+ '/router <profile> switch profile (enables the router)',
62
+ '/router off leave the router; restore the previous model',
63
+ '/router pin <tier|auto> pin the active profile to high|medium|low|micro, or clear',
64
+ '/router thinking <level|auto> override thinking for every tier, or clear',
65
+ '/router log [on|off|clear] recent decisions and Jev stats; control collection',
66
+ '/router widget toggle the status widget',
67
+ '/router reload reload model-router.json',
68
+ '/router help this text',
69
+ ].join('\n');
70
+
37
71
  export const registerCommands = (
38
72
  pi: ExtensionAPI,
39
73
  state: {
@@ -66,503 +100,224 @@ export const registerCommands = (
66
100
  syncPiThinkingLevel: (level: ThinkingLevel) => void;
67
101
  },
68
102
  ) => {
69
- const SUBCOMMAND_DETAILS = [
70
- { name: 'status', desc: 'Show current router status' },
71
- { name: 'profile', desc: 'Switch to a different router profile' },
72
- { name: 'pin', desc: 'Pin routing for a profile to a specific tier' },
73
- { name: 'thinking', desc: 'Override thinking level for a tier or profile' },
74
- { name: 'disable', desc: 'Disable the router and restore last model' },
75
- {
76
- name: 'fix',
77
- desc: 'Correct the last routing decision and pin that tier',
78
- },
79
- { name: 'widget', desc: 'Toggle the router status widget' },
80
- {
81
- name: 'debug',
82
- desc: 'Inspect Jev stats or control router debug history',
83
- },
84
- { name: 'reload', desc: 'Reload the model router configuration' },
85
- { name: 'help', desc: 'Show usage help for subcommands' },
86
- ];
87
-
88
- const getSubcommandCompletions = (
89
- prefix: string,
90
- ): AutocompleteItem[] | null => {
91
- const items = SUBCOMMAND_DETAILS.filter((s) =>
92
- s.name.startsWith(prefix),
93
- ).map((s) => ({
94
- value: s.name,
95
- label: s.name,
96
- description: s.desc,
97
- }));
98
- return items.length > 0 ? items : null;
99
- };
100
-
101
- const getPinCompletions = (args: string[]): AutocompleteItem[] | null => {
102
- // pin <tier|auto>
103
- if (args.length <= 1) {
104
- const token = args[0] ?? '';
105
- const items = ROUTER_PIN_VALUES.filter((value) =>
106
- value.startsWith(token),
107
- ).map((value) => ({
108
- value,
109
- label: value,
110
- description:
111
- value === 'auto'
112
- ? 'Restore auto-routing (clear pin) for the active profile'
113
- : `Pin active profile to ${value} tier`,
114
- }));
115
- return items.length > 0 ? items : null;
116
- }
117
- return null;
118
- };
119
-
120
- const getThinkingCompletions = (
121
- args: string[],
122
- ): AutocompleteItem[] | null => {
123
- // thinking [tier] <level|auto>
124
- const tierValues: RouterTier[] = [...ROUTER_TIERS];
125
- const levelValues = ['auto', ...THINKING_LEVELS];
126
-
127
- if (args.length <= 1) {
128
- const token = args[0] ?? '';
129
- return [
130
- ...levelValues
131
- .filter((v) => v.startsWith(token))
132
- .map((v) => ({
133
- value: v,
134
- label: v,
135
- description:
136
- v === 'auto'
137
- ? 'Restore default thinking level'
138
- : `Set thinking level to ${v}`,
139
- })),
140
- ...tierValues
141
- .filter((v) => v.startsWith(token))
142
- .map((v) => ({
143
- value: v,
144
- label: v,
145
- description: `Override thinking for ${v} tier`,
146
- })),
147
- ];
148
- }
149
-
150
- const tier = args[0];
151
- if (isRouterTier(tier)) {
152
- const levelPrefix = args[1] ?? '';
153
- return levelValues
154
- .filter((v) => v.startsWith(levelPrefix))
155
- .map((v) => ({
156
- value: `${tier} ${v}`,
157
- label: `${tier} ${v}`,
158
- description:
159
- v === 'auto'
160
- ? `Restore default thinking level for ${tier} tier`
161
- : `Set thinking level to ${v} for ${tier} tier`,
162
- }));
163
- }
103
+ const usage = (ctx: ExtensionContext, line: string) =>
104
+ ctx.ui.notify(`Usage: ${line}`, 'error');
164
105
 
165
- return null;
106
+ const activeProfile = (ctx: ExtensionContext): string | undefined => {
107
+ if (!state.selectedProfile)
108
+ ctx.ui.notify(
109
+ 'No router profile is active. Run /router <profile> first.',
110
+ 'error',
111
+ );
112
+ return state.selectedProfile;
166
113
  };
167
114
 
168
- const handleStatus = async (args: string[], ctx: ExtensionContext) => {
169
- if (args.length > 0) {
170
- ctx.ui.notify('Usage: /router status (no arguments)', 'error');
171
- return;
172
- }
173
- const names = profileNames(state.currentConfig).join(', ');
174
- const jev = state.currentConfig.jev;
175
- const input = jev?.context ?? DEFAULT_JEV_CONTEXT;
115
+ const showStatus = (ctx: ExtensionContext) => {
116
+ const profile = state.selectedProfile;
117
+ const config = state.currentConfig;
118
+ const jev = config.jev;
119
+ const context = jev?.context ?? DEFAULT_JEV_CONTEXT;
120
+ const cost =
121
+ `$${state.accumulatedCost.toFixed(4)}` +
122
+ (config.maxSessionBudget
123
+ ? ` / $${config.maxSessionBudget.toFixed(2)}`
124
+ : '');
176
125
  const lines = [
177
- 'Model Router Status:',
178
- `Router enabled: ${state.routerEnabled ? 'yes' : 'off'}`,
179
- `Selected profile: ${state.selectedProfile ?? 'none'}`,
180
- `Selected profile pin: ${state.selectedProfile ? (state.pinnedTierByProfile[state.selectedProfile] ?? 'auto') : 'none'}`,
181
- `Pins by profile: ${formatPinSummary(state.pinnedTierByProfile)}`,
182
- `Thinking overrides: ${formatThinkingSummary(state.thinkingByProfile)}`,
183
- `Widget: ${state.widgetEnabled ? 'on' : 'off'}`,
184
- `Status line: ${state.currentConfig.ui?.statusLine ?? 'compact'}`,
126
+ `Router: ${state.routerEnabled ? 'on' : 'off'} · profile ${profile ?? 'none'} · available: ${profileNames(config).join(', ')}`,
127
+ `Pin: ${formatPinSummary(state.pinnedTierByProfile)} · thinking override: ${formatThinkingSummary(state.thinkingByProfile)}`,
128
+ `Baseline: ${profile ? (config.profiles[profile]?.baselineTier ?? 'automatic') : 'none'} · cost: ${cost} · widget: ${state.widgetEnabled ? 'on' : 'off'} · log: ${state.debugEnabled ? 'on' : 'off'} (${state.debugHistory.length} decisions)`,
185
129
  jev
186
- ? `Jev context: ${input.previousTurns} prior turns; history≈${input.maxHistoryTokens} tokens; tools=${input.toolResults}/≈${input.maxToolTokens} tokens; state≈${jev.maxStateTokens} tokens`
187
- : 'Jev context: not configured',
188
- `Jev: ${state.currentConfig.jev?.enabled ? 'enabled' : 'disabled'} · profile opt-in: ${state.selectedProfile && state.currentConfig.profiles[state.selectedProfile]?.jev?.enabled ? 'yes' : 'no'} · timeout: ${state.currentConfig.jev?.timeoutMs ?? 1500}ms`,
189
- 'Jev confidence measures classification certainty, not model success.',
190
- `Session cost: $${state.accumulatedCost.toFixed(4)}` +
191
- (state.currentConfig.maxSessionBudget
192
- ? ` / $${state.currentConfig.maxSessionBudget.toFixed(2)}`
193
- : ''),
194
- `Available profiles: ${names}`,
195
- `Last non-router model: ${formatModelRef(state.lastNonRouterModel)}`,
196
- `Debug: ${state.debugEnabled ? 'on' : 'off'}`,
197
- `Debug history: ${state.debugHistory.length} decisions`,
198
- `Baseline preference: ${state.selectedProfile ? (state.currentConfig.profiles[state.selectedProfile]?.baselineTier ?? 'automatic') : 'none'} (eligibility and budget still apply)`,
130
+ ? `Jev: ${jev.enabled ? 'enabled' : 'disabled'} · profile opt-in: ${profile && config.profiles[profile]?.jev?.enabled ? 'yes' : 'no'} · budget ${jev.timeoutMs}ms · context ${context.previousTurns} turns / ≈${context.maxHistoryTokens} history / ${context.toolResults} ≈${context.maxToolTokens} tool / ≈${jev.maxStateTokens} state tokens`
131
+ : 'Jev: not configured',
132
+ `Previous model: ${formatModelRef(state.lastNonRouterModel)}`,
199
133
  ...formatJevStats(state.debugHistory),
200
134
  ];
201
- if (state.lastDecision) {
202
- const advisorDetail = formatAdvisorDetail(state.lastDecision);
135
+ const last = state.lastDecision;
136
+ if (last) {
137
+ const source = formatDecisionSource(last);
138
+ const advisor = formatAdvisorDetail(last);
203
139
  lines.push(
204
- `Last routed tier: ${state.lastDecision.tier}`,
205
- `Last phase: ${state.lastDecision.phase}`,
206
- `Last model: ${state.lastDecision.targetProvider}/${state.lastDecision.targetModelId} (${state.lastDecision.thinking})`,
207
- ...(formatDecisionSource(state.lastDecision)
208
- ? [`Reason: ${formatDecisionSource(state.lastDecision)}`]
209
- : []),
210
- ...(advisorDetail ? [advisorDetail] : []),
140
+ `Last: ${last.tier} ${last.targetProvider}/${last.targetModelId} (${last.thinking})${source ? ` · ${source}` : ''}`,
141
+ ...(advisor ? [advisor] : []),
211
142
  );
212
143
  }
213
- if (state.lastConfigWarnings && state.lastConfigWarnings.length > 0) {
144
+ if (state.lastConfigWarnings.length > 0)
214
145
  lines.push(
215
146
  '',
216
- '⚠️ Configuration Warnings:',
217
- ...state.lastConfigWarnings.map((w) => ` - ${w}`),
147
+ '⚠️ Configuration warnings:',
148
+ ...state.lastConfigWarnings.map((warning) => ` - ${warning}`),
218
149
  );
219
- }
220
150
  ctx.ui.notify(lines.join('\n'), 'info');
221
151
  actions.updateStatus(ctx);
222
152
  };
223
153
 
224
- const handleProfile = async (args: string[], ctx: ExtensionContext) => {
225
- if (args.length > 1) {
226
- ctx.ui.notify('Usage: /router profile [name]', 'error');
227
- return;
228
- }
229
- const profileName = args[0];
230
- if (!profileName) {
231
- ctx.ui.notify(
232
- `Current profile: ${state.selectedProfile}. Available: ${profileNames(state.currentConfig).join(', ')}`,
233
- 'info',
234
- );
235
- return;
236
- }
237
- const success = await actions.switchToRouterProfile(profileName, ctx);
238
- if (success) {
239
- ctx.ui.notify(
240
- `Switched to router profile: ${state.selectedProfile}`,
241
- 'info',
242
- );
243
- }
154
+ const handleProfile = async (name: string, ctx: ExtensionContext) => {
155
+ if (await actions.switchToRouterProfile(name, ctx))
156
+ ctx.ui.notify(`Router profile: ${state.selectedProfile}`, 'info');
244
157
  };
245
158
 
246
- const handlePin = async (args: string[], ctx: ExtensionContext) => {
247
- const currentProfile = state.selectedProfile;
248
- if (!currentProfile) {
159
+ const handleOff = async (ctx: ExtensionContext) => {
160
+ if (!state.lastNonRouterModel) {
249
161
  ctx.ui.notify(
250
- 'No router profile is active. Select a router model first.',
251
- 'error',
162
+ 'No previous non-router model recorded. Use /model to pick one.',
163
+ 'warning',
252
164
  );
253
165
  return;
254
166
  }
255
- if (args.length === 0) {
167
+ const { provider, modelId } = parseCanonicalModelRef(
168
+ state.lastNonRouterModel,
169
+ );
170
+ const target = ctx.modelRegistry.find(provider, modelId);
171
+ if (!target) {
256
172
  ctx.ui.notify(
257
- [
258
- `Profile: ${currentProfile}`,
259
- `Pinned tier: ${state.pinnedTierByProfile[currentProfile] ?? 'auto'}`,
260
- `Usage: /router pin <high|medium|low|micro|auto>`,
261
- ].join('\n'),
262
- 'info',
173
+ `Previous model is unavailable: ${state.lastNonRouterModel}`,
174
+ 'error',
263
175
  );
264
- actions.updateStatus(ctx);
265
176
  return;
266
177
  }
267
-
268
- if (args.length > 1) {
269
- ctx.ui.notify('Usage: /router pin <high|medium|low|micro|auto>', 'error');
178
+ if (!(await pi.setModel(target))) {
179
+ ctx.ui.notify(`Failed to switch to ${state.lastNonRouterModel}`, 'error');
270
180
  return;
271
181
  }
182
+ state.routerEnabled = false;
183
+ actions.persistState();
184
+ actions.updateStatus(ctx);
185
+ ctx.ui.notify(`Router off. Restored ${state.lastNonRouterModel}`, 'info');
186
+ };
272
187
 
273
- const pinValue = args[0];
274
-
275
- if (!isRouterPinValue(pinValue)) {
188
+ const handlePin = (args: string[], ctx: ExtensionContext) => {
189
+ const profile = activeProfile(ctx);
190
+ if (!profile) return;
191
+ const value = args[0]?.toLowerCase();
192
+ if (args.length === 0) {
276
193
  ctx.ui.notify(
277
- `Invalid router pin: ${pinValue}. Use one of: ${ROUTER_PIN_VALUES.join(', ')}`,
278
- 'error',
194
+ `Pin: ${state.pinnedTierByProfile[profile] ?? 'auto'} (profile ${profile})`,
195
+ 'info',
279
196
  );
280
197
  return;
281
198
  }
282
-
283
- const nextTier: RouterTier | undefined =
284
- pinValue === 'auto' ? undefined : pinValue;
285
- if (nextTier) {
286
- state.pinnedTierByProfile[currentProfile] = nextTier;
287
- } else {
288
- delete state.pinnedTierByProfile[currentProfile];
199
+ if (args.length > 1 || !isRouterPinValue(value)) {
200
+ usage(ctx, `/router pin <${ROUTER_PIN_VALUES.join('|')}>`);
201
+ return;
289
202
  }
203
+ if (value === 'auto') delete state.pinnedTierByProfile[profile];
204
+ else state.pinnedTierByProfile[profile] = value;
290
205
  actions.persistState();
291
206
  actions.updateStatus(ctx);
292
207
  ctx.ui.notify(
293
- nextTier
294
- ? `Router pinned to ${nextTier}`
295
- : `Router pin cleared; baseline routing restored`,
208
+ value === 'auto'
209
+ ? 'Router pin cleared; baseline routing restored'
210
+ : `Router pinned to ${value}`,
296
211
  'info',
297
212
  );
298
213
  };
299
214
 
300
- const handleThinking = async (args: string[], ctx: ExtensionContext) => {
301
- const currentProfile = state.selectedProfile;
302
- if (!currentProfile) {
303
- ctx.ui.notify(
304
- 'No router profile is active. Select a router model first.',
305
- 'error',
306
- );
307
- return;
308
- }
215
+ const handleThinking = (args: string[], ctx: ExtensionContext) => {
216
+ const profile = activeProfile(ctx);
217
+ if (!profile) return;
218
+ const value = args[0]?.toLowerCase();
309
219
  if (args.length === 0) {
310
220
  ctx.ui.notify(
311
- [
312
- `Profile: ${currentProfile}`,
313
- `Thinking overrides: ${JSON.stringify(state.thinkingByProfile[currentProfile] ?? {})}`,
314
- 'Usage: /router thinking <level|auto> (applies to all tiers)',
315
- ' or: /router thinking <tier> <level|auto> (applies to one tier)',
316
- 'Note: not all tier models may support every thinking level.',
317
- ].join('\n'),
221
+ `Thinking override: ${formatThinkingSummary(state.thinkingByProfile)}`,
318
222
  'info',
319
223
  );
320
224
  return;
321
225
  }
322
-
323
- if (args.length > 2) {
324
- ctx.ui.notify('Too many arguments for /router thinking.', 'error');
325
- return;
326
- }
327
-
328
- let tier: RouterTier | 'all' | undefined;
329
- let levelValue = '';
330
-
331
- const levelValues = ['auto', ...THINKING_LEVELS];
332
-
333
- if (args.length === 1) {
334
- const level = args[0];
335
- if (!level) return;
336
- levelValue = level;
337
- tier = 'all';
338
- } else if (args.length === 2) {
339
- const requestedTier = args[0];
340
- const requestedLevel = args[1];
341
- if (!requestedTier || !requestedLevel) return;
342
- if (isRouterTier(requestedTier) || requestedTier === 'all') {
343
- tier = requestedTier === 'all' ? 'all' : requestedTier;
344
- levelValue = requestedLevel;
345
- } else {
346
- ctx.ui.notify(
347
- `Invalid tier: ${args[0]}. Use high, medium, low, or micro.`,
348
- 'error',
349
- );
350
- return;
351
- }
352
- }
353
-
354
- if (tier !== 'all' && !tier) {
355
- ctx.ui.notify(
356
- `Invalid tier: ${tier}. Use high, medium, low, or micro.`,
357
- 'error',
358
- );
359
- return;
360
- }
361
- if (!levelValues.includes(levelValue)) {
362
- ctx.ui.notify(
363
- `Invalid thinking level: ${levelValue}. Use auto or: ${THINKING_LEVELS.join(', ')}`,
364
- 'error',
365
- );
226
+ if (
227
+ args.length > 1 ||
228
+ !value ||
229
+ !THINKING_VALUES.some((level) => level === value)
230
+ ) {
231
+ usage(ctx, `/router thinking <${THINKING_VALUES.join('|')}>`);
366
232
  return;
367
233
  }
368
-
369
- const nextLevel =
370
- levelValue === 'auto'
371
- ? undefined
372
- : isThinkingLevel(levelValue)
373
- ? levelValue
374
- : undefined;
375
- const overrides = { ...state.thinkingByProfile[currentProfile] };
376
- const tiers = tier === 'all' ? ROUTER_TIERS : [tier];
377
- for (const targetTier of tiers) {
378
- if (nextLevel) overrides[targetTier] = nextLevel;
379
- else delete overrides[targetTier];
380
- }
381
- const activeProfile = state.currentConfig.profiles[currentProfile];
234
+ const level = isThinkingLevel(value) ? value : undefined;
235
+ const config = state.currentConfig.profiles[profile];
382
236
  if (
383
- nextLevel &&
384
- activeProfile &&
237
+ level &&
238
+ config &&
385
239
  preservesRouteCoverage(
386
- activeProfile,
240
+ config,
387
241
  (provider, id) => ctx.modelRegistry.find(provider, id),
388
- overrides,
242
+ Object.fromEntries(ROUTER_TIERS.map((tier) => [tier, level])),
389
243
  ) === false
390
244
  ) {
391
245
  ctx.ui.notify(
392
- `Router thinking unchanged: '${nextLevel}' leaves no eligible route.`,
246
+ `Router thinking unchanged: '${level}' leaves no eligible route.`,
393
247
  'warning',
394
248
  );
395
249
  return;
396
250
  }
397
- state.thinkingByProfile[currentProfile] = overrides;
398
- if (Object.keys(overrides).length === 0) {
399
- delete state.thinkingByProfile[currentProfile];
400
- }
401
-
402
- actions.persistState();
403
- actions.updateStatus(ctx);
404
- if (nextLevel) {
405
- actions.syncPiThinkingLevel(nextLevel);
406
- } else if (state.lastDecision) {
407
- actions.syncPiThinkingLevel(state.lastDecision.thinking);
408
- }
409
- // Only warn when the level isn't supported by some tiers; skip for 'off' and 'auto'
410
- if (nextLevel && nextLevel !== 'off') {
411
- const activeProfile = state.currentConfig.profiles[currentProfile];
412
- if (!activeProfile) return;
413
- const unsupported = getUnsupportedTiers(activeProfile, nextLevel);
414
- if (unsupported.length > 0) {
415
- ctx.ui.notify(
416
- `Router thinking (${tier}) set to ${nextLevel}. ` +
417
- `${unsupported.join(', ')} tier${unsupported.length > 1 ? 's' : ''} may not support '${nextLevel}' and will be skipped when unsupported.`,
418
- 'warning',
419
- );
420
- }
421
- }
422
- };
423
-
424
- const handleDisable = async (args: string[], ctx: ExtensionContext) => {
425
- if (args.length > 0) {
426
- ctx.ui.notify('Usage: /router disable (no arguments)', 'error');
427
- return;
428
- }
429
- if (!state.lastNonRouterModel) {
430
- ctx.ui.notify(
431
- 'No previous non-router model recorded. Use /model to pick a concrete model.',
432
- 'warning',
433
- );
434
- return;
435
- }
436
- const { provider, modelId } = parseCanonicalModelRef(
437
- state.lastNonRouterModel,
438
- );
439
- const targetModel = ctx.modelRegistry.find(provider, modelId);
440
- if (!targetModel) {
441
- ctx.ui.notify(
442
- `Recorded non-router model is unavailable: ${state.lastNonRouterModel}`,
443
- 'error',
251
+ if (level)
252
+ state.thinkingByProfile[profile] = Object.fromEntries(
253
+ ROUTER_TIERS.map((tier) => [tier, level]),
444
254
  );
445
- return;
446
- }
447
- const success = await pi.setModel(targetModel);
448
- if (!success) {
449
- ctx.ui.notify(`Failed to switch to ${state.lastNonRouterModel}`, 'error');
450
- return;
451
- }
452
- state.routerEnabled = false;
255
+ else delete state.thinkingByProfile[profile];
453
256
  actions.persistState();
454
257
  actions.updateStatus(ctx);
258
+ if (level) actions.syncPiThinkingLevel(level);
259
+ else if (state.lastDecision)
260
+ actions.syncPiThinkingLevel(state.lastDecision.thinking);
261
+ const unsupported =
262
+ level && level !== 'off' && config
263
+ ? getUnsupportedTiers(config, level)
264
+ : [];
455
265
  ctx.ui.notify(
456
- `Router disabled. Restored ${state.lastNonRouterModel}`,
457
- 'info',
266
+ level
267
+ ? `Router thinking set to ${level}${unsupported.length > 0 ? `; ${unsupported.join(', ')} may not support it and will be skipped when unsupported` : ''}`
268
+ : 'Router thinking override cleared',
269
+ unsupported.length > 0 ? 'warning' : 'info',
458
270
  );
459
271
  };
460
272
 
461
- const handleFix = async (args: string[], ctx: ExtensionContext) => {
462
- if (args.length !== 1) {
463
- ctx.ui.notify('Usage: /router fix <high|medium|low|micro>', 'error');
273
+ const handleLog = (args: string[], ctx: ExtensionContext) => {
274
+ const action = args[0]?.toLowerCase();
275
+ if (args.length > 1 || (action && !LOG_ACTIONS.some((a) => a === action))) {
276
+ usage(ctx, `/router log [${LOG_ACTIONS.join('|')}]`);
464
277
  return;
465
278
  }
466
- const tier = args[0]?.toLowerCase();
467
- if (!isRouterTier(tier)) {
468
- ctx.ui.notify('Usage: /router fix <high|medium|low|micro>', 'error');
279
+ if (action === 'on' || action === 'off') {
280
+ state.debugEnabled = action === 'on';
281
+ actions.persistState();
282
+ ctx.ui.notify(`Router log ${action}`, 'info');
469
283
  return;
470
284
  }
471
- if (!state.lastDecision) {
472
- ctx.ui.notify('No recent routing decision to fix.', 'warning');
285
+ if (action === 'clear') {
286
+ state.debugHistory.length = 0;
287
+ actions.persistState();
288
+ ctx.ui.notify('Router log cleared', 'info');
473
289
  return;
474
290
  }
475
- state.pinnedTierByProfile[state.lastDecision.profile] = tier;
476
- actions.persistState();
477
- actions.updateStatus(ctx);
478
- ctx.ui.notify(
479
- `Router decision corrected. ${state.lastDecision.profile} is now pinned to ${tier}.`,
480
- 'info',
291
+ const header = state.debugEnabled
292
+ ? 'Log: on'
293
+ : 'Log: off; /router log on collects new decisions';
294
+ const history = state.debugHistory.map(
295
+ (decision) =>
296
+ `[${new Date(decision.timestamp).toLocaleTimeString()}] ${formatDecision(decision)}`,
481
297
  );
482
- };
483
-
484
- const handleWidget = async (args: string[], ctx: ExtensionContext) => {
485
- if (args.length > 1) {
486
- ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
487
- return;
488
- }
489
- const cmd = args[0]?.toLowerCase();
490
- if (cmd && !['on', 'off', 'toggle'].includes(cmd)) {
491
- ctx.ui.notify('Usage: /router widget <on|off|toggle>', 'error');
492
- return;
493
- }
494
- if (cmd === 'on') state.widgetEnabled = true;
495
- else if (cmd === 'off') state.widgetEnabled = false;
496
- else state.widgetEnabled = !state.widgetEnabled;
497
- actions.persistState();
498
- actions.updateStatus(ctx);
499
298
  ctx.ui.notify(
500
- `Router widget ${state.widgetEnabled ? 'enabled' : 'disabled'}.`,
299
+ [
300
+ header,
301
+ ...formatJevStats(state.debugHistory),
302
+ ...(history.length > 0
303
+ ? ['Recent decisions:', ...history]
304
+ : ['No recent routing decisions.']),
305
+ ].join('\n'),
501
306
  'info',
502
307
  );
503
308
  };
504
309
 
505
- const handleDebug = async (args: string[], ctx: ExtensionContext) => {
506
- if (args.length > 1) {
507
- ctx.ui.notify('Usage: /router debug <on|off|show|stats|clear>', 'error');
508
- return;
509
- }
510
- const cmd = args[0]?.toLowerCase();
511
- if (
512
- cmd &&
513
- !['on', 'off', 'toggle', 'clear', 'show', 'stats'].includes(cmd)
514
- ) {
515
- ctx.ui.notify(
516
- 'Usage: /router debug <on|off|toggle|show|stats|clear>',
517
- 'error',
518
- );
519
- return;
520
- }
521
- if (cmd === 'on') state.debugEnabled = true;
522
- else if (cmd === 'off') state.debugEnabled = false;
523
- else if (cmd === 'clear') state.debugHistory.length = 0;
524
- else if (cmd === 'stats') {
525
- ctx.ui.notify(
526
- [
527
- state.debugEnabled
528
- ? 'Debug collection: on'
529
- : 'Debug collection: off; use /router debug on to collect new decisions.',
530
- ...formatJevStats(state.debugHistory),
531
- ].join('\n'),
532
- 'info',
533
- );
534
- return;
535
- } else if (cmd === 'show') {
536
- if (state.debugHistory.length === 0) {
537
- ctx.ui.notify('No recent routing decisions.', 'info');
538
- } else {
539
- const history = state.debugHistory
540
- .map(
541
- (d) =>
542
- `[${new Date(d.timestamp).toLocaleTimeString()}] ${formatDecision(d)}`,
543
- )
544
- .join('\n');
545
- ctx.ui.notify(
546
- `${formatJevStats(state.debugHistory).join('\n')}\nRecent Routing Decisions:\n${history}`,
547
- 'info',
548
- );
549
- }
550
- return;
551
- } else {
552
- state.debugEnabled = !state.debugEnabled;
553
- }
310
+ const handleWidget = (ctx: ExtensionContext) => {
311
+ state.widgetEnabled = !state.widgetEnabled;
554
312
  actions.persistState();
313
+ actions.updateStatus(ctx);
555
314
  ctx.ui.notify(
556
- `Router debug ${state.debugEnabled ? 'enabled' : 'disabled'}.`,
315
+ `Router widget ${state.widgetEnabled ? 'on' : 'off'}`,
557
316
  'info',
558
317
  );
559
318
  };
560
319
 
561
- const handleReload = async (args: string[], ctx: ExtensionContext) => {
562
- if (args.length > 0) {
563
- ctx.ui.notify('Usage: /router reload (no arguments)', 'error');
564
- return;
565
- }
320
+ const handleReload = async (ctx: ExtensionContext) => {
566
321
  actions.reloadConfig(ctx, { preserveDebug: true });
567
322
  await actions.ensureValidActiveRouterProfile(ctx);
568
323
  ctx.ui.notify(
@@ -571,186 +326,133 @@ export const registerCommands = (
571
326
  );
572
327
  };
573
328
 
329
+ const items = (
330
+ values: readonly string[],
331
+ token: string,
332
+ describe: (value: string) => string,
333
+ prefix = '',
334
+ ): AutocompleteItem[] | null => {
335
+ const matches = values
336
+ .filter((value) => value.startsWith(token))
337
+ .map((value) => ({
338
+ value: `${prefix}${value}`,
339
+ label: value,
340
+ description: describe(value),
341
+ }));
342
+ return matches.length > 0 ? matches : null;
343
+ };
344
+
574
345
  pi.registerCommand('router', {
575
- description: 'Model router control center',
346
+ description: 'Model router: profile, pin, thinking, log',
576
347
  getArgumentCompletions: (prefix) => {
577
- const trimmedLeft = prefix.trimStart();
578
- const hasTrailingSpace = /\s$/.test(prefix);
579
- const parts = trimmedLeft.length > 0 ? trimmedLeft.split(/\s+/) : [];
580
-
581
- if (parts.length === 0) {
582
- return getSubcommandCompletions('');
583
- }
584
-
585
- if (parts.length === 1 && !hasTrailingSpace) {
586
- const subcommand = parts[0];
587
- return subcommand ? getSubcommandCompletions(subcommand) : null;
588
- }
589
-
590
- const subcommand = parts[0];
591
- if (!subcommand) return null;
592
- const subArgs = parts.slice(1);
593
- if (hasTrailingSpace && parts.length === 1) {
594
- subArgs.push('');
348
+ const text = prefix.trimStart();
349
+ const parts = text.length > 0 ? text.split(/\s+/) : [];
350
+ const trailing = /\s$/.test(prefix);
351
+ if (parts.length === 0 || (parts.length === 1 && !trailing)) {
352
+ const token = parts[0] ?? '';
353
+ const profiles = items(
354
+ profileNames(state.currentConfig),
355
+ token,
356
+ (name) => `Switch to profile ${name}`,
357
+ );
358
+ const verbs = items(
359
+ VERBS.map((verb) => verb.name),
360
+ token,
361
+ (name) => VERBS.find((verb) => verb.name === name)?.desc ?? name,
362
+ );
363
+ const all = [...(profiles ?? []), ...(verbs ?? [])];
364
+ return all.length > 0 ? all : null;
595
365
  }
596
-
597
- switch (subcommand) {
598
- case 'profile': {
599
- const profilePrefix = subArgs[0] ?? '';
600
- const items = profileNames(state.currentConfig)
601
- .filter((name) => name.startsWith(profilePrefix))
602
- .map((name) => ({
603
- value: `profile ${name}`,
604
- label: `router/${name}`,
605
- description: `Switch to router profile "${name}"`,
606
- }));
607
- return items.length > 0 ? items : null;
608
- }
609
- case 'pin': {
610
- const completions = getPinCompletions(subArgs);
611
- return (
612
- completions?.map((c) => ({
613
- ...c,
614
- value: `pin ${c.value}`,
615
- description: c.description ?? `Pin routing to ${c.label}`,
616
- })) ?? null
366
+ const [verb, ...rest] = parts;
367
+ const token = trailing && rest.length === 0 ? '' : (rest[0] ?? '');
368
+ if (rest.length > 1) return null;
369
+ switch (verb) {
370
+ case 'pin':
371
+ return items(
372
+ ROUTER_PIN_VALUES,
373
+ token,
374
+ (value) =>
375
+ value === 'auto'
376
+ ? 'Clear the pin for the active profile'
377
+ : `Pin the active profile to ${value}`,
378
+ 'pin ',
617
379
  );
618
- }
619
- case 'thinking': {
620
- const completions = getThinkingCompletions(subArgs);
621
- return (
622
- completions?.map((c) => ({
623
- ...c,
624
- value: `thinking ${c.value}`,
625
- description: c.description ?? `Set thinking level to ${c.label}`,
626
- })) ?? null
380
+ case 'thinking':
381
+ return items(
382
+ THINKING_VALUES,
383
+ token,
384
+ (value) =>
385
+ value === 'auto'
386
+ ? 'Clear the thinking override'
387
+ : `Set thinking to ${value} for every tier`,
388
+ 'thinking ',
627
389
  );
628
- }
629
- case 'fix': {
630
- const fixPrefix = subArgs[0] ?? '';
631
- const items = ROUTER_TIERS.filter((t) =>
632
- t.startsWith(fixPrefix.toLowerCase()),
633
- ).map((t) => ({
634
- value: `fix ${t}`,
635
- label: t,
636
- description: `Correct decision and pin to ${t} tier`,
637
- }));
638
- return items.length > 0 ? items : null;
639
- }
640
- case 'widget': {
641
- const widgetPrefix = subArgs[0] ?? '';
642
- const items = ['on', 'off', 'toggle']
643
- .filter((v) => v.startsWith(widgetPrefix))
644
- .map((v) => ({
645
- value: `widget ${v}`,
646
- label: v,
647
- description: `Set widget to ${v}`,
648
- }));
649
- return items.length > 0 ? items : null;
650
- }
651
- case 'debug': {
652
- const debugPrefix = subArgs[0] ?? '';
653
- const items = ['on', 'off', 'toggle', 'clear', 'show', 'stats']
654
- .filter((v) => v.startsWith(debugPrefix))
655
- .map((v) => ({
656
- value: `debug ${v}`,
657
- label: v,
658
- description: `Router debug: ${v}`,
659
- }));
660
- return items.length > 0 ? items : null;
661
- }
390
+ case 'log':
391
+ return items(
392
+ LOG_ACTIONS,
393
+ token,
394
+ (value) =>
395
+ ({
396
+ on: 'Collect decisions',
397
+ off: 'Stop collecting decisions',
398
+ clear: 'Forget collected decisions',
399
+ })[value] ?? value,
400
+ 'log ',
401
+ );
402
+ default:
403
+ return null;
662
404
  }
663
-
664
- return null;
665
405
  },
666
406
  handler: async (args, ctx) => {
667
- const parts = args?.trim().split(/\s+/) ?? [];
668
- const subcommand = parts[0];
669
- const subArgs = parts.slice(1);
670
- if (!subcommand) {
671
- await handleStatus(subArgs, ctx);
407
+ const parts = args?.trim().split(/\s+/).filter(Boolean) ?? [];
408
+ const [verb, ...rest] = parts;
409
+ if (!verb) {
410
+ showStatus(ctx);
672
411
  return;
673
412
  }
674
-
675
- switch (subcommand) {
676
- case 'profile':
677
- await handleProfile(subArgs, ctx);
678
- break;
413
+ const noArgs = (line: string) => {
414
+ if (rest.length > 0) {
415
+ usage(ctx, line);
416
+ return false;
417
+ }
418
+ return true;
419
+ };
420
+ switch (verb) {
679
421
  case 'pin':
680
- await handlePin(subArgs, ctx);
681
- break;
422
+ handlePin(rest, ctx);
423
+ return;
682
424
  case 'thinking':
683
- await handleThinking(subArgs, ctx);
684
- break;
685
- case 'disable':
686
- await handleDisable(subArgs, ctx);
687
- break;
688
- case 'fix':
689
- await handleFix(subArgs, ctx);
690
- break;
425
+ handleThinking(rest, ctx);
426
+ return;
427
+ case 'log':
428
+ handleLog(rest, ctx);
429
+ return;
691
430
  case 'widget':
692
- await handleWidget(subArgs, ctx);
693
- break;
694
- case 'debug':
695
- await handleDebug(subArgs, ctx);
696
- break;
431
+ if (noArgs('/router widget')) handleWidget(ctx);
432
+ return;
433
+ case 'off':
434
+ if (noArgs('/router off')) await handleOff(ctx);
435
+ return;
697
436
  case 'reload':
698
- await handleReload(subArgs, ctx);
699
- break;
700
- case 'status':
701
- await handleStatus(subArgs, ctx);
702
- break;
437
+ if (noArgs('/router reload')) await handleReload(ctx);
438
+ return;
703
439
  case 'help':
704
- case '?':
705
- if (subArgs.length > 0) {
706
- ctx.ui.notify('Usage: /router help (no arguments)', 'error');
707
- return;
708
- }
709
- ctx.ui.notify(
710
- [
711
- 'Router Subcommands:',
712
- ' status Show current status, profile, pin, cost, and last decision.',
713
- ' profile [name] Switch to a profile (enables router if off). Lists available if no name.',
714
- ' pin <tier|auto> Force a tier (high|medium|low|micro) or set to auto.',
715
- ' thinking [tier] <level> Override thinking level (off|minimal|...|max|auto). Not all tier models may support every level.',
716
- ' disable Disable the router and restore the last used non-router model.',
717
- ' fix <tier> Correct the last routing decision and pin that tier for the current profile.',
718
- ' widget <on|off|toggle> Control the persistent status widget visibility.',
719
- ' debug <on|off|show|stats|clear> Control decision history; stats summarize unique Jev requests.',
720
- ' reload Hot-reload the configuration JSON from .pi/model-router.json.',
721
- ' help, ? Show this help message.',
722
- ].join('\n'),
723
- 'info',
724
- );
725
- break;
440
+ if (noArgs('/router help')) ctx.ui.notify(USAGE, 'info');
441
+ return;
726
442
  default:
727
- if (subcommand) {
728
- // Check if subcommand is actually a profile name (backwards compatible-ish with /router-on)
729
- if (state.currentConfig.profiles[subcommand]) {
730
- if (subArgs.length > 0) {
731
- ctx.ui.notify(
732
- `Usage: /router ${subcommand} (no extra arguments allowed)`,
733
- 'error',
734
- );
735
- return;
736
- }
737
- if (await actions.switchToRouterProfile(subcommand, ctx)) {
738
- ctx.ui.notify(
739
- `Router enabled with profile: ${state.selectedProfile}`,
740
- 'info',
741
- );
742
- }
743
- } else {
744
- ctx.ui.notify(
745
- `Unknown router subcommand: ${subcommand}. Try /router help`,
746
- 'error',
747
- );
748
- }
749
- } else {
750
- await handleStatus(subArgs, ctx);
751
- }
752
443
  break;
753
444
  }
445
+ if (Object.hasOwn(state.currentConfig.profiles, verb)) {
446
+ if (noArgs(`/router ${verb}`)) await handleProfile(verb, ctx);
447
+ return;
448
+ }
449
+ const replacement = RETIRED_VERBS[verb];
450
+ ctx.ui.notify(
451
+ replacement
452
+ ? `/router ${verb} was removed; use ${replacement}`
453
+ : `Unknown router command: ${verb}. Try /router help`,
454
+ 'error',
455
+ );
754
456
  },
755
457
  });
756
458
  };