@steipete/oracle 0.14.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 (69) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/oracle-cli.js +8 -8
  3. package/dist/src/browser/actions/assistantResponse.js +2 -1
  4. package/dist/src/browser/actions/modelSelection.js +344 -29
  5. package/dist/src/browser/actions/thinkingTime.js +239 -46
  6. package/dist/src/browser/chatgptImages.js +126 -24
  7. package/dist/src/browser/index.js +63 -8
  8. package/dist/src/browser/projectSourcesRunner.js +2 -1
  9. package/dist/src/browser/prompt.js +151 -22
  10. package/dist/src/cli/browserConfig.js +2 -1
  11. package/dist/src/cli/browserDefaults.js +2 -1
  12. package/dist/src/cli/options.js +8 -0
  13. package/dist/src/mcp/tools/chatgptImage.js +8 -3
  14. package/dist/src/mcp/tools/consult.js +9 -8
  15. package/dist/src/mcp/types.js +11 -2
  16. package/dist/src/oracle/thinkingTime.js +40 -0
  17. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  18. package/package.json +32 -34
  19. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  20. package/dist/bin/oracle.js +0 -569
  21. package/dist/docs-site/.nojekyll +0 -0
  22. package/dist/docs-site/CNAME +0 -1
  23. package/dist/docs-site/RELEASING.html +0 -410
  24. package/dist/docs-site/agents.html +0 -374
  25. package/dist/docs-site/anthropic.html +0 -368
  26. package/dist/docs-site/bridge.html +0 -400
  27. package/dist/docs-site/browser-mode.html +0 -593
  28. package/dist/docs-site/chromium-forks.html +0 -347
  29. package/dist/docs-site/cli-reference.html +0 -346
  30. package/dist/docs-site/configuration.html +0 -452
  31. package/dist/docs-site/favicon.svg +0 -14
  32. package/dist/docs-site/followup.html +0 -375
  33. package/dist/docs-site/gemini.html +0 -383
  34. package/dist/docs-site/grok.html +0 -325
  35. package/dist/docs-site/index.html +0 -360
  36. package/dist/docs-site/install.html +0 -335
  37. package/dist/docs-site/linux.html +0 -321
  38. package/dist/docs-site/llms.txt +0 -43
  39. package/dist/docs-site/manual-tests.html +0 -596
  40. package/dist/docs-site/mcp.html +0 -391
  41. package/dist/docs-site/multimodel.html +0 -364
  42. package/dist/docs-site/mythical-pro-agents.html +0 -360
  43. package/dist/docs-site/notifier.html +0 -338
  44. package/dist/docs-site/openai-endpoints.html +0 -387
  45. package/dist/docs-site/openrouter.html +0 -344
  46. package/dist/docs-site/quickstart.html +0 -369
  47. package/dist/docs-site/refactor/ux.html +0 -532
  48. package/dist/docs-site/sessions.html +0 -388
  49. package/dist/docs-site/social-card.png +0 -0
  50. package/dist/docs-site/social-card.svg +0 -79
  51. package/dist/docs-site/spec.html +0 -363
  52. package/dist/docs-site/testing.html +0 -320
  53. package/dist/docs-site/tui-debug.html +0 -326
  54. package/dist/docs-site/windows-work.html +0 -323
  55. package/dist/docs-site/windows.html +0 -320
  56. package/dist/src/browser/chromeCookies.js +0 -312
  57. package/dist/src/browser/keytarShim.js +0 -56
  58. package/dist/src/browser/windowsCookies.js +0 -219
  59. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  60. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  61. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  62. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  63. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  68. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  69. package/vendor/oracle-notifier/README.md +0 -26
package/README.md CHANGED
@@ -302,8 +302,8 @@ Browser automation can open or control Chrome, so dry-runs and live runs print a
302
302
  | `--browser-research deep` | Activate ChatGPT Deep Research for broad web research and cited reports (browser only). |
303
303
  | `--browser-follow-up <prompt>` | Browser-only multi-turn consult: submit an additional prompt in the same ChatGPT conversation after the initial answer. Repeat for challenge/revision/final-decision passes. Not supported with Deep Research mode. |
304
304
  | `--browser-archive <auto\|always\|never>` | Archive completed ChatGPT browser conversations after local artifacts are saved. `auto` archives successful one-shot chats only, and skips project, Deep Research, multi-turn, failed, and incomplete sessions. |
