advisor-pi 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -7,16 +7,22 @@ import {
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Type, type Static } from "typebox";
10
+ import { loadPreferences, savePreferencesPatch, type AdvisorPreferencesPatch } from "./preferences.js";
11
+ import { buildMenuRows, type MenuKey, rowForLabel } from "./config-ui.js";
10
12
 
11
- const STATE_ENTRY = "advisor-pi-state";
13
+ export { loadPreferences, preferencesPath, savePreferences, savePreferencesPatch } from "./preferences.js";
14
+ export type { AdvisorPreferences, AdvisorPreferencesConfig, AdvisorPreferencesPatch } from "./preferences.js";
15
+
16
+ export const STATE_ENTRY = "advisor-pi-state";
12
17
  const TOOL_NAME = "advisor";
13
- const DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.6-sol";
14
- const LEGACY_DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.5";
18
+ export const DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.6-sol";
19
+ export const LEGACY_DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.5";
15
20
  const DEFAULT_ADVISOR_THINKING_LEVEL: AdvisorThinkingLevel = "high";
16
21
  const DEFAULT_MAX_USES = 5;
17
22
  const DEFAULT_CACHE_RETENTION: CacheRetention = "short";
18
23
  const DEFAULT_TIMEOUT_MS = 600_000;
19
24
  const DEFAULT_MAX_TOKENS = 4_000;
25
+ export const DEFAULT_MAX_TRANSCRIPT_CHARS = 20_000;
20
26
 
