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