@steipete/oracle 0.14.0 → 0.15.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.
Files changed (31) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/oracle-cli.js +10 -8
  3. package/dist/docs-site/browser-mode.html +3 -2
  4. package/dist/docs-site/cli-reference.html +1 -1
  5. package/dist/docs-site/mcp.html +1 -1
  6. package/dist/src/browser/actions/assistantResponse.js +2 -1
  7. package/dist/src/browser/actions/deepResearch.js +132 -61
  8. package/dist/src/browser/actions/modelSelection.js +388 -30
  9. package/dist/src/browser/actions/thinkingTime.js +303 -65
  10. package/dist/src/browser/artifacts.js +2 -8
  11. package/dist/src/browser/chatgptFiles.js +198 -49
  12. package/dist/src/browser/chatgptImages.js +126 -24
  13. package/dist/src/browser/chromeLifecycle.js +35 -4
  14. package/dist/src/browser/deepResearchResult.js +23 -0
  15. package/dist/src/browser/index.js +145 -19
  16. package/dist/src/browser/profileCopy.js +93 -0
  17. package/dist/src/browser/projectSourcesRunner.js +2 -1
  18. package/dist/src/browser/prompt.js +151 -22
  19. package/dist/src/cli/browserConfig.js +19 -2
  20. package/dist/src/cli/browserDefaults.js +2 -1
  21. package/dist/src/cli/options.js +8 -0
  22. package/dist/src/cli/sessionRunner.js +13 -7
  23. package/dist/src/mcp/tools/chatgptImage.js +8 -3
  24. package/dist/src/mcp/tools/consult.js +9 -8
  25. package/dist/src/mcp/types.js +11 -2
  26. package/dist/src/oracle/thinkingTime.js +40 -0
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/package.json +6 -6
  30. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -12,8 +12,7 @@ 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": {