21
27
  const advisorToolSchema = Type.Object({
22
28
  question: Type.String({
@@ -40,7 +46,9 @@ const advisorToolSchema = Type.Object({
40
46
  type AdvisorToolInput = Static<typeof advisorToolSchema>;
41
47
  type AdvisorThinkingLevel = ThinkingLevel | "max";
42
48
 
43
- type AdvisorConfig = {
49
+ type ModeAwareContext = ExtensionContext & { mode?: string };
50
+
51
+ export type AdvisorConfig = {
44
52
  enabled: boolean;
45
53
  provider: string;
46
54
  modelId: string;
@@ -49,6 +57,7 @@ type AdvisorConfig = {
49
57
  cacheRetention: CacheRetention;
50
58
  maxTokens: number;
51
59
  timeoutMs: number;
60
+ maxTranscriptChars: number;
52
61
  };
53
62
 
54
63
  type AdvisorStateEntry = {
@@ -74,8 +83,21 @@ type AdvisorToolDetails = {
74
83
  state: AdvisorStateEntry;
75
84
  };
76
85
 
86
+ export type ModelRegistryLike = {
87
+ find: (provider: string, modelId: string) => unknown;
88
+ getAvailable?: () => Array<{ provider?: unknown; id?: unknown }>;
89
+ };
90
+
91
+ export type NormalizeConfigOptions = {
92
+ migrateLegacyDefault?: boolean;
93
+ };
94
+
95
+ function isTuiContext(ctx: ExtensionContext): boolean {
96
+ return (ctx as ModeAwareContext).mode === "tui" && ctx.hasUI;
97
+ }
98
+
77
99
  export default function advisorPiExtension(pi: ExtensionAPI) {
78
- let config = defaultConfig();
100
+ let config = normalizeConfig(loadPreferences(), defaultConfig(), undefined, { migrateLegacyDefault: false });
79
101
  let useCount = 0;
80
102
 
81
103
  pi.registerFlag("advisor-model", {
@@ -94,10 +116,13 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
94
116
  description: "Advisor prompt-cache preference: none, short, or long",
95
117
  type: "string",
96
118
  });
119
+ pi.registerFlag("advisor-max-transcript-chars", {
120
+ description: "Maximum conversation transcript characters sent per advisor call (keeps most recent)",
121
+ type: "string",
122
+ });
97
123
  pi.registerFlag("advisor-enabled", {
98
124
  description: "Enable the advisor tool on startup",
99
125
  type: "boolean",
100
- default: true,
101
126
  });
102
127
 
103
128
  pi.registerTool<typeof advisorToolSchema, AdvisorToolDetails>({
@@ -115,8 +140,8 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
115
140
  parameters: advisorToolSchema,
116
141
  executionMode: "sequential",
117
142
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
118
- refreshStateFromBranch(ctx);
119
-
143
+ // Branch state is replayed at session_start/session_tree; refreshing here
144
+ // would reapply startup flags over explicit in-session command edits.
120
145
  if (!config.enabled) {
121
146
  return advisorDisabledResult(config, useCount, params);
122
147
  }
@@ -146,7 +171,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
146
171
  if (!auth.ok) throw new Error(auth.error);
147
172
 
148
173
  const startedAt = Date.now();
149
- const conversationText = serializeCurrentConversation(ctx);
174
+ const conversationText = truncateTranscript(serializeCurrentConversation(ctx), config.maxTranscriptChars);
150
175
  const userMessage: Message = {
151
176
  role: "user",
152
177
  content: [
@@ -218,13 +243,16 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
218
243
  });
219
244
 
220
245
  pi.registerCommand("advisor-pi", {
221
- description: "Configure advisor-pi: status, enable, disable, model, thinking, max-uses, cache, reset",
246
+ description: "Configure advisor-pi: status, enable, disable, model, thinking, max-uses, max-transcript-chars, cache, reset",
222
247
  handler: async (args, ctx) => {
223
- const result = handleCommand(args.trim(), ctx);
224
- if (result.persist) persistState(pi, config, useCount);
225
- if (result.updateToolState) syncActiveTool(pi);
226
- updateStatus(ctx);
227
- ctx.ui.notify(result.message, result.level);
248
+ const trimmed = args.trim();
249
+ // Bare /advisor-pi is interactive only in the terminal TUI. Other modes
250
+ // keep the existing status response for scripts and print output.
251
+ if (!trimmed && isTuiContext(ctx)) {
252
+ await openConfigPanel(ctx);
253
+ return;
254
+ }
255
+ await applyCommand(trimmed, ctx);
228
256
  },
229
257
  });
230
258
 
@@ -248,28 +276,30 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
248
276
  });
249
277
 
250
278
  function refreshStateFromBranch(ctx: ExtensionContext): void {
251
- config = defaultConfig();
279
+ config = normalizeConfig(loadPreferences(), defaultConfig(), ctx.modelRegistry, { migrateLegacyDefault: false });
280
+
252
281
  useCount = 0;
253
- const restoredFromBranch = restoreStateFromSession(ctx);
254
- if (!restoredFromBranch) applyStartupFlags(pi);
282
+ restoreStateFromSession(ctx, ctx.modelRegistry);
283
+ applyStartupFlags(pi, ctx.modelRegistry);
284
+ ensureAdvisorModelResolves(ctx);
255
285
  syncActiveTool(pi);
256
286
  updateStatus(ctx);
257
287
  }
258
288
 
259
- function restoreStateFromSession(ctx: ExtensionContext): boolean {
289
+ function restoreStateFromSession(ctx: ExtensionContext, registry?: ModelRegistryLike): boolean {
260
290
  let restored = false;
261
291
  const branch = ctx.sessionManager.getBranch();
262
292
  for (const entry of branch) {
263
293
  if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
264
294
  restored = true;
265
295
  const data = entry.data as Partial<AdvisorStateEntry> | undefined;
266
- if (data?.config) config = normalizeConfig(data.config, config);
296
+ if (data?.config) config = normalizeConfig(data.config, config, registry);
267
297
  if (typeof data?.useCount === "number") useCount = Math.max(0, data.useCount);
268
298
  }
269
299
  if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === TOOL_NAME) {
270
300
  restored = true;
271
301
  const details = entry.message.details as Partial<AdvisorToolDetails> | undefined;
272
- if (details?.state?.config) config = normalizeConfig(details.state.config, config);
302
+ if (details?.state?.config) config = normalizeConfig(details.state.config, config, registry);
273
303
  if (typeof details?.state?.useCount === "number") useCount = Math.max(useCount, details.state.useCount);
274
304
  else if (details?.advisor?.useCount) useCount = Math.max(useCount, details.advisor.useCount);
275
305
  }
@@ -277,42 +307,78 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
277
307
  return restored;
278
308
  }
279
309
 
280
- function applyStartupFlags(api: ExtensionAPI): void {
281
- const enabledFlag = api.getFlag("advisor-enabled");
310
+ function applyStartupFlags(api: ExtensionAPI, registry?: ModelRegistryLike): void {
311
+ const enabledFlag = flagValue(api, "advisor-enabled");
282
312
  if (typeof enabledFlag === "boolean") config.enabled = enabledFlag;
283
313
 
284
- const modelFlag = api.getFlag("advisor-model");
314
+ const modelFlag = flagValue(api, "advisor-model");
285
315
  if (typeof modelFlag === "string" && modelFlag.trim()) {
286
316
  const parsed = parseModelSpec(modelFlag.trim());
287
- if (parsed) {
317
+ if (parsed && (!registry || modelResolves(registry, parsed.provider, parsed.modelId))) {
288
318
  config.provider = parsed.provider;
289
319
  config.modelId = parsed.modelId;
290
320
  }
291
321
  }
292
322
 
293
- const thinkingFlag = api.getFlag("advisor-thinking");
323
+ const thinkingFlag = flagValue(api, "advisor-thinking");
294
324
  if (typeof thinkingFlag === "string") {
295
325
  const thinkingLevel = parseThinkingLevel(thinkingFlag);
296
326
  if (thinkingLevel) config.thinkingLevel = thinkingLevel;
297
327
  }
298
328
 
299
- const maxUsesFlag = api.getFlag("advisor-max-uses");
329
+ const maxUsesFlag = flagValue(api, "advisor-max-uses");
300
330
  if (typeof maxUsesFlag === "string") {
301
331
  const maxUses = parsePositiveInt(maxUsesFlag);
302
332
  if (maxUses !== undefined) config.maxUses = maxUses;
303
333
  }
304
334
 
305
- const cacheFlag = api.getFlag("advisor-cache");
335
+ const cacheFlag = flagValue(api, "advisor-cache");
306
336
  if (typeof cacheFlag === "string") {
307
337
  const cacheRetention = parseCacheRetention(cacheFlag);
308
338
  if (cacheRetention) config.cacheRetention = cacheRetention;
309
339
  }
340
+
341
+ const maxTranscriptCharsFlag = flagValue(api, "advisor-max-transcript-chars");
342
+ if (typeof maxTranscriptCharsFlag === "string") {
343
+ const maxTranscriptChars = parsePositiveInt(maxTranscriptCharsFlag);
344
+ if (maxTranscriptChars !== undefined) config.maxTranscriptChars = maxTranscriptChars;
345
+ }
346
+ }
347
+
348
+ function ensureAdvisorModelResolves(ctx: ExtensionContext): void {
349
+ const registry = ctx.modelRegistry as ModelRegistryLike | undefined;
350
+ if (!registry || modelResolves(registry, config.provider, config.modelId)) return;
351
+ const previous = `${config.provider}/${config.modelId}`;
352
+ const candidates = [parseModelSpec(DEFAULT_ADVISOR_MODEL), parseModelSpec(LEGACY_DEFAULT_ADVISOR_MODEL)].filter(
353
+ (candidate): candidate is { provider: string; modelId: string } => candidate !== undefined,
354
+ );
355
+ const working = resolveAdvisorModel(registry, candidates);
356
+ if (!working) return;
357
+ config.provider = working.provider;
358
+ config.modelId = working.modelId;
359
+ persistState(pi, config, useCount);
360
+ if (ctx.hasUI) {
361
+ ctx.ui.notify(`Advisor model ${previous} not found. Falling back to ${working.provider}/${working.modelId}.`, "warning");
362
+ }
363
+ }
364
+
365
+ async function persistPreferencesForEdit(ctx: ExtensionContext, patch: AdvisorPreferencesPatch): Promise<void> {
366
+ if (await savePreferencesPatch(patch)) return;
367
+ if (ctx.hasUI) {
368
+ ctx.ui.notify("Could not save advisor-pi preferences; this change applies to the current session only.", "warning");
369
+ }
310
370
  }
311
371
 
312
372
  function handleCommand(
313
373
  args: string,
314
374
  ctx: ExtensionContext,
315
- ): { message: string; level: "info" | "warning" | "error"; persist: boolean; updateToolState: boolean } {
375
+ ): {
376
+ message: string;
377
+ level: "info" | "warning" | "error";
378
+ persist: boolean;
379
+ preferencePatch?: AdvisorPreferencesPatch;
380
+ updateToolState: boolean;
381
+ } {
316
382
  if (!args || args === "status") {
317
383
  return {
318
384
  message: formatStatus(ctx),
@@ -328,10 +394,22 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
328
394
  switch (command) {
329
395
  case "enable":
330
396
  config.enabled = true;
331
- return { message: "advisor-pi enabled", level: "info", persist: true, updateToolState: true };
397
+ return {
398
+ message: "advisor-pi enabled",
399
+ level: "info",
400
+ persist: true,
401
+ preferencePatch: { enabled: true },
402
+ updateToolState: true,
403
+ };
332
404
  case "disable":
333
405
  config.enabled = false;
334
- return { message: "advisor-pi disabled", level: "info", persist: true, updateToolState: true };
406
+ return {
407
+ message: "advisor-pi disabled",
408
+ level: "info",
409
+ persist: true,
410
+ preferencePatch: { enabled: false },
411
+ updateToolState: true,
412
+ };
335
413
  case "reset":
336
414
  useCount = 0;
337
415
  return { message: "advisor-pi use count reset", level: "info", persist: true, updateToolState: false };
@@ -360,6 +438,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
360
438
  message: `advisor-pi model set to ${config.provider}/${config.modelId}`,
361
439
  level: "info",
362
440
  persist: true,
441
+ preferencePatch: { provider: config.provider, modelId: config.modelId },
363
442
  updateToolState: false,
364
443
  };
365
444
  }
@@ -378,6 +457,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
378
457
  message: `advisor-pi thinking level set to ${thinkingLevel}`,
379
458
  level: "info",
380
459
  persist: true,
460
+ preferencePatch: { thinkingLevel },
381
461
  updateToolState: false,
382
462
  };
383
463
  }
@@ -396,6 +476,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
396
476
  message: `advisor-pi max uses set to ${maxUses}`,
397
477
  level: "info",
398
478
  persist: true,
479
+ preferencePatch: { maxUses },
399
480
  updateToolState: false,
400
481
  };
401
482
  }
@@ -414,13 +495,33 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
414
495
  message: `advisor-pi cache set to ${cacheRetention}`,
415
496
  level: "info",
416
497
  persist: true,
498
+ preferencePatch: { cacheRetention },
499
+ updateToolState: false,
500
+ };
501
+ }
502
+ case "max-transcript-chars": {
503
+ const maxTranscriptChars = parsePositiveInt(value);
504
+ if (maxTranscriptChars === undefined) {
505
+ return {
506
+ message: "Usage: /advisor-pi max-transcript-chars <positive-number>",
507
+ level: "error",
508
+ persist: false,
509
+ updateToolState: false,
510
+ };
511
+ }
512
+ config.maxTranscriptChars = maxTranscriptChars;
513
+ return {
514
+ message: `advisor-pi max transcript chars set to ${maxTranscriptChars}`,
515
+ level: "info",
516
+ persist: true,
517
+ preferencePatch: { maxTranscriptChars },
417
518
  updateToolState: false,
418
519
  };
419
520
  }
420
521
  default:
421
522
  return {
422
523
  message:
423
- "Usage: /advisor-pi [status|enable|disable|model <provider>/<model>|thinking <minimal|low|medium|high|xhigh|max>|max-uses <n>|cache <none|short|long>|reset]",
524
+ "Usage: /advisor-pi [status|enable|disable|model <provider>/<model>|thinking <minimal|low|medium|high|xhigh|max>|max-uses <n>|max-transcript-chars <n>|cache <none|short|long>|reset]",
424
525
  level: "error",
425
526
  persist: false,
426
527
  updateToolState: false,
@@ -428,6 +529,110 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
428
529
  }
429
530
  }
430
531
 
532
+ async function applyCommand(
533
+ args: string,
534
+ ctx: ExtensionContext,
535
+ options: { redactModelErrors?: boolean } = {},
536
+ ): Promise<void> {
537
+ const result = handleCommand(args, ctx);
538
+ if (result.persist) {
539
+ persistState(pi, config, useCount);
540
+ if (result.preferencePatch) await persistPreferencesForEdit(ctx, result.preferencePatch);
541
+ }
542
+ if (result.updateToolState) syncActiveTool(pi);
543
+ updateStatus(ctx);
544
+ const message = options.redactModelErrors && result.level === "error" ? "Advisor model not found" : result.message;
545
+ ctx.ui.notify(message, result.level);
546
+ }
547
+
548
+ async function openConfigPanel(ctx: ExtensionContext): Promise<void> {
549
+ for (;;) {
550
+ const rows = buildMenuRows(config, useCount);
551
+ const choice = await ctx.ui.select("advisor-pi settings", rows.map((row) => row.label));
552
+ const row = rowForLabel(rows, choice);
553
+ // Escape returns undefined, which closes the panel without changing state.
554
+ if (!row || row.key === "close") return;
555
+ await applyMenuChoice(row.key, ctx);
556
+ }
557
+ }
558
+
559
+ async function applyMenuChoice(key: MenuKey, ctx: ExtensionContext): Promise<void> {
560
+ switch (key) {
561
+ case "enabled": {
562
+ const choice = await ctx.ui.select("Advisor enabled", ["enabled", "disabled"]);
563
+ if (choice !== "enabled" && choice !== "disabled") return;
564
+ const enabled = choice === "enabled";
565
+ if (enabled === config.enabled) return;
566
+ const confirmed = await ctx.ui.confirm(
567
+ enabled ? "Enable advisor-pi?" : "Disable advisor-pi?",
568
+ enabled
569
+ ? "The advisor tool will be available to the executor."
570
+ : "The advisor tool will be removed from the active tool list.",
571
+ );
572
+ if (!confirmed) return;
573
+ await applyCommand(enabled ? "enable" : "disable", ctx);
574
+ return;
575
+ }
576
+ case "model": {
577
+ const typed = await ctx.ui.input("Advisor model", "provider/model");
578
+ if (typed === undefined) return;
579
+ const parsed = parseModelSpec(typed);
580
+ if (!parsed) {
581
+ ctx.ui.notify("Expected provider/model", "error");
582
+ return;
583
+ }
584
+ let model: unknown;
585
+ try {
586
+ model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
587
+ } catch {
588
+ model = undefined;
589
+ }
590
+ if (!model) {
591
+ ctx.ui.notify("Advisor model not found", "error");
592
+ return;
593
+ }
594
+ await applyCommand(`model ${parsed.provider}/${parsed.modelId}`, ctx, { redactModelErrors: true });
595
+ return;
596
+ }
597
+ case "thinking": {
598
+ const choice = await ctx.ui.select("Advisor thinking", ["minimal", "low", "medium", "high", "xhigh", "max"]);
599
+ if (!parseThinkingLevel(choice)) return;
600
+ await applyCommand(`thinking ${choice}`, ctx);
601
+ return;
602
+ }
603
+ case "max-uses": {
604
+ const value = await ctx.ui.input("Maximum advisor uses", String(config.maxUses));
605
+ if (value === undefined) return;
606
+ await applyCommand(`max-uses ${value}`, ctx);
607
+ return;
608
+ }
609
+ case "max-transcript-chars": {
610
+ const value = await ctx.ui.input("Maximum transcript characters", String(config.maxTranscriptChars));
611
+ if (value === undefined) return;
612
+ await applyCommand(`max-transcript-chars ${value}`, ctx);
613
+ return;
614
+ }
615
+ case "cache": {
616
+ const choice = await ctx.ui.select("Advisor cache retention", ["none", "short", "long"]);
617
+ if (!parseCacheRetention(choice)) return;
618
+ await applyCommand(`cache ${choice}`, ctx);
619
+ return;
620
+ }
621
+ case "reset": {
622
+ if (useCount <= 0) return;
623
+ const confirmed = await ctx.ui.confirm(
624
+ "Reset advisor use count?",
625
+ `This clears ${useCount} advisor use${useCount === 1 ? "" : "s"} for the current session branch.`,
626
+ );
627
+ if (!confirmed) return;
628
+ await applyCommand("reset", ctx);
629
+ return;
630
+ }
631
+ case "close":
632
+ return;
633
+ }
634
+ }
635
+
431
636
  function syncActiveTool(api: ExtensionAPI): void {
432
637
  const activeTools = api.getActiveTools();
433
638
  const hasAdvisor = activeTools.includes(TOOL_NAME);
@@ -460,6 +665,7 @@ export default function advisorPiExtension(pi: ExtensionAPI) {
460
665
  `model: ${config.provider}/${config.modelId} (${availability})`,
461
666
  `thinking: ${config.thinkingLevel}`,
462
667
  `uses: ${useCount}/${config.maxUses}`,
668
+ `transcript: max ${config.maxTranscriptChars} chars`,
463
669
  `cache: ${config.cacheRetention}`,
464
670
  ].join(" • ");
465
671
  }
@@ -477,7 +683,7 @@ Your role is strategic guidance only. You do not have tools and you do not make
477
683
 
478
684
  Return guidance in short sections with bullets when useful.`;
479
685
 
480
- function defaultConfig(): AdvisorConfig {
686
+ export function defaultConfig(): AdvisorConfig {
481
687
  const parsed = parseModelSpec(DEFAULT_ADVISOR_MODEL) ?? { provider: "openai-codex", modelId: "gpt-5.6-sol" };
482
688
  return {
483
689
  enabled: true,
@@ -488,6 +694,7 @@ function defaultConfig(): AdvisorConfig {
488
694
  cacheRetention: DEFAULT_CACHE_RETENTION,
489
695
  maxTokens: DEFAULT_MAX_TOKENS,
490
696
  timeoutMs: DEFAULT_TIMEOUT_MS,
697
+ maxTranscriptChars: DEFAULT_MAX_TRANSCRIPT_CHARS,
491
698
  };
492
699
  }
493
700
 
@@ -509,6 +716,13 @@ function serializeCurrentConversation(ctx: ExtensionContext): string {
509
716
  return serializeConversation(llmMessages);
510
717
  }
511
718
 
719
+ export function truncateTranscript(text: string, maxChars: number): string {
720
+ if (maxChars <= 0) return text;
721
+ if (text.length <= maxChars) return text;
722
+ const omitted = text.length - maxChars;
723
+ return `[... earlier conversation truncated: showing the most recent ${maxChars} of ${text.length} characters (${omitted} omitted)]\n\n${text.slice(text.length - maxChars)}`;
724
+ }
725
+
512
726
  function buildAdvisorUserPrompt(params: AdvisorToolInput, conversationText: string): string {
513
727
  return [
514
728
  "## Executor Question",
@@ -572,7 +786,7 @@ function makeSkippedDetails(config: AdvisorConfig, useCount: number, params: Adv
572
786
  };
573
787
  }
574
788
 
575
- function makeStateEntry(config: AdvisorConfig, useCount: number): AdvisorStateEntry {
789
+ export function makeStateEntry(config: AdvisorConfig, useCount: number): AdvisorStateEntry {
576
790
  return {
577
791
  version: 1,
578
792
  config: { ...config },
@@ -581,48 +795,116 @@ function makeStateEntry(config: AdvisorConfig, useCount: number): AdvisorStateEn
581
795
  };
582
796
  }
583
797
 
584
- function persistState(pi: ExtensionAPI, config: AdvisorConfig, useCount: number): void {
798
+ export function persistState(pi: ExtensionAPI, config: AdvisorConfig, useCount: number): void {
585
799
  pi.appendEntry(STATE_ENTRY, makeStateEntry(config, useCount));
586
800
  }
587
801
 
588
- function normalizeConfig(input: Partial<AdvisorConfig>, fallback: AdvisorConfig): AdvisorConfig {
802
+ export function normalizeConfig(
803
+ input: Partial<AdvisorConfig>,
804
+ fallback: AdvisorConfig,
805
+ registry?: ModelRegistryLike,
806
+ options: NormalizeConfigOptions = {},
807
+ ): AdvisorConfig {
589
808
  const normalized: AdvisorConfig = {
590
809
  enabled: typeof input.enabled === "boolean" ? input.enabled : fallback.enabled,
591
- provider: typeof input.provider === "string" && input.provider ? input.provider : fallback.provider,
592
- modelId: typeof input.modelId === "string" && input.modelId ? input.modelId : fallback.modelId,
810
+ provider: typeof input.provider === "string" && input.provider.trim() ? input.provider.trim() : fallback.provider,
811
+ modelId: typeof input.modelId === "string" && input.modelId.trim() ? input.modelId.trim() : fallback.modelId,
593
812
  thinkingLevel: parseThinkingLevel(input.thinkingLevel) ?? fallback.thinkingLevel,
594
- maxUses: typeof input.maxUses === "number" && input.maxUses > 0 ? Math.floor(input.maxUses) : fallback.maxUses,
813
+ maxUses:
814
+ typeof input.maxUses === "number" && Number.isFinite(input.maxUses) && Math.floor(input.maxUses) > 0
815
+ ? Math.floor(input.maxUses)
816
+ : fallback.maxUses,
595
817
  cacheRetention: parseCacheRetention(input.cacheRetention) ?? fallback.cacheRetention,
596
- maxTokens: typeof input.maxTokens === "number" && input.maxTokens > 0 ? Math.floor(input.maxTokens) : fallback.maxTokens,
597
- timeoutMs: typeof input.timeoutMs === "number" && input.timeoutMs > 0 ? Math.floor(input.timeoutMs) : fallback.timeoutMs,
818
+ maxTokens:
819
+ typeof input.maxTokens === "number" && Number.isFinite(input.maxTokens) && Math.floor(input.maxTokens) > 0
820
+ ? Math.floor(input.maxTokens)
821
+ : fallback.maxTokens,
822
+ timeoutMs:
823
+ typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) && Math.floor(input.timeoutMs) > 0
824
+ ? Math.floor(input.timeoutMs)
825
+ : fallback.timeoutMs,
826
+ maxTranscriptChars:
827
+ typeof input.maxTranscriptChars === "number" && Number.isFinite(input.maxTranscriptChars) && Math.floor(input.maxTranscriptChars) > 0
828
+ ? Math.floor(input.maxTranscriptChars)
829
+ : fallback.maxTranscriptChars,
598
830
  };
599
831
 
600
832
  const legacyDefault = parseModelSpec(LEGACY_DEFAULT_ADVISOR_MODEL);
601
833
  if (
834
+ options.migrateLegacyDefault !== false &&
602
835
  input.thinkingLevel === undefined &&
603
836
  legacyDefault &&
604
837
  normalized.provider === legacyDefault.provider &&
605
838
  normalized.modelId === legacyDefault.modelId
606
839
  ) {
607
- normalized.provider = fallback.provider;
608
- normalized.modelId = fallback.modelId;
840
+ // Only rewrite the stored legacy default when the migration target resolves.
841
+ // Otherwise a resolving stored model would be replaced by a non-resolving default.
842
+ if (!registry || modelResolves(registry, fallback.provider, fallback.modelId)) {
843
+ normalized.provider = fallback.provider;
844
+ normalized.modelId = fallback.modelId;
845
+ }
846
+ }
847
+
848
+ if (registry && typeof input.provider === "string" && input.provider && typeof input.modelId === "string" && input.modelId) {
849
+ // Never replace a resolving stored model with a non-resolving one.
850
+ if (modelResolves(registry, input.provider, input.modelId) && !modelResolves(registry, normalized.provider, normalized.modelId)) {
851
+ normalized.provider = input.provider;
852
+ normalized.modelId = input.modelId;
853
+ }
609
854
  }
610
855
 
611
856
  return normalized;
612
857
  }
613
858
 
614
- function parseModelSpec(value: string): { provider: string; modelId: string } | undefined {
615
- const slash = value.indexOf("/");
616
- if (slash <= 0 || slash === value.length - 1) return undefined;
617
- return {
618
- provider: value.slice(0, slash),
619
- modelId: value.slice(slash + 1),
620
- };
859
+ export function modelResolves(registry: ModelRegistryLike | undefined, provider: string, modelId: string): boolean {
860
+ if (!registry) return false;
861
+ try {
862
+ const found = registry.find(provider, modelId);
863
+ return found !== undefined && found !== null;
864
+ } catch {
865
+ return false;
866
+ }
867
+ }
868
+
869
+ export function resolveAdvisorModel(
870
+ registry: ModelRegistryLike | undefined,
871
+ candidates: Array<{ provider: string; modelId: string }>,
872
+ ): { provider: string; modelId: string } | undefined {
873
+ if (!registry) return undefined;
874
+ for (const candidate of candidates) {
875
+ if (modelResolves(registry, candidate.provider, candidate.modelId)) return candidate;
876
+ }
877
+ try {
878
+ const available = registry.getAvailable?.();
879
+ const first = Array.isArray(available) ? available[0] : undefined;
880
+ if (first && typeof first.provider === "string" && typeof first.id === "string" && first.provider && first.id) {
881
+ return { provider: first.provider, modelId: first.id };
882
+ }
883
+ } catch {
884
+ // Ignore registry listing failures; callers keep the current config.
885
+ }
886
+ return undefined;
887
+ }
888
+
889
+ export function parseModelSpec(value: string): { provider: string; modelId: string } | undefined {
890
+ const trimmed = value.trim();
891
+ const slash = trimmed.indexOf("/");
892
+ if (slash <= 0 || slash === trimmed.length - 1) return undefined;
893
+ const provider = trimmed.slice(0, slash).trim();
894
+ const modelId = trimmed.slice(slash + 1).trim();
895
+ if (!provider || !modelId) return undefined;
896
+ return { provider, modelId };
897
+ }
898
+
899
+ function flagValue(api: ExtensionAPI, name: string): boolean | string | undefined {
900
+ return typeof api.getFlag === "function" ? api.getFlag(name) : undefined;
621
901
  }
622
902
 
623
903
  function parsePositiveInt(value: string): number | undefined {
624
- const parsed = Number.parseInt(value, 10);
625
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
904
+ const trimmed = value.trim();
905
+ if (!/^\d+$/.test(trimmed)) return undefined;
906
+ const parsed = Number(trimmed);
907
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
626
908
  return parsed;
627
909
  }
628
910