@steipete/oracle 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +22 -11
  2. package/dist/bin/oracle-cli.js +71 -71
  3. package/dist/src/browser/actions/assistantResponse.js +93 -10
  4. package/dist/src/browser/actions/deepResearch.js +180 -52
  5. package/dist/src/browser/actions/modelSelection.js +357 -40
  6. package/dist/src/browser/actions/navigation.js +294 -65
  7. package/dist/src/browser/actions/promptComposer.js +50 -4
  8. package/dist/src/browser/actions/thinkingTime.js +517 -105
  9. package/dist/src/browser/artifacts.js +19 -0
  10. package/dist/src/browser/chatgptFiles.js +797 -0
  11. package/dist/src/browser/chatgptImages.js +256 -27
  12. package/dist/src/browser/chromeLifecycle.js +4 -0
  13. package/dist/src/browser/config.js +2 -0
  14. package/dist/src/browser/index.js +404 -41
  15. package/dist/src/browser/pageActions.js +1 -1
  16. package/dist/src/browser/policies.js +2 -6
  17. package/dist/src/browser/projectSourcesRunner.js +16 -3
  18. package/dist/src/browser/prompt.js +161 -36
  19. package/dist/src/browser/reattach.js +6 -2
  20. package/dist/src/browser/reattachability.js +22 -10
  21. package/dist/src/browser/recoverConversation.js +73 -0
  22. package/dist/src/browser/sessionRunner.js +4 -1
  23. package/dist/src/cli/browserConfig.js +6 -2
  24. package/dist/src/cli/browserDefaults.js +2 -1
  25. package/dist/src/cli/browserTabs.js +129 -54
  26. package/dist/src/cli/followup.js +72 -0
  27. package/dist/src/cli/help.js +1 -1
  28. package/dist/src/cli/options.js +24 -0
  29. package/dist/src/cli/reattachGuidance.js +8 -0
  30. package/dist/src/cli/runOptions.js +2 -12
  31. package/dist/src/cli/sessionCommand.js +4 -0
  32. package/dist/src/cli/sessionDisplay.js +17 -3
  33. package/dist/src/cli/sessionLineage.js +7 -4
  34. package/dist/src/cli/sessionRunner.js +16 -1
  35. package/dist/src/gemini-web/client.js +5 -10
  36. package/dist/src/gemini-web/executor.js +1 -24
  37. package/dist/src/gemini-web/models.js +83 -0
  38. package/dist/src/mcp/server.js +2 -0
  39. package/dist/src/mcp/tools/chatgptImage.js +132 -0
  40. package/dist/src/mcp/tools/consult.js +270 -175
  41. package/dist/src/mcp/types.js +13 -2
  42. package/dist/src/mcp/utils.js +87 -1
  43. package/dist/src/oracle/config.js +27 -1
  44. package/dist/src/oracle/files.js +4 -6
  45. package/dist/src/oracle/geminiModels.js +2 -0
  46. package/dist/src/oracle/markdown.js +36 -3
  47. package/dist/src/oracle/modelResolver.js +21 -13
  48. package/dist/src/oracle/promptAssembly.js +2 -4
  49. package/dist/src/oracle/request.js +3 -5
  50. package/dist/src/oracle/thinkingTime.js +40 -0
  51. package/dist/src/oracle/tokenStats.js +5 -1
  52. package/dist/src/oracle.js +1 -1
  53. package/dist/src/sessionManager.js +1 -0
  54. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  55. package/package.json +47 -58
  56. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  57. package/dist/bin/oracle.js +0 -569
  58. package/dist/src/browser/chromeCookies.js +0 -312
  59. package/dist/src/browser/keytarShim.js +0 -56
  60. package/dist/src/browser/windowsCookies.js +0 -219
  61. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  62. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  63. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  64. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  65. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  68. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  71. package/vendor/oracle-notifier/README.md +0 -26
@@ -12,13 +12,12 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
12
12
  const result = outcome.result?.value;