305
- | `--browser-attachments <auto\|never\|always>` | Control browser file delivery: `auto` pastes small text files inline and uploads larger bundles, `never` always pastes inline, and `always` uploads files as ChatGPT attachments. |
306
- | `--browser-bundle-files`, `--browser-bundle-format <text\|zip>` | Bundle browser uploads into one attachment. `text` keeps the existing single Markdown-style text bundle; `zip` preserves individual file names inside one ZIP upload. |
305
+ | `--browser-attachments <auto\|never\|always>` | Control browser file delivery: `auto` pastes small text files inline and uploads larger or raw files, `never` requires inline-compatible text files, and `always` uploads files as ChatGPT attachments. |
306
+ | `--browser-bundle-files`, `--browser-bundle-format <auto\|text\|zip>` | Bundle browser uploads into one attachment. `auto` uses a text bundle for text-only inputs and a byte-preserving ZIP when bundled inputs include raw files; `text` writes a Markdown-style text bundle; `zip` archives the original file bytes. |
307
307
  | `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
308
308
  | `--browser-inline-cookies[(-file)] <payload \| path>` | Supply cookies without Chrome/Keychain (browser). |
309
309
  | `--browser-timeout`, `--browser-input-timeout`, `--browser-attachment-timeout` | Control overall/browser input/attachment readiness timeouts (supports h/m/s/ms). |
@@ -18,7 +18,7 @@ import { DEFAULT_MODEL, MODEL_CONFIGS } from "../src/oracle/config.js";
18
18
  import { isKnownModel } from "../src/oracle/modelResolver.js";
19
19
  import { CHATGPT_URL } from "../src/browser/constants.js";
20
20
  import { applyHelpStyling } from "../src/cli/help.js";
21
- import { collectPaths, collectModelList, collectTextValues, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from "../src/cli/options.js";
21
+ import { collectPaths, collectModelList, collectTextValues, parseFloatOption, parseIntOption, parseSearchOption, parseThinkingTimeOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from "../src/cli/options.js";
22
22
  import { copyToClipboard } from "../src/cli/clipboard.js";
23
23
  import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
24
24
  import { shouldDetachSession } from "../src/cli/detach.js";
@@ -329,8 +329,8 @@ program
329
329
  .addOption(new Option("--browser-hide-window", "Hide the Chrome window after launch (macOS headful only).").hideHelp())
330
330
  .addOption(new Option("--browser-keep-browser", "Keep Chrome running after completion.").hideHelp())
331
331
  .addOption(new Option("--browser-model-strategy <mode>", "ChatGPT model picker strategy: select (default) switches to the requested model, current keeps the active model, ignore skips the picker entirely.").choices(["select", "current", "ignore"]))
332
- .addOption(new Option("--browser-thinking-time <level>", "Thinking time intensity for Thinking/Pro models: light, standard, extended, heavy.")
333
- .choices(["light", "standard", "extended", "heavy"])
332
+ .addOption(new Option("--browser-thinking-time <level>", "Thinking time intensity for Thinking/Pro models: light, standard, extended, heavy, or ChatGPT UI aliases.")
333
+ .argParser(parseThinkingTimeOption)
334
334
  .hideHelp())
335
335
  .addOption(new Option("--browser-research <mode>", "Browser research mode: deep activates ChatGPT Deep Research.").choices(["off", "deep"]))
336
336
  .addOption(new Option("--browser-archive <mode>", "Archive completed ChatGPT browser conversations after local artifacts are saved (auto archives successful non-project one-shots only).").choices(["auto", "always", "never"]))
@@ -338,7 +338,7 @@ program
338
338
  .argParser(collectTextValues)
339
339
  .default([]))
340
340
  .addOption(new Option("--browser-allow-cookie-errors", "Continue even if Chrome cookies cannot be copied.").hideHelp())
341
- .addOption(new Option("--browser-attachments <mode>", "How to deliver --file inputs in browser mode: auto (default) pastes inline up to ~60k chars then uploads; never always paste inline; always always upload.")
341
+ .addOption(new Option("--browser-attachments <mode>", "How to deliver --file inputs in browser mode: auto (default) pastes text inline up to ~60k chars then uploads; never requires inline-compatible text files; always uploads.")
342
342
  .choices(["auto", "never", "always"])
343
343
  .default("auto"))
344
344
  .addOption(new Option("--remote-chrome <host:port>", "Connect to remote Chrome DevTools Protocol, or when combined with --browser-attach-running use this host:port as the local attach hint."))
@@ -347,9 +347,9 @@ program
347
347
  .addOption(new Option("--remote-token <token>", "Access token for the remote `oracle serve` instance."))
348
348
  .addOption(new Option("--browser-inline-files", "Alias for --browser-attachments never (force pasting file contents inline).").default(false))
349
349
  .addOption(new Option("--browser-bundle-files", "Bundle all attachments into a single archive before uploading.").default(false))
350
- .addOption(new Option("--browser-bundle-format <format>", "Bundle format for browser uploads when files are bundled: text (default) or zip.")
351
- .choices(["text", "zip"])
352
- .default("text"))
350
+ .addOption(new Option("--browser-bundle-format <format>", "Bundle format for browser uploads when files are bundled: auto (default), text, or zip.")
351
+ .choices(["auto", "text", "zip"])
352
+ .default("auto"))
353
353
  .addOption(new Option("--youtube <url>", "YouTube video URL to analyze (Gemini web/cookie mode only; uses your signed-in Chrome cookies for gemini.google.com)."))
354
354
  .addOption(new Option("--generate-image <file>", "Generate image and save to file (Gemini browser mode; ChatGPT browser mode saves downloadable image artifacts when present)."))
355
355
  .addOption(new Option("--edit-image <file>", "Edit existing image (Gemini browser mode; for ChatGPT attach source images with --file and use --generate-image for output)."))
@@ -689,7 +689,7 @@ function buildRunOptions(options, overrides = {}) {
689
689
  "auto",
690
690
  browserInlineFiles: overrides.browserInlineFiles ?? options.browserInlineFiles ?? false,
691
691
  browserBundleFiles: overrides.browserBundleFiles ?? options.browserBundleFiles ?? false,
692
- browserBundleFormat: overrides.browserBundleFormat ?? options.browserBundleFormat ?? "text",
692
+ browserBundleFormat: overrides.browserBundleFormat ?? options.browserBundleFormat ?? "auto",
693
693
  generateImage: overrides.generateImage ?? options.generateImage,
694
694
  outputPath: overrides.outputPath ?? options.output,
695
695
  browserFollowUps: overrides.browserFollowUps ?? options.browserFollowUp ?? [],
@@ -857,7 +857,8 @@ function buildAssistantExtractor(functionName) {
857
857
  !normalizedText ||
858
858
  normalizedText === 'edit' ||
859
859
  normalizedText === 'stopped thinking' ||
860
- normalizedText === 'stopped thinking edit';
860
+ normalizedText === 'stopped thinking edit' ||
861
+ /^thought for \\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\\s+edit$/.test(normalizedText);
861
862
  if (generatedImages.length > 0 && imageOnlyChrome) {
862
863
  const label = generatedImages.length === 1 ? 'Generated image.' : \`Generated \${generatedImages.length} images.\`;
863
864
  return { text: label, html: messageRoot?.innerHTML ?? html, messageId, turnId, turnIndex: index };
@@ -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) ||
@@ -464,11 +660,11 @@ function buildModelSelectionExpression(targetModel, strategy) {
464
660
  const candidateHasInstant =
465
661
  normalizedText.includes('instant') || normalizedTestId.includes('instant');
466
662
  if (wantsPro && candidateHasThinking) return 0;
467
- if (wantsPro && candidateHasLegacyProVersion) return 0;
468
- if (wantsPro && !candidateHasPro) return 0;
469
- 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;
470
666
  if (wantsThinking && candidateHasPro) return 0;
471
- if (wantsThinking && !candidateHasThinking) return 0;
667
+ if (wantsThinking && !candidateHasThinking && !candidateSelectsDesiredVersion) return 0;
472
668
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
473
669
  const candidateHasVersion =
474
670
  normalizedText.includes('5 5') ||
@@ -482,6 +678,39 @@ function buildModelSelectionExpression(targetModel, strategy) {
482
678
  if (candidateGpt55VisibleAlias) {
483
679
  score += 900;
484
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
+ }
485
714
  if (normalizedText && normalizedTarget) {
486
715
  if (normalizedText === normalizedTarget) {
487
716
  score += 500;
@@ -517,7 +746,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
517
746
  }
518
747
  // Similarly for Thinking variant
519
748
  if (wantsThinking) {
520
- 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
+ ) {
521
757
  score -= 80;
522
758
  }
523
759
  } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
@@ -558,6 +794,21 @@ function buildModelSelectionExpression(targetModel, strategy) {
558
794
  );
559
795
  return pickerMenus.concat(textFallbackMenus);
560
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
+ };
561
812
 
562
813
  const findBestOption = () => {
563
814
  // Walk through every menu item and keep whichever earns the highest score.
@@ -566,16 +817,19 @@ function buildModelSelectionExpression(targetModel, strategy) {
566
817
  for (const menu of menus) {
567
818
  const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
568
819
  for (const option of buttons) {
569
- if (isThinkingEffortControl(option)) {
820
+ if (isNestedEffortControl(option, menu)) {
570
821
  continue;
571
822
  }
572
823
  const text = option.textContent ?? '';
573
824
  const normalizedText = normalizeText(text);
574
825
  const testid = option.getAttribute('data-testid') ?? '';
575
- const score = scoreOption(normalizedText, testid);
826
+ let score = scoreOption(normalizedText, testid, option);
576
827
  if (score <= 0) {
577
828
  continue;
578
829
  }
830
+ if (canTrustSelectedOption(option, normalizedText, testid)) {
831
+ score += 1000;
832
+ }
579
833
  const label = getOptionLabel(option);
580
834
  if (!bestMatch || score > bestMatch.score) {
581
835
  bestMatch = { node: option, label, score, testid, normalizedText };
@@ -613,6 +867,28 @@ function buildModelSelectionExpression(targetModel, strategy) {
613
867
  };
614
868
  check();
615
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
+ };
616
892
 
617
893
  return new Promise((resolve) => {
618
894
  const start = performance.now();
@@ -655,26 +931,32 @@ function buildModelSelectionExpression(targetModel, strategy) {
655
931
  ensureMenuOpen();
656
932
  const match = findBestOption();
657
933
  if (match) {
658
- if (optionIsSelected(match.node) || activeSelectionMatchesTarget()) {
934
+ if (
935
+ activeSelectionMatchesTarget() ||
936
+ canTrustSelectedOption(match.node, match.normalizedText, match.testid)
937
+ ) {
938
+ const resolvedLabel = getResolvedLabel(match.label);
659
939
  closeMenu();
660
- resolve({ status: 'already-selected', label: getResolvedLabel(match.label) });
940
+ resolve({ status: 'already-selected', label: resolvedLabel });
661
941
  return;
662
942
  }
663
943
  const previousButtonLabel = normalizeText(getButtonLabel());
664
944
  const previousComposerSignal = readComposerModelSignal();
665
- dispatchClickSequence(match.node);
666
945
  // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
667
946
  // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
668
- const isSubmenu = (match.testid ?? '').toLowerCase().includes('submenu');
947
+ const isSubmenu = isSubmenuOption(match.node, match.testid);
669
948
  if (isSubmenu) {
949
+ openSubmenuOption(match.node);
670
950
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
671
951
  return;
672
952
  }
953
+ dispatchClickSequence(match.node);
673
954
  // Wait for the selected model signal to settle before reopening the picker.
674
955
  waitForTargetSelection(previousButtonLabel, previousComposerSignal).then((selectionSettled) => {
675
956
  if (selectionSettled === 'target') {
957
+ const resolvedLabel = getResolvedLabel(match.label);
676
958
  closeMenu();
677
- resolve({ status: 'switched', label: getResolvedLabel(match.label) });
959
+ resolve({ status: 'switched', label: resolvedLabel });
678
960
  return;
679
961
  }
680
962
  attempt();
@@ -783,6 +1065,34 @@ function buildModelMatchersLiteral(targetModel) {
783
1065
  testIdTokens.add("gpt5-4");
784
1066
  testIdTokens.add("gpt54");
785
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
+ }
786
1096
  // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
787
1097
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
788
1098
  push("5.1", labelTokens);
@@ -857,6 +1167,11 @@ function buildModelMatchersLiteral(targetModel) {
857
1167
  testIdTokens.add("gpt-5-4-pro");
858
1168
  testIdTokens.add("gpt54pro");
859
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
+ }
860
1175
  if (base.includes("5.1") || base.includes("5-1") || base.includes("51")) {
861
1176
  testIdTokens.add("gpt-5.1-pro");
862
1177
  testIdTokens.add("gpt-5-1-pro");