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/dist/index.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { completeSimple, StringEnum } from "@earendil-works/pi-ai";
2
2
  import { buildSessionContext, convertToLlm, serializeConversation, } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- const STATE_ENTRY = "advisor-pi-state";
4
+ import { loadPreferences, savePreferencesPatch } from "./preferences.js";
5
+ import { buildMenuRows, rowForLabel } from "./config-ui.js";
6
+ export { loadPreferences, preferencesPath, savePreferences, savePreferencesPatch } from "./preferences.js";
7
+ export const STATE_ENTRY = "advisor-pi-state";
5
8
  const TOOL_NAME = "advisor";
6
- const DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.6-sol";
7
- const LEGACY_DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.5";
9
+ export const DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.6-sol";
10
+ export const LEGACY_DEFAULT_ADVISOR_MODEL = "openai-codex/gpt-5.5";
8
11
  const DEFAULT_ADVISOR_THINKING_LEVEL = "high";
9
12
  const DEFAULT_MAX_USES = 5;
10
13
  const DEFAULT_CACHE_RETENTION = "short";
11
14
  const DEFAULT_TIMEOUT_MS = 600_000;
12
15
  const DEFAULT_MAX_TOKENS = 4_000;
16
+ export const DEFAULT_MAX_TRANSCRIPT_CHARS = 20_000;
13
17
  const advisorToolSchema = Type.Object({
14
18
  question: Type.String({
15
19
  description: "Strategic question for the advisor. Ask for planning, risk analysis, course correction, or review guidance.",
@@ -22,8 +26,11 @@ const advisorToolSchema = Type.Object({
22
26
  description: "Optional extra context that is not obvious from the transcript, such as constraints, current plan, or failed attempts.",
23
27
  })),
24
28
  });
29
+ function isTuiContext(ctx) {
30
+ return ctx.mode === "tui" && ctx.hasUI;
31
+ }
25
32
  export default function advisorPiExtension(pi) {
26
- let config = defaultConfig();
33
+ let config = normalizeConfig(loadPreferences(), defaultConfig(), undefined, { migrateLegacyDefault: false });
27
34
  let useCount = 0;
28
35
  pi.registerFlag("advisor-model", {
29
36
  description: "Advisor model as provider/model, e.g. openai-codex/gpt-5.6-sol",
@@ -41,10 +48,13 @@ export default function advisorPiExtension(pi) {
41
48
  description: "Advisor prompt-cache preference: none, short, or long",
42
49
  type: "string",
43
50
  });
51
+ pi.registerFlag("advisor-max-transcript-chars", {
52
+ description: "Maximum conversation transcript characters sent per advisor call (keeps most recent)",
53
+ type: "string",
54
+ });
44
55
  pi.registerFlag("advisor-enabled", {
45
56
  description: "Enable the advisor tool on startup",
46
57
  type: "boolean",
47
- default: true,
48
58
  });
49
59
  pi.registerTool({
50
60
  name: TOOL_NAME,
@@ -59,7 +69,8 @@ export default function advisorPiExtension(pi) {
59
69
  parameters: advisorToolSchema,
60
70
  executionMode: "sequential",
61
71
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
62
- refreshStateFromBranch(ctx);
72
+ // Branch state is replayed at session_start/session_tree; refreshing here
73
+ // would reapply startup flags over explicit in-session command edits.
63
74
  if (!config.enabled) {
64
75
  return advisorDisabledResult(config, useCount, params);
65
76
  }
@@ -83,7 +94,7 @@ export default function advisorPiExtension(pi) {
83
94
  if (!auth.ok)
84
95
  throw new Error(auth.error);
85
96
  const startedAt = Date.now();
86
- const conversationText = serializeCurrentConversation(ctx);
97
+ const conversationText = truncateTranscript(serializeCurrentConversation(ctx), config.maxTranscriptChars);
87
98
  const userMessage = {
88
99
  role: "user",
89
100
  content: [
@@ -145,15 +156,16 @@ export default function advisorPiExtension(pi) {
145
156
  },
146
157
  });
147
158
  pi.registerCommand("advisor-pi", {
148
- description: "Configure advisor-pi: status, enable, disable, model, thinking, max-uses, cache, reset",
159
+ description: "Configure advisor-pi: status, enable, disable, model, thinking, max-uses, max-transcript-chars, cache, reset",
149
160
  handler: async (args, ctx) => {
150
- const result = handleCommand(args.trim(), ctx);
151
- if (result.persist)
152
- persistState(pi, config, useCount);
153
- if (result.updateToolState)
154
- syncActiveTool(pi);
155
- updateStatus(ctx);
156
- ctx.ui.notify(result.message, result.level);
161
+ const trimmed = args.trim();
162
+ // Bare /advisor-pi is interactive only in the terminal TUI. Other modes
163
+ // keep the existing status response for scripts and print output.
164
+ if (!trimmed && isTuiContext(ctx)) {
165
+ await openConfigPanel(ctx);
166
+ return;
167
+ }
168
+ await applyCommand(trimmed, ctx);
157
169
  },
158
170
  });
159
171
  pi.on("session_start", async (_event, ctx) => {
@@ -173,15 +185,15 @@ export default function advisorPiExtension(pi) {
173
185
  persistState(pi, config, useCount);
174
186
  });
175
187
  function refreshStateFromBranch(ctx) {
176
- config = defaultConfig();
188
+ config = normalizeConfig(loadPreferences(), defaultConfig(), ctx.modelRegistry, { migrateLegacyDefault: false });
177
189
  useCount = 0;
178
- const restoredFromBranch = restoreStateFromSession(ctx);
179
- if (!restoredFromBranch)
180
- applyStartupFlags(pi);
190
+ restoreStateFromSession(ctx, ctx.modelRegistry);
191
+ applyStartupFlags(pi, ctx.modelRegistry);
192
+ ensureAdvisorModelResolves(ctx);
181
193
  syncActiveTool(pi);
182
194
  updateStatus(ctx);
183
195
  }
184
- function restoreStateFromSession(ctx) {
196
+ function restoreStateFromSession(ctx, registry) {
185
197
  let restored = false;
186
198
  const branch = ctx.sessionManager.getBranch();
187
199
  for (const entry of branch) {
@@ -189,7 +201,7 @@ export default function advisorPiExtension(pi) {
189
201
  restored = true;
190
202
  const data = entry.data;
191
203
  if (data?.config)
192
- config = normalizeConfig(data.config, config);
204
+ config = normalizeConfig(data.config, config, registry);
193
205
  if (typeof data?.useCount === "number")
194
206
  useCount = Math.max(0, data.useCount);
195
207
  }
@@ -197,7 +209,7 @@ export default function advisorPiExtension(pi) {
197
209
  restored = true;
198
210
  const details = entry.message.details;
199
211
  if (details?.state?.config)
200
- config = normalizeConfig(details.state.config, config);
212
+ config = normalizeConfig(details.state.config, config, registry);
201
213
  if (typeof details?.state?.useCount === "number")
202
214
  useCount = Math.max(useCount, details.state.useCount);
203
215
  else if (details?.advisor?.useCount)
@@ -206,36 +218,65 @@ export default function advisorPiExtension(pi) {
206
218
  }
207
219
  return restored;
208
220
  }
209
- function applyStartupFlags(api) {
210
- const enabledFlag = api.getFlag("advisor-enabled");
221
+ function applyStartupFlags(api, registry) {
222
+ const enabledFlag = flagValue(api, "advisor-enabled");
211
223
  if (typeof enabledFlag === "boolean")
212
224
  config.enabled = enabledFlag;
213
- const modelFlag = api.getFlag("advisor-model");
225
+ const modelFlag = flagValue(api, "advisor-model");
214
226
  if (typeof modelFlag === "string" && modelFlag.trim()) {
215
227
  const parsed = parseModelSpec(modelFlag.trim());
216
- if (parsed) {
228
+ if (parsed && (!registry || modelResolves(registry, parsed.provider, parsed.modelId))) {
217
229
  config.provider = parsed.provider;
218
230
  config.modelId = parsed.modelId;
219
231
  }
220
232
  }
221
- const thinkingFlag = api.getFlag("advisor-thinking");
233
+ const thinkingFlag = flagValue(api, "advisor-thinking");
222
234
  if (typeof thinkingFlag === "string") {
223
235
  const thinkingLevel = parseThinkingLevel(thinkingFlag);
224
236
  if (thinkingLevel)
225
237
  config.thinkingLevel = thinkingLevel;
226
238
  }
227
- const maxUsesFlag = api.getFlag("advisor-max-uses");
239
+ const maxUsesFlag = flagValue(api, "advisor-max-uses");
228
240
  if (typeof maxUsesFlag === "string") {
229
241
  const maxUses = parsePositiveInt(maxUsesFlag);
230
242
  if (maxUses !== undefined)
231
243
  config.maxUses = maxUses;
232
244
  }
233
- const cacheFlag = api.getFlag("advisor-cache");
245
+ const cacheFlag = flagValue(api, "advisor-cache");
234
246
  if (typeof cacheFlag === "string") {
235
247
  const cacheRetention = parseCacheRetention(cacheFlag);
236
248
  if (cacheRetention)
237
249
  config.cacheRetention = cacheRetention;
238
250
  }
251
+ const maxTranscriptCharsFlag = flagValue(api, "advisor-max-transcript-chars");
252
+ if (typeof maxTranscriptCharsFlag === "string") {
253
+ const maxTranscriptChars = parsePositiveInt(maxTranscriptCharsFlag);
254
+ if (maxTranscriptChars !== undefined)
255
+ config.maxTranscriptChars = maxTranscriptChars;
256
+ }
257
+ }
258
+ function ensureAdvisorModelResolves(ctx) {
259
+ const registry = ctx.modelRegistry;
260
+ if (!registry || modelResolves(registry, config.provider, config.modelId))
261
+ return;
262
+ const previous = `${config.provider}/${config.modelId}`;
263
+ const candidates = [parseModelSpec(DEFAULT_ADVISOR_MODEL), parseModelSpec(LEGACY_DEFAULT_ADVISOR_MODEL)].filter((candidate) => candidate !== undefined);
264
+ const working = resolveAdvisorModel(registry, candidates);
265
+ if (!working)
266
+ return;
267
+ config.provider = working.provider;
268
+ config.modelId = working.modelId;
269
+ persistState(pi, config, useCount);
270
+ if (ctx.hasUI) {
271
+ ctx.ui.notify(`Advisor model ${previous} not found. Falling back to ${working.provider}/${working.modelId}.`, "warning");
272
+ }
273
+ }
274
+ async function persistPreferencesForEdit(ctx, patch) {
275
+ if (await savePreferencesPatch(patch))
276
+ return;
277
+ if (ctx.hasUI) {
278
+ ctx.ui.notify("Could not save advisor-pi preferences; this change applies to the current session only.", "warning");
279
+ }
239
280
  }
240
281
  function handleCommand(args, ctx) {
241
282
  if (!args || args === "status") {
@@ -251,10 +292,22 @@ export default function advisorPiExtension(pi) {
251
292
  switch (command) {
252
293
  case "enable":
253
294
  config.enabled = true;
254
- return { message: "advisor-pi enabled", level: "info", persist: true, updateToolState: true };
295
+ return {
296
+ message: "advisor-pi enabled",
297
+ level: "info",
298
+ persist: true,
299
+ preferencePatch: { enabled: true },
300
+ updateToolState: true,
301
+ };
255
302
  case "disable":
256
303
  config.enabled = false;
257
- return { message: "advisor-pi disabled", level: "info", persist: true, updateToolState: true };
304
+ return {
305
+ message: "advisor-pi disabled",
306
+ level: "info",
307
+ persist: true,
308
+ preferencePatch: { enabled: false },
309
+ updateToolState: true,
310
+ };
258
311
  case "reset":
259
312
  useCount = 0;
260
313
  return { message: "advisor-pi use count reset", level: "info", persist: true, updateToolState: false };
@@ -283,6 +336,7 @@ export default function advisorPiExtension(pi) {
283
336
  message: `advisor-pi model set to ${config.provider}/${config.modelId}`,
284
337
  level: "info",
285
338
  persist: true,
339
+ preferencePatch: { provider: config.provider, modelId: config.modelId },
286
340
  updateToolState: false,
287
341
  };
288
342
  }
@@ -301,6 +355,7 @@ export default function advisorPiExtension(pi) {
301
355
  message: `advisor-pi thinking level set to ${thinkingLevel}`,
302
356
  level: "info",
303
357
  persist: true,
358
+ preferencePatch: { thinkingLevel },
304
359
  updateToolState: false,
305
360
  };
306
361
  }
@@ -319,6 +374,7 @@ export default function advisorPiExtension(pi) {
319
374
  message: `advisor-pi max uses set to ${maxUses}`,
320
375
  level: "info",
321
376
  persist: true,
377
+ preferencePatch: { maxUses },
322
378
  updateToolState: false,
323
379
  };
324
380
  }
@@ -337,18 +393,143 @@ export default function advisorPiExtension(pi) {
337
393
  message: `advisor-pi cache set to ${cacheRetention}`,
338
394
  level: "info",
339
395
  persist: true,
396
+ preferencePatch: { cacheRetention },
397
+ updateToolState: false,
398
+ };
399
+ }
400
+ case "max-transcript-chars": {
401
+ const maxTranscriptChars = parsePositiveInt(value);
402
+ if (maxTranscriptChars === undefined) {
403
+ return {
404
+ message: "Usage: /advisor-pi max-transcript-chars <positive-number>",
405
+ level: "error",
406
+ persist: false,
407
+ updateToolState: false,
408
+ };
409
+ }
410
+ config.maxTranscriptChars = maxTranscriptChars;
411
+ return {
412
+ message: `advisor-pi max transcript chars set to ${maxTranscriptChars}`,
413
+ level: "info",
414
+ persist: true,
415
+ preferencePatch: { maxTranscriptChars },
340
416
  updateToolState: false,
341
417
  };
342
418
  }
343
419
  default:
344
420
  return {
345
- message: "Usage: /advisor-pi [status|enable|disable|model <provider>/<model>|thinking <minimal|low|medium|high|xhigh|max>|max-uses <n>|cache <none|short|long>|reset]",
421
+ message: "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]",
346
422
  level: "error",
347
423
  persist: false,
348
424
  updateToolState: false,
349
425
  };
350
426
  }
351
427
  }
428
+ async function applyCommand(args, ctx, options = {}) {
429
+ const result = handleCommand(args, ctx);
430
+ if (result.persist) {
431
+ persistState(pi, config, useCount);
432
+ if (result.preferencePatch)
433
+ await persistPreferencesForEdit(ctx, result.preferencePatch);
434
+ }
435
+ if (result.updateToolState)
436
+ syncActiveTool(pi);
437
+ updateStatus(ctx);
438
+ const message = options.redactModelErrors && result.level === "error" ? "Advisor model not found" : result.message;
439
+ ctx.ui.notify(message, result.level);
440
+ }
441
+ async function openConfigPanel(ctx) {
442
+ for (;;) {
443
+ const rows = buildMenuRows(config, useCount);
444
+ const choice = await ctx.ui.select("advisor-pi settings", rows.map((row) => row.label));
445
+ const row = rowForLabel(rows, choice);
446
+ // Escape returns undefined, which closes the panel without changing state.
447
+ if (!row || row.key === "close")
448
+ return;
449
+ await applyMenuChoice(row.key, ctx);
450
+ }
451
+ }
452
+ async function applyMenuChoice(key, ctx) {
453
+ switch (key) {
454
+ case "enabled": {
455
+ const choice = await ctx.ui.select("Advisor enabled", ["enabled", "disabled"]);
456
+ if (choice !== "enabled" && choice !== "disabled")
457
+ return;
458
+ const enabled = choice === "enabled";
459
+ if (enabled === config.enabled)
460
+ return;
461
+ const confirmed = await ctx.ui.confirm(enabled ? "Enable advisor-pi?" : "Disable advisor-pi?", enabled
462
+ ? "The advisor tool will be available to the executor."
463
+ : "The advisor tool will be removed from the active tool list.");
464
+ if (!confirmed)
465
+ return;
466
+ await applyCommand(enabled ? "enable" : "disable", ctx);
467
+ return;
468
+ }
469
+ case "model": {
470
+ const typed = await ctx.ui.input("Advisor model", "provider/model");
471
+ if (typed === undefined)
472
+ return;
473
+ const parsed = parseModelSpec(typed);
474
+ if (!parsed) {
475
+ ctx.ui.notify("Expected provider/model", "error");
476
+ return;
477
+ }
478
+ let model;
479
+ try {
480
+ model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
481
+ }
482
+ catch {
483
+ model = undefined;
484
+ }
485
+ if (!model) {
486
+ ctx.ui.notify("Advisor model not found", "error");
487
+ return;
488
+ }
489
+ await applyCommand(`model ${parsed.provider}/${parsed.modelId}`, ctx, { redactModelErrors: true });
490
+ return;
491
+ }
492
+ case "thinking": {
493
+ const choice = await ctx.ui.select("Advisor thinking", ["minimal", "low", "medium", "high", "xhigh", "max"]);
494
+ if (!parseThinkingLevel(choice))
495
+ return;
496
+ await applyCommand(`thinking ${choice}`, ctx);
497
+ return;
498
+ }
499
+ case "max-uses": {
500
+ const value = await ctx.ui.input("Maximum advisor uses", String(config.maxUses));
501
+ if (value === undefined)
502
+ return;
503
+ await applyCommand(`max-uses ${value}`, ctx);
504
+ return;
505
+ }
506
+ case "max-transcript-chars": {
507
+ const value = await ctx.ui.input("Maximum transcript characters", String(config.maxTranscriptChars));
508
+ if (value === undefined)
509
+ return;
510
+ await applyCommand(`max-transcript-chars ${value}`, ctx);
511
+ return;
512
+ }
513
+ case "cache": {
514
+ const choice = await ctx.ui.select("Advisor cache retention", ["none", "short", "long"]);
515
+ if (!parseCacheRetention(choice))
516
+ return;
517
+ await applyCommand(`cache ${choice}`, ctx);
518
+ return;
519
+ }
520
+ case "reset": {
521
+ if (useCount <= 0)
522
+ return;
523
+ const confirmed = await ctx.ui.confirm("Reset advisor use count?", `This clears ${useCount} advisor use${useCount === 1 ? "" : "s"} for the current session branch.`);
524
+ if (!confirmed)
525
+ return;
526
+ await applyCommand("reset", ctx);
527
+ return;
528
+ }
529
+ case "close":
530
+ return;
531
+ }
532
+ }
352
533
  function syncActiveTool(api) {
353
534
  const activeTools = api.getActiveTools();
354
535
  const hasAdvisor = activeTools.includes(TOOL_NAME);
@@ -378,6 +559,7 @@ export default function advisorPiExtension(pi) {
378
559
  `model: ${config.provider}/${config.modelId} (${availability})`,
379
560
  `thinking: ${config.thinkingLevel}`,
380
561
  `uses: ${useCount}/${config.maxUses}`,
562
+ `transcript: max ${config.maxTranscriptChars} chars`,
381
563
  `cache: ${config.cacheRetention}`,
382
564
  ].join(" • ");
383
565
  }
@@ -393,7 +575,7 @@ Your role is strategic guidance only. You do not have tools and you do not make
393
575
  - If the task is unsafe, impossible, or underspecified, say what must be clarified.
394
576
 
395
577
  Return guidance in short sections with bullets when useful.`;
396
- function defaultConfig() {
578
+ export function defaultConfig() {
397
579
  const parsed = parseModelSpec(DEFAULT_ADVISOR_MODEL) ?? { provider: "openai-codex", modelId: "gpt-5.6-sol" };
398
580
  return {
399
581
  enabled: true,
@@ -404,6 +586,7 @@ function defaultConfig() {
404
586
  cacheRetention: DEFAULT_CACHE_RETENTION,
405
587
  maxTokens: DEFAULT_MAX_TOKENS,
406
588
  timeoutMs: DEFAULT_TIMEOUT_MS,
589
+ maxTranscriptChars: DEFAULT_MAX_TRANSCRIPT_CHARS,
407
590
  };
408
591
  }
409
592
  function buildExecutorGuidance(config, useCount) {
@@ -422,6 +605,14 @@ function serializeCurrentConversation(ctx) {
422
605
  const llmMessages = convertToLlm(sessionContext.messages);
423
606
  return serializeConversation(llmMessages);
424
607
  }
608
+ export function truncateTranscript(text, maxChars) {
609
+ if (maxChars <= 0)
610
+ return text;
611
+ if (text.length <= maxChars)
612
+ return text;
613
+ const omitted = text.length - maxChars;
614
+ return `[... earlier conversation truncated: showing the most recent ${maxChars} of ${text.length} characters (${omitted} omitted)]\n\n${text.slice(text.length - maxChars)}`;
615
+ }
425
616
  function buildAdvisorUserPrompt(params, conversationText) {
426
617
  return [
427
618
  "## Executor Question",
@@ -480,7 +671,7 @@ function makeSkippedDetails(config, useCount, params) {
480
671
  state: makeStateEntry(config, useCount),
481
672
  };
482
673
  }
483
- function makeStateEntry(config, useCount) {
674
+ export function makeStateEntry(config, useCount) {
484
675
  return {
485
676
  version: 1,
486
677
  config: { ...config },
@@ -488,42 +679,101 @@ function makeStateEntry(config, useCount) {
488
679
  updatedAt: new Date().toISOString(),
489
680
  };
490
681
  }
491
- function persistState(pi, config, useCount) {
682
+ export function persistState(pi, config, useCount) {
492
683
  pi.appendEntry(STATE_ENTRY, makeStateEntry(config, useCount));
493
684
  }
494
- function normalizeConfig(input, fallback) {
685
+ export function normalizeConfig(input, fallback, registry, options = {}) {
495
686
  const normalized = {
496
687
  enabled: typeof input.enabled === "boolean" ? input.enabled : fallback.enabled,
497
- provider: typeof input.provider === "string" && input.provider ? input.provider : fallback.provider,
498
- modelId: typeof input.modelId === "string" && input.modelId ? input.modelId : fallback.modelId,
688
+ provider: typeof input.provider === "string" && input.provider.trim() ? input.provider.trim() : fallback.provider,
689
+ modelId: typeof input.modelId === "string" && input.modelId.trim() ? input.modelId.trim() : fallback.modelId,
499
690
  thinkingLevel: parseThinkingLevel(input.thinkingLevel) ?? fallback.thinkingLevel,
500
- maxUses: typeof input.maxUses === "number" && input.maxUses > 0 ? Math.floor(input.maxUses) : fallback.maxUses,
691
+ maxUses: typeof input.maxUses === "number" && Number.isFinite(input.maxUses) && Math.floor(input.maxUses) > 0
692
+ ? Math.floor(input.maxUses)
693
+ : fallback.maxUses,
501
694
  cacheRetention: parseCacheRetention(input.cacheRetention) ?? fallback.cacheRetention,
502
- maxTokens: typeof input.maxTokens === "number" && input.maxTokens > 0 ? Math.floor(input.maxTokens) : fallback.maxTokens,
503
- timeoutMs: typeof input.timeoutMs === "number" && input.timeoutMs > 0 ? Math.floor(input.timeoutMs) : fallback.timeoutMs,
695
+ maxTokens: typeof input.maxTokens === "number" && Number.isFinite(input.maxTokens) && Math.floor(input.maxTokens) > 0
696
+ ? Math.floor(input.maxTokens)
697
+ : fallback.maxTokens,
698
+ timeoutMs: typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) && Math.floor(input.timeoutMs) > 0
699
+ ? Math.floor(input.timeoutMs)
700
+ : fallback.timeoutMs,
701
+ maxTranscriptChars: typeof input.maxTranscriptChars === "number" && Number.isFinite(input.maxTranscriptChars) && Math.floor(input.maxTranscriptChars) > 0
702
+ ? Math.floor(input.maxTranscriptChars)
703
+ : fallback.maxTranscriptChars,
504
704
  };
505
705
  const legacyDefault = parseModelSpec(LEGACY_DEFAULT_ADVISOR_MODEL);
506
- if (input.thinkingLevel === undefined &&
706
+ if (options.migrateLegacyDefault !== false &&
707
+ input.thinkingLevel === undefined &&
507
708
  legacyDefault &&
508
709
  normalized.provider === legacyDefault.provider &&
509
710
  normalized.modelId === legacyDefault.modelId) {
510
- normalized.provider = fallback.provider;
511
- normalized.modelId = fallback.modelId;
711
+ // Only rewrite the stored legacy default when the migration target resolves.
712
+ // Otherwise a resolving stored model would be replaced by a non-resolving default.
713
+ if (!registry || modelResolves(registry, fallback.provider, fallback.modelId)) {
714
+ normalized.provider = fallback.provider;
715
+ normalized.modelId = fallback.modelId;
716
+ }
717
+ }
718
+ if (registry && typeof input.provider === "string" && input.provider && typeof input.modelId === "string" && input.modelId) {
719
+ // Never replace a resolving stored model with a non-resolving one.
720
+ if (modelResolves(registry, input.provider, input.modelId) && !modelResolves(registry, normalized.provider, normalized.modelId)) {
721
+ normalized.provider = input.provider;
722
+ normalized.modelId = input.modelId;
723
+ }
512
724
  }
513
725
  return normalized;
514
726
  }
515
- function parseModelSpec(value) {
516
- const slash = value.indexOf("/");
517
- if (slash <= 0 || slash === value.length - 1)
727
+ export function modelResolves(registry, provider, modelId) {
728
+ if (!registry)
729
+ return false;
730
+ try {
731
+ const found = registry.find(provider, modelId);
732
+ return found !== undefined && found !== null;
733
+ }
734
+ catch {
735
+ return false;
736
+ }
737
+ }
738
+ export function resolveAdvisorModel(registry, candidates) {
739
+ if (!registry)
518
740
  return undefined;
519
- return {
520
- provider: value.slice(0, slash),
521
- modelId: value.slice(slash + 1),
522
- };
741
+ for (const candidate of candidates) {
742
+ if (modelResolves(registry, candidate.provider, candidate.modelId))
743
+ return candidate;
744
+ }
745
+ try {
746
+ const available = registry.getAvailable?.();
747
+ const first = Array.isArray(available) ? available[0] : undefined;
748
+ if (first && typeof first.provider === "string" && typeof first.id === "string" && first.provider && first.id) {
749
+ return { provider: first.provider, modelId: first.id };
750
+ }
751
+ }
752
+ catch {
753
+ // Ignore registry listing failures; callers keep the current config.
754
+ }
755
+ return undefined;
756
+ }
757
+ export function parseModelSpec(value) {
758
+ const trimmed = value.trim();
759
+ const slash = trimmed.indexOf("/");
760
+ if (slash <= 0 || slash === trimmed.length - 1)
761
+ return undefined;
762
+ const provider = trimmed.slice(0, slash).trim();
763
+ const modelId = trimmed.slice(slash + 1).trim();
764
+ if (!provider || !modelId)
765
+ return undefined;
766
+ return { provider, modelId };
767
+ }
768
+ function flagValue(api, name) {
769
+ return typeof api.getFlag === "function" ? api.getFlag(name) : undefined;
523
770
  }
524
771
  function parsePositiveInt(value) {
525
- const parsed = Number.parseInt(value, 10);
526
- if (!Number.isFinite(parsed) || parsed <= 0)
772
+ const trimmed = value.trim();
773
+ if (!/^\d+$/.test(trimmed))
774
+ return undefined;
775
+ const parsed = Number(trimmed);
776
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
527
777
  return undefined;
528
778
  return parsed;
529
779
  }