15
+ case "switched": {
17
16
  const label = result.label?.trim() || (strategy === "current" ? null : desiredModel);
18
17
  if (strategy !== "current") {
19
18
  assertResolvedModelSelection(desiredModel, label ?? desiredModel);
@@ -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 = () => {
@@ -206,6 +219,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
206
219
  };
207
220
 
208
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
+ }
209
230
  const button = findModelButton();
210
231
  if (!button) return;
211
232
  try {
@@ -231,6 +252,25 @@ function buildModelSelectionExpression(targetModel, strategy) {
231
252
  const getComposerModelLabel = () =>
232
253
  (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim();
233
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
+ };
234
274
  const withProPillSignal = (label) => {
235
275
  const resolved = label || '';
236
276
  if (!wantsPro || !hasProComposerPill()) return resolved;
@@ -240,10 +280,84 @@ function buildModelSelectionExpression(targetModel, strategy) {
240
280
  if (normalized.includes('pro')) return resolved;
241
281
  return resolved + ' + Pro';
242
282
  };
243
- const getResolvedLabel = (fallback) =>
244
- withProPillSignal(getComposerModelLabel() || getButtonLabel() || fallback);
245
283
  const isThinkingEffortLabel = (label) =>
246
- 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
+ };
247
361
  if (MODEL_STRATEGY === 'current') {
248
362
  const currentLabel = getResolvedLabel('') || null;
249
363
  return {
@@ -257,15 +371,18 @@ function buildModelSelectionExpression(targetModel, strategy) {
257
371
  return { status: 'button-missing' };
258
372
  }
259
373
  const buttonMatchesTarget = () => {
374
+ if (configuredSelectionMatchesTarget()) return true;
260
375
  const normalizedLabel = normalizeText(getButtonLabel());
261
376
  if (!normalizedLabel) return false;
377
+ if (wantsThinking && !wantsPro && hasProComposerPill()) return false;
262
378
  if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
263
379
  if (
264
380
  wantsThinking &&
265
381
  desiredVersion === '5-5' &&
266
382
  !hasProComposerPill() &&
267
383
  isThinkingEffortLabel(normalizedLabel) &&
268
- isTargetGpt55VisibleAlias(readComposerModelSignal())
384
+ (isNonProIntelligenceThinkingLabel(normalizedLabel) ||
385
+ isTargetGpt55VisibleAlias(readComposerModelSignal()))
269
386
  ) {
270
387
  return true;
271
388
  }
@@ -283,6 +400,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
283
400
  if (desiredVersion) {
284
401
  if (desiredVersion === '5-5' && !normalizedLabel.includes('5 5')) return false;
285
402
  if (desiredVersion === '5-4' && !normalizedLabel.includes('5 4')) return false;
403
+ if (desiredVersion === '5-3' && !normalizedLabel.includes('5 3')) return false;
286
404
  if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
287
405
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
288
406
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
@@ -292,9 +410,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
292
410
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
293
411
  if (
294
412
  wantsThinking &&
295
- desiredVersion === '5-4' &&
296
- !normalizedLabel.includes('pro') &&
297
- !normalizedLabel.includes('instant')
413
+ desiredVersion &&
414
+ versionFromLabel(normalizedLabel) === desiredVersion &&
415
+ !labelHasProWord(normalizedLabel)
298
416
  ) {
299
417
  return true;
300
418
  }
@@ -359,10 +477,21 @@ function buildModelSelectionExpression(targetModel, strategy) {
359
477
  };
360
478
 
361
479
  const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
362
- 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) =>
363
489
  node instanceof HTMLElement &&
364
490
  (node.getAttribute('data-model-picker-thinking-effort-action') === 'true' ||
365
- 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));
366
495
  const optionIsSelected = (node) => {
367
496
  if (!(node instanceof HTMLElement)) {
368
497
  return false;
@@ -382,13 +511,28 @@ function buildModelSelectionExpression(targetModel, strategy) {
382
511
  return false;
383
512
  };
384
513
 
385
- const scoreOption = (normalizedText, testid) => {
514
+ const scoreOption = (normalizedText, testid, node) => {
386
515
  // Assign a score to every node so we can pick the most likely match without brittle equality checks.
387
516
  if (!normalizedText && !testid) {
388
517
  return 0;
389
518
  }
390
519
  let score = 0;
391
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
+ }
392
536
  let exactTestIdMatch = false;
393
537
  if (normalizedTestId) {
394
538
  if (desiredVersion) {
@@ -411,6 +555,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
411
555
  normalizedTestId.includes('gpt-5-4') ||
412
556
  normalizedTestId.includes('gpt-5.4') ||
413
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');
414
564
  const has51 =
415
565
  normalizedTestId.includes('5-1') ||
416
566
  normalizedTestId.includes('5.1') ||
@@ -423,7 +573,19 @@ function buildModelSelectionExpression(targetModel, strategy) {
423
573
  normalizedTestId.includes('gpt-5-0') ||
424
574
  normalizedTestId.includes('gpt-5.0') ||
425
575
  normalizedTestId.includes('gpt50');
426
- 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;
427
589
  // If a candidate advertises a different version, ignore it entirely.
428
590
  if (candidateVersion && candidateVersion !== desiredVersion) {
429
591
  return 0;
@@ -452,10 +614,44 @@ function buildModelSelectionExpression(targetModel, strategy) {
452
614
  }
453
615
  }
454
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'));
455
647
  const candidateHasThinking =
456
648
  normalizedText.includes('thinking') ||
457
649
  normalizedTestId.includes('thinking') ||
458
- (wantsThinking && desiredVersion === '5-4' && exactTestIdMatch);
650
+ candidateIsNonProGpt55Thinking ||
651
+ candidateClearsProForThinking ||
652
+ candidateOpensVersionSubmenu ||
653
+ candidateIsGpt55ThinkingFamily ||
654
+ (wantsThinking && desiredVersion && (exactTestIdMatch || candidateSelectsDesiredVersion));
459
655
  const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
460
656
  const candidateHasPro =
461
657
  labelHasProWord(normalizedText) ||
@@ -463,12 +659,29 @@ function buildModelSelectionExpression(targetModel, strategy) {
463
659
  normalizedTestId.includes('pro');
464
660
  const candidateHasInstant =
465
661
  normalizedText.includes('instant') || normalizedTestId.includes('instant');
662
+ const candidateSelectsConfiguredVersion =
663
+ Boolean(getConfigurationDialog()) &&
664
+ candidateSelectsDesiredVersion &&
665
+ (node?.getAttribute?.('role') === 'option' ||
666
+ node?.getAttribute?.('role') === 'menuitemradio');
667
+ const candidateOpensInstantSubmenu =
668
+ wantsInstant &&
669
+ candidateSelectsDesiredVersion &&
670
+ !candidateHasInstant &&
671
+ (normalizedTestId.includes('submenu') ||
672
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
673
+ node?.getAttribute?.('data-has-submenu') !== null);
466
674
  if (wantsPro && candidateHasThinking) return 0;
467
- if (wantsPro && candidateHasLegacyProVersion) return 0;
468
- if (wantsPro && !candidateHasPro) return 0;
469
- if (wantsInstant && !candidateHasInstant) return 0;
675
+ if (wantsPro && candidateHasLegacyProVersion && !candidateSelectsDesiredVersion) return 0;
676
+ if (wantsPro && !candidateHasPro && !candidateSelectsDesiredVersion) return 0;
677
+ if (
678
+ wantsInstant &&
679
+ !candidateHasInstant &&
680
+ !candidateOpensInstantSubmenu &&
681
+ !candidateSelectsConfiguredVersion
682
+ ) return 0;
470
683
  if (wantsThinking && candidateHasPro) return 0;
471
- if (wantsThinking && !candidateHasThinking) return 0;
684
+ if (wantsThinking && !candidateHasThinking && !candidateSelectsDesiredVersion) return 0;
472
685
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
473
686
  const candidateHasVersion =
474
687
  normalizedText.includes('5 5') ||
@@ -482,6 +695,43 @@ function buildModelSelectionExpression(targetModel, strategy) {
482
695
  if (candidateGpt55VisibleAlias) {
483
696
  score += 900;
484
697
  }
698
+ if (wantsPro && candidateHasPro && !candidateSelectsDesiredVersion) {
699
+ score += 900;
700
+ }
701
+ if (wantsInstant && candidateHasInstant && !candidateSelectsDesiredVersion) {
702
+ score += 900;
703
+ }
704
+ if (
705
+ wantsThinking &&
706
+ candidateHasThinking &&
707
+ !candidateSelectsDesiredVersion &&
708
+ !candidateOpensVersionSubmenu
709
+ ) {
710
+ score += 900;
711
+ }
712
+ if (candidateIsNonProGpt55Thinking) {
713
+ score += scoreNonProGpt55ThinkingLabel(normalizedText);
714
+ }
715
+ if (candidateClearsProForThinking) {
716
+ score += scoreNonProGpt55ThinkingLabel(normalizedText) + 600;
717
+ }
718
+ if (
719
+ desiredVersion &&
720
+ candidateTextVersion === desiredVersion &&
721
+ !candidateOpensInstantSubmenu &&
722
+ !(wantsThinking && desiredVersion === '5-5' && normalizedText === 'gpt 5 5')
723
+ ) {
724
+ score += 1200;
725
+ }
726
+ if (candidateOpensInstantSubmenu) {
727
+ score += 300;
728
+ }
729
+ if (candidateOpensVersionSubmenu) {
730
+ score += 500;
731
+ }
732
+ if (candidateIsGpt55ThinkingFamily) {
733
+ score += 260;
734
+ }
485
735
  if (normalizedText && normalizedTarget) {
486
736
  if (normalizedText === normalizedTarget) {
487
737
  score += 500;
@@ -517,7 +767,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
517
767
  }
518
768
  // Similarly for Thinking variant
519
769
  if (wantsThinking) {
520
- if (!normalizedText.includes('thinking') && !normalizedTestId.includes('thinking')) {
770
+ if (
771
+ !candidateIsGpt55ThinkingFamily &&
772
+ !candidateIsNonProGpt55Thinking &&
773
+ !candidateClearsProForThinking &&
774
+ !candidateOpensVersionSubmenu &&
775
+ !normalizedText.includes('thinking') &&
776
+ !normalizedTestId.includes('thinking')
777
+ ) {
521
778
  score -= 80;
522
779
  }
523
780
  } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
@@ -558,6 +815,24 @@ function buildModelSelectionExpression(targetModel, strategy) {
558
815
  );
559
816
  return pickerMenus.concat(textFallbackMenus);
560
817
  };
818
+ const isSubmenuOption = (node, testid) =>
819
+ (testid ?? '').toLowerCase().includes('submenu') ||
820
+ (testid ?? '').toLowerCase() === 'model-configure-modal' ||
821
+ (node?.getAttribute?.('role') === 'combobox' &&
822
+ node?.getAttribute?.('aria-labelledby') === 'model-selection-label') ||
823
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
824
+ node?.getAttribute?.('data-has-submenu') !== null;
825
+ const canTrustSelectedOption = (node, normalizedText, testid) => {
826
+ if (!optionIsSelected(node)) return false;
827
+ if (getConfigurationDialog() && !configuredSelectionMatchesTarget()) return false;
828
+ const optionVersion = versionFromLabel(normalizedText) ?? versionFromTestId(testid);
829
+ if (desiredVersion && optionVersion !== desiredVersion) return false;
830
+ const currentButtonLabel = normalizeText(getButtonLabel());
831
+ return !labelHasProWord(currentButtonLabel) && !hasProComposerPill();
832
+ };
833
+ const openedSubmenuKeys = new Set();
834
+ const submenuKey = (normalizedText, testid) =>
835
+ normalizeText(testid ?? '') + '|' + normalizedText;
561
836
 
562
837
  const findBestOption = () => {
563
838
  // Walk through every menu item and keep whichever earns the highest score.
@@ -566,19 +841,33 @@ function buildModelSelectionExpression(targetModel, strategy) {
566
841
  for (const menu of menus) {
567
842
  const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
568
843
  for (const option of buttons) {
569
- if (isThinkingEffortControl(option)) {
844
+ if (isNestedEffortControl(option, menu)) {
570
845
  continue;
571
846
  }
572
847
  const text = option.textContent ?? '';
573
848
  const normalizedText = normalizeText(text);
574
849
  const testid = option.getAttribute('data-testid') ?? '';
575
- const score = scoreOption(normalizedText, testid);
850
+ const optionSubmenuKey = submenuKey(normalizedText, testid);
851
+ if (isSubmenuOption(option, testid) && openedSubmenuKeys.has(optionSubmenuKey)) {
852
+ continue;
853
+ }
854
+ let score = scoreOption(normalizedText, testid, option);
576
855
  if (score <= 0) {
577
856
  continue;
578
857
  }
858
+ if (canTrustSelectedOption(option, normalizedText, testid)) {
859
+ score += 1000;
860
+ }
579
861
  const label = getOptionLabel(option);
580
862
  if (!bestMatch || score > bestMatch.score) {
581
- bestMatch = { node: option, label, score, testid, normalizedText };
863
+ bestMatch = {
864
+ node: option,
865
+ label,
866
+ score,
867
+ testid,
868
+ normalizedText,
869
+ submenuKey: optionSubmenuKey,
870
+ };
582
871
  }
583
872
  }
584
873
  }
@@ -613,6 +902,28 @@ function buildModelSelectionExpression(targetModel, strategy) {
613
902
  };
614
903
  check();
615
904
  });
905
+ const dispatchHoverSequence = (target) => {
906
+ if (!target || !(target instanceof EventTarget)) return false;
907
+ const types = ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove'];
908
+ for (const type of types) {
909
+ try {
910
+ const common = { bubbles: true, cancelable: true, view: window };
911
+ const event =
912
+ type.startsWith('pointer') && 'PointerEvent' in window
913
+ ? new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' })
914
+ : new MouseEvent(type, common);
915
+ target.dispatchEvent(event);
916
+ } catch {}
917
+ }
918
+ try {
919
+ target.focus?.();
920
+ } catch {}
921
+ return true;
922
+ };
923
+ const openSubmenuOption = (node) => {
924
+ dispatchHoverSequence(node);
925
+ dispatchClickSequence(node);
926
+ };
616
927
 
617
928
  return new Promise((resolve) => {
618
929
  const start = performance.now();
@@ -648,6 +959,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
648
959
  const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
649
960
  let initialized = false;
650
961
  const attempt = async () => {
962
+ if (performance.now() - start > MAX_WAIT_MS) {
963
+ resolve({
964
+ status: 'option-not-found',
965
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
966
+ });
967
+ return;
968
+ }
651
969
  if (!initialized) {
652
970
  initialized = true;
653
971
  await openDelay();
@@ -655,26 +973,33 @@ function buildModelSelectionExpression(targetModel, strategy) {
655
973
  ensureMenuOpen();
656
974
  const match = findBestOption();
657
975
  if (match) {
658
- if (optionIsSelected(match.node) || activeSelectionMatchesTarget()) {
976
+ if (
977
+ activeSelectionMatchesTarget() ||
978
+ canTrustSelectedOption(match.node, match.normalizedText, match.testid)
979
+ ) {
980
+ const resolvedLabel = getResolvedLabel(match.label);
659
981
  closeMenu();
660
- resolve({ status: 'already-selected', label: getResolvedLabel(match.label) });
982
+ resolve({ status: 'already-selected', label: resolvedLabel });
661
983
  return;
662
984
  }
663
985
  const previousButtonLabel = normalizeText(getButtonLabel());
664
986
  const previousComposerSignal = readComposerModelSignal();
665
- dispatchClickSequence(match.node);
666
987
  // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
667
988
  // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
668
- const isSubmenu = (match.testid ?? '').toLowerCase().includes('submenu');
989
+ const isSubmenu = isSubmenuOption(match.node, match.testid);
669
990
  if (isSubmenu) {
991
+ openedSubmenuKeys.add(match.submenuKey);
992
+ openSubmenuOption(match.node);
670
993
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
671
994
  return;
672
995
  }
996
+ dispatchClickSequence(match.node);
673
997
  // Wait for the selected model signal to settle before reopening the picker.
674
998
  waitForTargetSelection(previousButtonLabel, previousComposerSignal).then((selectionSettled) => {
675
999
  if (selectionSettled === 'target') {
1000
+ const resolvedLabel = getResolvedLabel(match.label);
676
1001
  closeMenu();
677
- resolve({ status: 'switched', label: getResolvedLabel(match.label) });
1002
+ resolve({ status: 'switched', label: resolvedLabel });
678
1003
  return;
679
1004
  }
680
1005
  attempt();
@@ -783,6 +1108,34 @@ function buildModelMatchersLiteral(targetModel) {
783
1108
  testIdTokens.add("gpt5-4");
784
1109
  testIdTokens.add("gpt54");
785
1110
  }
1111
+ // Numeric variations (5.3 ↔ 53 ↔ gpt-5-3)
1112
+ if (base.includes("5.3") || base.includes("5-3") || base.includes("53")) {
1113
+ push("5.3", labelTokens);
1114
+ push("gpt-5.3", labelTokens);
1115
+ push("gpt5.3", labelTokens);
1116
+ push("gpt-5-3", labelTokens);
1117
+ push("gpt5-3", labelTokens);
1118
+ push("gpt53", labelTokens);
1119
+ push("chatgpt 5.3", labelTokens);
1120
+ if (base.includes("thinking")) {
1121
+ push("thinking", labelTokens);
1122
+ testIdTokens.add("model-switcher-gpt-5-3-thinking");
1123
+ testIdTokens.add("gpt-5-3-thinking");
1124
+ testIdTokens.add("gpt-5.3-thinking");
1125
+ }
1126
+ if (base.includes("instant")) {
1127
+ push("instant", labelTokens);
1128
+ testIdTokens.add("model-switcher-gpt-5-3-instant");
1129
+ testIdTokens.add("gpt-5-3-instant");
1130
+ testIdTokens.add("gpt-5.3-instant");
1131
+ }
1132
+ if (!base.includes("pro") && !base.includes("thinking") && !base.includes("instant")) {
1133
+ testIdTokens.add("model-switcher-gpt-5-3");
1134
+ }
1135
+ testIdTokens.add("gpt-5-3");
1136
+ testIdTokens.add("gpt5-3");
1137
+ testIdTokens.add("gpt53");
1138
+ }
786
1139
  // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
787
1140
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
788
1141
  push("5.1", labelTokens);
@@ -857,6 +1210,11 @@ function buildModelMatchersLiteral(targetModel) {
857
1210
  testIdTokens.add("gpt-5-4-pro");
858
1211
  testIdTokens.add("gpt54pro");
859
1212
  }
1213
+ if (base.includes("5.3") || base.includes("5-3") || base.includes("53")) {
1214
+ testIdTokens.add("gpt-5.3-pro");
1215
+ testIdTokens.add("gpt-5-3-pro");
1216
+ testIdTokens.add("gpt53pro");
1217
+ }
860
1218
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
861
1219
  testIdTokens.add("gpt-5.1-pro");
862
1220
  testIdTokens.add("gpt-5-1-pro");