13
13
  switch (result?.status) {
14
14
  case "already-selected":
15
- case "switched":
16
- case "switched-best-effort": {
17
- const label = result.label ?? desiredModel;
15
+ case "switched": {
16
+ const label = result.label?.trim() || (strategy === "current" ? null : desiredModel);
18
17
  if (strategy !== "current") {
19
- assertResolvedModelSelection(desiredModel, label);
18
+ assertResolvedModelSelection(desiredModel, label ?? desiredModel);
20
19
  }
21
- logger(`Model picker: ${label}`);
20
+ logger(`Model picker: ${label ?? "current model (label unavailable)"}`);
22
21
  return {
23
22
  requestedModel: desiredModel,
24
23
  resolvedLabel: label,
@@ -41,7 +40,7 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
41
40
  }
42
41
  default: {
43
42
  await logDomFailure(Runtime, logger, "model-switcher-button");
44
- throw new Error("Unable to locate the ChatGPT model selector button.");
43
+ throw new Error("Unable to locate the ChatGPT model selector button. If the desired model is already selected in the browser, retry with --browser-model-strategy current; otherwise retry with --browser-model-strategy ignore to skip model selection.");
45
44
  }
46
45
  }
47
46
  }
@@ -95,8 +94,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
95
94
  const composerIncludesLiteral = JSON.stringify(composerSignalMatchers.includesAny);
96
95
  const composerExcludesLiteral = JSON.stringify(composerSignalMatchers.excludesAny);
97
96
  const composerAllowBlankLiteral = JSON.stringify(composerSignalMatchers.allowBlank);
98
- const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
99
- const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
97
+ const menuContainerLiteral = JSON.stringify(`${MENU_CONTAINER_SELECTOR}, [role="listbox"], [role="dialog"]`);
98
+ const menuItemLiteral = JSON.stringify(`${MENU_ITEM_SELECTOR}, [role="option"], [role="radio"], [role="combobox"]`);
100
99
  return `(() => {
101
100
  ${buildClickDispatcher()}
102
101
  // Capture the selectors and matcher literals up front so the browser expression stays pure.
@@ -134,6 +133,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
134
133
  ? '5-4'
135
134
  : normalizedTarget.includes('5 5')
136
135
  ? '5-5'
136
+ : normalizedTarget.includes('5 3')
137
+ ? '5-3'
137
138
  : normalizedTarget.includes('5 2')
138
139
  ? '5-2'
139
140
  : normalizedTarget.includes('5 1')
@@ -196,7 +197,19 @@ function buildModelSelectionExpression(targetModel, strategy) {
196
197
  );
197
198
  if (!label) return false;
198
199
  if (label.includes('click to remove')) return false;
199
- const modelTokens = ['chatgpt', 'gpt', 'instant', 'thinking', 'pro', 'extended', 'standard', 'heavy', 'light'];
200
+ const modelTokens = [
201
+ 'chatgpt',
202
+ 'gpt',
203
+ 'instant',
204
+ 'thinking',
205
+ 'pro',
206
+ 'extended',
207
+ 'standard',
208
+ 'medium',
209
+ 'high',
210
+ 'heavy',
211
+ 'light',
212
+ ];
200
213
  return modelTokens.some((token) => hasToken(label, token));
201
214
  };
202
215
  const findModelButton = () => {
@@ -205,12 +218,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
205
218
  return Array.from(document.querySelectorAll('button.__composer-pill')).find(looksLikeModelPill) ?? null;
206
219
  };
207
220
 
208
- const button = findModelButton();
209
- if (!button) {
210
- return { status: 'button-missing' };
211
- }
212
-
213
221
  const closeMenu = () => {
222
+ const dialogCloseButton = document.querySelector(
223
+ '[role="dialog"] [data-testid="close-button"]',
224
+ );
225
+ if (dialogCloseButton) {
226
+ try {
227
+ if (dispatchClickSequence(dialogCloseButton)) return;
228
+ } catch {}
229
+ }
230
+ const button = findModelButton();
231
+ if (!button) return;
214
232
  try {
215
233
  if (dispatchClickSequence(button)) {
216
234
  lastPointerClick = performance.now();
@@ -230,10 +248,29 @@ function buildModelSelectionExpression(targetModel, strategy) {
230
248
  } catch {}
231
249
  };
232
250
 
233
- const getButtonLabel = () => (button.textContent ?? '').trim();
251
+ const getButtonLabel = () => (findModelButton()?.textContent ?? '').trim();
234
252
  const getComposerModelLabel = () =>
235
253
  (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim();
236
254
  const readComposerModelSignal = () => normalizeText(getComposerModelLabel());
255
+ const isIntelligenceEffortLabel = (label) =>
256
+ label === 'instant' ||
257
+ label === 'medium' ||
258
+ label === 'high' ||
259
+ label === 'extra high' ||
260
+ label === 'extended' ||
261
+ label === 'standard' ||
262
+ label === 'heavy' ||
263
+ label === 'light';
264
+ const formatModelOptionLabel = (label) => {
265
+ const normalized = normalizeText(label ?? '');
266
+ if (normalized === '5 5' || normalized === 'gpt 5 5') return 'GPT-5.5';
267
+ if (normalized === '5 4' || normalized === 'gpt 5 4') return 'GPT-5.4';
268
+ if (normalized === '5 3' || normalized === 'gpt 5 3') return 'GPT-5.3';
269
+ if (normalized === '5 2' || normalized === 'gpt 5 2') return 'GPT-5.2';
270
+ if (normalized === '5 1' || normalized === 'gpt 5 1') return 'GPT-5.1';
271
+ if (normalized === '5 0' || normalized === 'gpt 5 0') return 'GPT-5.0';
272
+ return label || '';
273
+ };
237
274
  const withProPillSignal = (label) => {
238
275
  const resolved = label || '';
239
276
  if (!wantsPro || !hasProComposerPill()) return resolved;
@@ -243,27 +280,109 @@ function buildModelSelectionExpression(targetModel, strategy) {
243
280
  if (normalized.includes('pro')) return resolved;
244
281
  return resolved + ' + Pro';
245
282
  };
246
- const getResolvedLabel = (fallback) =>
247
- withProPillSignal(getComposerModelLabel() || getButtonLabel() || fallback);
248
283
  const isThinkingEffortLabel = (label) =>
249
- label === 'extended' || label === 'standard' || label === 'heavy' || label === 'light';
284
+ label === 'extended' ||
285
+ label === 'standard' ||
286
+ label === 'heavy' ||
287
+ label === 'light' ||
288
+ label === 'medium' ||
289
+ label === 'high' ||
290
+ label === 'extra high';
291
+ const isNonProIntelligenceThinkingLabel = (label) =>
292
+ label === 'medium' || label === 'high' || label === 'extra high';
293
+ const scoreNonProGpt55ThinkingLabel = (label) => {
294
+ if (label === 'extra high') return 1400;
295
+ if (label === 'high') return 1200;
296
+ return 1000;
297
+ };
298
+ const versionFromLabel = (label) => {
299
+ if (label === '5 5' || label === 'gpt 5 5') return '5-5';
300
+ if (label === '5 4' || label === 'gpt 5 4') return '5-4';
301
+ if (label === '5 3' || label === 'gpt 5 3') return '5-3';
302
+ if (label === '5 2' || label === 'gpt 5 2') return '5-2';
303
+ if (label === '5 1' || label === 'gpt 5 1') return '5-1';
304
+ if (label === '5 0' || label === 'gpt 5 0') return '5-0';
305
+ if (label === '4 5' || label === 'gpt 4 5') return '4-5';
306
+ return null;
307
+ };
308
+ const versionFromTestId = (testid) => {
309
+ const normalized = normalizeText(testid);
310
+ if (normalized.includes('5 5') || normalized.includes('gpt55')) return '5-5';
311
+ if (normalized.includes('5 4') || normalized.includes('gpt54')) return '5-4';
312
+ if (normalized.includes('5 3') || normalized.includes('gpt53')) return '5-3';
313
+ if (normalized.includes('5 2') || normalized.includes('gpt52')) return '5-2';
314
+ if (normalized.includes('5 1') || normalized.includes('gpt51')) return '5-1';
315
+ if (normalized.includes('5 0') || normalized.includes('gpt50')) return '5-0';
316
+ return null;
317
+ };
318
+ const getConfigurationDialog = () => document.querySelector('[role="dialog"]');
319
+ const getConfiguredVersionLabel = () =>
320
+ (getConfigurationDialog()
321
+ ?.querySelector?.('[role="combobox"][aria-labelledby="model-selection-label"]')
322
+ ?.textContent ?? '').trim();
323
+ const getConfiguredVariantLabel = () => {
324
+ const label =
325
+ (getConfigurationDialog()
326
+ ?.querySelector?.('[aria-label="Model options"] [role="radio"][aria-checked="true"]')
327
+ ?.textContent ?? '').trim();
328
+ const normalized = normalizeText(label);
329
+ if (normalized.startsWith('instant')) return 'Instant';
330
+ if (normalized.startsWith('thinking')) return 'Thinking';
331
+ if (normalized.startsWith('pro')) return 'Pro';
332
+ return label;
333
+ };
334
+ const configuredSelectionMatchesTarget = () => {
335
+ const configuredVersion = versionFromLabel(normalizeText(getConfiguredVersionLabel()));
336
+ if (!configuredVersion || configuredVersion !== desiredVersion) return false;
337
+ const configuredVariant = normalizeText(getConfiguredVariantLabel());
338
+ if (wantsPro) return labelHasProWord(configuredVariant);
339
+ if (wantsInstant) return configuredVariant.includes('instant');
340
+ if (wantsThinking) {
341
+ return configuredVariant.includes('thinking') && !labelHasProWord(configuredVariant);
342
+ }
343
+ return true;
344
+ };
345
+ const getResolvedLabel = (fallback) => {
346
+ if (configuredSelectionMatchesTarget()) {
347
+ const variant = getConfiguredVariantLabel();
348
+ const version = formatModelOptionLabel(getConfiguredVersionLabel());
349
+ return [variant, version].filter(Boolean).join(' ');
350
+ }
351
+ const composerLabel = getComposerModelLabel();
352
+ if (composerLabel) return withProPillSignal(composerLabel);
353
+ const buttonLabel = getButtonLabel();
354
+ const normalizedButton = normalizeText(buttonLabel);
355
+ const fallbackLabel = formatModelOptionLabel(fallback);
356
+ if (fallbackLabel && !wantsPro && isIntelligenceEffortLabel(normalizedButton)) {
357
+ return fallbackLabel;
358
+ }
359
+ return withProPillSignal(buttonLabel || fallbackLabel || fallback);
360
+ };
250
361
  if (MODEL_STRATEGY === 'current') {
251
- const currentLabel = getResolvedLabel(PRIMARY_LABEL);
362
+ const currentLabel = getResolvedLabel('') || null;
252
363
  return {
253
364
  status: 'already-selected',
254
365
  label: currentLabel,
255
366
  };
256
367
  }
368
+
369
+ const button = findModelButton();
370
+ if (!button) {
371
+ return { status: 'button-missing' };
372
+ }
257
373
  const buttonMatchesTarget = () => {
374
+ if (configuredSelectionMatchesTarget()) return true;
258
375
  const normalizedLabel = normalizeText(getButtonLabel());
259
376
  if (!normalizedLabel) return false;
377
+ if (wantsThinking && !wantsPro && hasProComposerPill()) return false;
260
378
  if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
261
379
  if (
262
380
  wantsThinking &&
263
381
  desiredVersion === '5-5' &&
264
382
  !hasProComposerPill() &&
265
383
  isThinkingEffortLabel(normalizedLabel) &&
266
- isTargetGpt55VisibleAlias(readComposerModelSignal())
384
+ (isNonProIntelligenceThinkingLabel(normalizedLabel) ||
385
+ isTargetGpt55VisibleAlias(readComposerModelSignal()))
267
386
  ) {
268
387
  return true;
269
388
  }
@@ -281,6 +400,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
281
400
  if (desiredVersion) {
282
401
  if (desiredVersion === '5-5' && !normalizedLabel.includes('5 5')) return false;
283
402
  if (desiredVersion === '5-4' && !normalizedLabel.includes('5 4')) return false;
403
+ if (desiredVersion === '5-3' && !normalizedLabel.includes('5 3')) return false;
284
404
  if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
285
405
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
286
406
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
@@ -290,9 +410,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
290
410
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
291
411
  if (
292
412
  wantsThinking &&
293
- desiredVersion === '5-4' &&
294
- !normalizedLabel.includes('pro') &&
295
- !normalizedLabel.includes('instant')
413
+ desiredVersion &&
414
+ versionFromLabel(normalizedLabel) === desiredVersion &&
415
+ !labelHasProWord(normalizedLabel)
296
416
  ) {
297
417
  return true;
298
418
  }
@@ -357,10 +477,21 @@ function buildModelSelectionExpression(targetModel, strategy) {
357
477
  };
358
478
 
359
479
  const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
360
- const isThinkingEffortControl = (node) =>
480
+ const isDetachedProEffortMenu = (menu) => {
481
+ const text = normalizeText(menu?.textContent ?? '');
482
+ return (
483
+ menu?.getAttribute?.('data-testid') !== 'composer-intelligence-picker-content' &&
484
+ text.includes('pro standard') &&
485
+ text.includes('pro extended')
486
+ );
487
+ };
488
+ const isNestedEffortControl = (node, menu) =>
361
489
  node instanceof HTMLElement &&
362
490
  (node.getAttribute('data-model-picker-thinking-effort-action') === 'true' ||
363
- Boolean(node.closest('[data-model-picker-thinking-effort-action="true"]')));
491
+ node.getAttribute('data-composer-intelligence-pro-effort-action') === 'true' ||
492
+ Boolean(node.closest('[data-model-picker-thinking-effort-action="true"]')) ||
493
+ Boolean(node.closest('[data-composer-intelligence-pro-effort-action="true"]')) ||
494
+ isDetachedProEffortMenu(menu));
364
495
  const optionIsSelected = (node) => {
365
496
  if (!(node instanceof HTMLElement)) {
366
497
  return false;
@@ -380,13 +511,28 @@ function buildModelSelectionExpression(targetModel, strategy) {
380
511
  return false;
381
512
  };
382
513
 
383
- const scoreOption = (normalizedText, testid) => {
514
+ const scoreOption = (normalizedText, testid, node) => {
384
515
  // Assign a score to every node so we can pick the most likely match without brittle equality checks.
385
516
  if (!normalizedText && !testid) {
386
517
  return 0;
387
518
  }
388
519
  let score = 0;
389
520
  const normalizedTestId = (testid ?? '').toLowerCase();
521
+ const candidateTextVersion = versionFromLabel(normalizedText);
522
+ const candidateIsVersionCombobox =
523
+ node?.getAttribute?.('role') === 'combobox' &&
524
+ node?.getAttribute?.('aria-labelledby') === 'model-selection-label';
525
+ if (candidateIsVersionCombobox && candidateTextVersion === desiredVersion) {
526
+ return 0;
527
+ }
528
+ const candidateOpensConfiguration =
529
+ Boolean(desiredVersion) &&
530
+ desiredVersion !== '5-5' &&
531
+ (normalizedTestId === 'model-configure-modal' ||
532
+ (candidateIsVersionCombobox && node?.getAttribute?.('aria-expanded') !== 'true'));
533
+ if (candidateOpensConfiguration) {
534
+ return 2000;
535
+ }
390
536
  let exactTestIdMatch = false;
391
537
  if (normalizedTestId) {
392
538
  if (desiredVersion) {
@@ -409,6 +555,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
409
555
  normalizedTestId.includes('gpt-5-4') ||
410
556
  normalizedTestId.includes('gpt-5.4') ||
411
557
  normalizedTestId.includes('gpt54');
558
+ const has53 =
559
+ normalizedTestId.includes('5-3') ||
560
+ normalizedTestId.includes('5.3') ||
561
+ normalizedTestId.includes('gpt-5-3') ||
562
+ normalizedTestId.includes('gpt-5.3') ||
563
+ normalizedTestId.includes('gpt53');
412
564
  const has51 =
413
565
  normalizedTestId.includes('5-1') ||
414
566
  normalizedTestId.includes('5.1') ||
@@ -421,7 +573,19 @@ function buildModelSelectionExpression(targetModel, strategy) {
421
573
  normalizedTestId.includes('gpt-5-0') ||
422
574
  normalizedTestId.includes('gpt-5.0') ||
423
575
  normalizedTestId.includes('gpt50');
424
- const candidateVersion = has55 ? '5-5' : has54 ? '5-4' : has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null;
576
+ const candidateVersion = has55
577
+ ? '5-5'
578
+ : has54
579
+ ? '5-4'
580
+ : has53
581
+ ? '5-3'
582
+ : has52
583
+ ? '5-2'
584
+ : has51
585
+ ? '5-1'
586
+ : has50
587
+ ? '5-0'
588
+ : null;
425
589
  // If a candidate advertises a different version, ignore it entirely.
426
590
  if (candidateVersion && candidateVersion !== desiredVersion) {
427
591
  return 0;
@@ -450,10 +614,44 @@ function buildModelSelectionExpression(targetModel, strategy) {
450
614
  }
451
615
  }
452
616
  const candidateGpt55VisibleAlias = isTargetGpt55VisibleAlias(normalizedText);
617
+ const candidateIsNonProThinkingEffort =
618
+ isNonProIntelligenceThinkingLabel(normalizedText) && !normalizedTestId.includes('pro');
619
+ const hasActiveProPill = hasProComposerPill();
620
+ const candidateIsNonProGpt55Thinking =
621
+ wantsThinking && desiredVersion === '5-5' && candidateIsNonProThinkingEffort;
622
+ const candidateClearsProForThinking =
623
+ wantsThinking && !wantsPro && hasActiveProPill && candidateIsNonProThinkingEffort;
624
+ const candidateOpensVersionSubmenu =
625
+ wantsThinking &&
626
+ desiredVersion !== '5-5' &&
627
+ normalizedText === 'gpt 5 5' &&
628
+ !normalizedTestId.includes('pro');
629
+ const candidateSelectsDesiredVersion =
630
+ Boolean(desiredVersion) && candidateTextVersion === desiredVersion;
631
+ if (
632
+ desiredVersion &&
633
+ candidateTextVersion &&
634
+ candidateTextVersion !== desiredVersion &&
635
+ !candidateOpensVersionSubmenu
636
+ ) {
637
+ return 0;
638
+ }
639
+ const candidateIsGpt55ThinkingFamily =
640
+ wantsThinking &&
641
+ desiredVersion === '5-5' &&
642
+ (normalizedText === 'gpt 5 5' ||
643
+ normalizedText === '5 5' ||
644
+ normalizedTestId.includes('gpt-5-5') ||
645
+ normalizedTestId.includes('gpt-5.5') ||
646
+ normalizedTestId.includes('gpt55'));
453
647
  const candidateHasThinking =
454
648
  normalizedText.includes('thinking') ||
455
649
  normalizedTestId.includes('thinking') ||
456
- (wantsThinking && desiredVersion === '5-4' && exactTestIdMatch);
650
+ candidateIsNonProGpt55Thinking ||
651
+ candidateClearsProForThinking ||
652
+ candidateOpensVersionSubmenu ||
653
+ candidateIsGpt55ThinkingFamily ||
654
+ (wantsThinking && desiredVersion && (exactTestIdMatch || candidateSelectsDesiredVersion));
457
655
  const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
458
656
  const candidateHasPro =
459
657
  labelHasProWord(normalizedText) ||
@@ -462,11 +660,11 @@ function buildModelSelectionExpression(targetModel, strategy) {
462
660
  const candidateHasInstant =
463
661
  normalizedText.includes('instant') || normalizedTestId.includes('instant');
464
662
  if (wantsPro && candidateHasThinking) return 0;
465
- if (wantsPro && candidateHasLegacyProVersion) return 0;
466
- if (wantsPro && !candidateHasPro) return 0;
467
- if (wantsInstant && !candidateHasInstant) return 0;
663
+ if (wantsPro && candidateHasLegacyProVersion && !candidateSelectsDesiredVersion) return 0;
664
+ if (wantsPro && !candidateHasPro && !candidateSelectsDesiredVersion) return 0;
665
+ if (wantsInstant && !candidateHasInstant && !candidateSelectsDesiredVersion) return 0;
468
666
  if (wantsThinking && candidateHasPro) return 0;
469
- if (wantsThinking && !candidateHasThinking) return 0;
667
+ if (wantsThinking && !candidateHasThinking && !candidateSelectsDesiredVersion) return 0;
470
668
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
471
669
  const candidateHasVersion =
472
670
  normalizedText.includes('5 5') ||
@@ -480,6 +678,39 @@ function buildModelSelectionExpression(targetModel, strategy) {
480
678
  if (candidateGpt55VisibleAlias) {
481
679
  score += 900;
482
680
  }
681
+ if (wantsPro && candidateHasPro && !candidateSelectsDesiredVersion) {
682
+ score += 900;
683
+ }
684
+ if (wantsInstant && candidateHasInstant && !candidateSelectsDesiredVersion) {
685
+ score += 900;
686
+ }
687
+ if (
688
+ wantsThinking &&
689
+ candidateHasThinking &&
690
+ !candidateSelectsDesiredVersion &&
691
+ !candidateOpensVersionSubmenu
692
+ ) {
693
+ score += 900;
694
+ }
695
+ if (candidateIsNonProGpt55Thinking) {
696
+ score += scoreNonProGpt55ThinkingLabel(normalizedText);
697
+ }
698
+ if (candidateClearsProForThinking) {
699
+ score += scoreNonProGpt55ThinkingLabel(normalizedText) + 600;
700
+ }
701
+ if (
702
+ desiredVersion &&
703
+ candidateTextVersion === desiredVersion &&
704
+ !(wantsThinking && desiredVersion === '5-5' && normalizedText === 'gpt 5 5')
705
+ ) {
706
+ score += 1200;
707
+ }
708
+ if (candidateOpensVersionSubmenu) {
709
+ score += 500;
710
+ }
711
+ if (candidateIsGpt55ThinkingFamily) {
712
+ score += 260;
713
+ }
483
714
  if (normalizedText && normalizedTarget) {
484
715
  if (normalizedText === normalizedTarget) {
485
716
  score += 500;
@@ -515,7 +746,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
515
746
  }
516
747
  // Similarly for Thinking variant
517
748
  if (wantsThinking) {
518
- if (!normalizedText.includes('thinking') && !normalizedTestId.includes('thinking')) {
749
+ if (
750
+ !candidateIsGpt55ThinkingFamily &&
751
+ !candidateIsNonProGpt55Thinking &&
752
+ !candidateClearsProForThinking &&
753
+ !candidateOpensVersionSubmenu &&
754
+ !normalizedText.includes('thinking') &&
755
+ !normalizedTestId.includes('thinking')
756
+ ) {
519
757
  score -= 80;
520
758
  }
521
759
  } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
@@ -556,6 +794,21 @@ function buildModelSelectionExpression(targetModel, strategy) {
556
794
  );
557
795
  return pickerMenus.concat(textFallbackMenus);
558
796
  };
797
+ const isSubmenuOption = (node, testid) =>
798
+ (testid ?? '').toLowerCase().includes('submenu') ||
799
+ (testid ?? '').toLowerCase() === 'model-configure-modal' ||
800
+ (node?.getAttribute?.('role') === 'combobox' &&
801
+ node?.getAttribute?.('aria-labelledby') === 'model-selection-label') ||
802
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
803
+ node?.getAttribute?.('data-has-submenu') !== null;
804
+ const canTrustSelectedOption = (node, normalizedText, testid) => {
805
+ if (!optionIsSelected(node)) return false;
806
+ if (getConfigurationDialog() && !configuredSelectionMatchesTarget()) return false;
807
+ const optionVersion = versionFromLabel(normalizedText) ?? versionFromTestId(testid);
808
+ if (desiredVersion && optionVersion !== desiredVersion) return false;
809
+ const currentButtonLabel = normalizeText(getButtonLabel());
810
+ return !labelHasProWord(currentButtonLabel) && !hasProComposerPill();
811
+ };
559
812
 
560
813
  const findBestOption = () => {
561
814
  // Walk through every menu item and keep whichever earns the highest score.
@@ -564,16 +817,19 @@ function buildModelSelectionExpression(targetModel, strategy) {
564
817
  for (const menu of menus) {
565
818
  const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
566
819
  for (const option of buttons) {
567
- if (isThinkingEffortControl(option)) {
820
+ if (isNestedEffortControl(option, menu)) {
568
821
  continue;
569
822
  }
570
823
  const text = option.textContent ?? '';
571
824
  const normalizedText = normalizeText(text);
572
825
  const testid = option.getAttribute('data-testid') ?? '';
573
- const score = scoreOption(normalizedText, testid);
826
+ let score = scoreOption(normalizedText, testid, option);
574
827
  if (score <= 0) {
575
828
  continue;
576
829
  }
830
+ if (canTrustSelectedOption(option, normalizedText, testid)) {
831
+ score += 1000;
832
+ }
577
833
  const label = getOptionLabel(option);
578
834
  if (!bestMatch || score > bestMatch.score) {
579
835
  bestMatch = { node: option, label, score, testid, normalizedText };
@@ -611,6 +867,28 @@ function buildModelSelectionExpression(targetModel, strategy) {
611
867
  };
612
868
  check();
613
869
  });
870
+ const dispatchHoverSequence = (target) => {
871
+ if (!target || !(target instanceof EventTarget)) return false;
872
+ const types = ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove'];
873
+ for (const type of types) {
874
+ try {
875
+ const common = { bubbles: true, cancelable: true, view: window };
876
+ const event =
877
+ type.startsWith('pointer') && 'PointerEvent' in window
878
+ ? new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' })
879
+ : new MouseEvent(type, common);
880
+ target.dispatchEvent(event);
881
+ } catch {}
882
+ }
883
+ try {
884
+ target.focus?.();
885
+ } catch {}
886
+ return true;
887
+ };
888
+ const openSubmenuOption = (node) => {
889
+ dispatchHoverSequence(node);
890
+ dispatchClickSequence(node);
891
+ };
614
892
 
615
893
  return new Promise((resolve) => {
616
894
  const start = performance.now();
@@ -653,26 +931,32 @@ function buildModelSelectionExpression(targetModel, strategy) {
653
931
  ensureMenuOpen();
654
932
  const match = findBestOption();
655
933
  if (match) {
656
- if (optionIsSelected(match.node) || activeSelectionMatchesTarget()) {
934
+ if (
935
+ activeSelectionMatchesTarget() ||
936
+ canTrustSelectedOption(match.node, match.normalizedText, match.testid)
937
+ ) {
938
+ const resolvedLabel = getResolvedLabel(match.label);
657
939
  closeMenu();
658
- resolve({ status: 'already-selected', label: getResolvedLabel(match.label) });
940
+ resolve({ status: 'already-selected', label: resolvedLabel });
659
941
  return;
660
942
  }
661
943
  const previousButtonLabel = normalizeText(getButtonLabel());
662
944
  const previousComposerSignal = readComposerModelSignal();
663
- dispatchClickSequence(match.node);
664
945
  // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
665
946
  // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
666
- const isSubmenu = (match.testid ?? '').toLowerCase().includes('submenu');
947
+ const isSubmenu = isSubmenuOption(match.node, match.testid);
667
948
  if (isSubmenu) {
949
+ openSubmenuOption(match.node);
668
950
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
669
951
  return;
670
952
  }
953
+ dispatchClickSequence(match.node);
671
954
  // Wait for the selected model signal to settle before reopening the picker.
672
955
  waitForTargetSelection(previousButtonLabel, previousComposerSignal).then((selectionSettled) => {
673
956
  if (selectionSettled === 'target') {
957
+ const resolvedLabel = getResolvedLabel(match.label);
674
958
  closeMenu();
675
- resolve({ status: 'switched', label: getResolvedLabel(match.label) });
959
+ resolve({ status: 'switched', label: resolvedLabel });
676
960
  return;
677
961
  }
678
962
  attempt();
@@ -781,6 +1065,34 @@ function buildModelMatchersLiteral(targetModel) {
781
1065
  testIdTokens.add("gpt5-4");
782
1066
  testIdTokens.add("gpt54");
783
1067
  }
1068
+ // Numeric variations (5.3 ↔ 53 ↔ gpt-5-3)
1069
+ if (base.includes("5.3") || base.includes("5-3") || base.includes("53")) {
1070
+ push("5.3", labelTokens);
1071
+ push("gpt-5.3", labelTokens);
1072
+ push("gpt5.3", labelTokens);
1073
+ push("gpt-5-3", labelTokens);
1074
+ push("gpt5-3", labelTokens);
1075
+ push("gpt53", labelTokens);
1076
+ push("chatgpt 5.3", labelTokens);
1077
+ if (base.includes("thinking")) {
1078
+ push("thinking", labelTokens);
1079
+ testIdTokens.add("model-switcher-gpt-5-3-thinking");
1080
+ testIdTokens.add("gpt-5-3-thinking");
1081
+ testIdTokens.add("gpt-5.3-thinking");
1082
+ }
1083
+ if (base.includes("instant")) {
1084
+ push("instant", labelTokens);
1085
+ testIdTokens.add("model-switcher-gpt-5-3-instant");
1086
+ testIdTokens.add("gpt-5-3-instant");
1087
+ testIdTokens.add("gpt-5.3-instant");
1088
+ }
1089
+ if (!base.includes("pro") && !base.includes("thinking") && !base.includes("instant")) {
1090
+ testIdTokens.add("model-switcher-gpt-5-3");
1091
+ }
1092
+ testIdTokens.add("gpt-5-3");
1093
+ testIdTokens.add("gpt5-3");
1094
+ testIdTokens.add("gpt53");
1095
+ }
784
1096
  // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
785
1097
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
786
1098
  push("5.1", labelTokens);
@@ -855,6 +1167,11 @@ function buildModelMatchersLiteral(targetModel) {
855
1167
  testIdTokens.add("gpt-5-4-pro");
856
1168
  testIdTokens.add("gpt54pro");
857
1169
  }
1170
+ if (base.includes("5.3") || base.includes("5-3") || base.includes("53")) {
1171
+ testIdTokens.add("gpt-5.3-pro");
1172
+ testIdTokens.add("gpt-5-3-pro");
1173
+ testIdTokens.add("gpt53pro");
1174
+ }
858
1175
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
859
1176
  testIdTokens.add("gpt-5.1-pro");
860
1177
  testIdTokens.add("gpt-5-1-pro");