@steipete/oracle 0.17.1 → 0.17.3

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 (92) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/bin/oracle-mcp.js +0 -0
  3. package/dist/bin/oracle.js +683 -0
  4. package/dist/docs-site/bridge.html +1 -17
  5. package/dist/docs-site/browser-mode.html +14 -20
  6. package/dist/docs-site/cli-reference.html +6 -6
  7. package/dist/docs-site/configuration.html +7 -33
  8. package/dist/docs-site/followup.html +9 -16
  9. package/dist/docs-site/gemini.html +6 -10
  10. package/dist/docs-site/index.html +7 -7
  11. package/dist/docs-site/install.html +1 -1
  12. package/dist/docs-site/manual-tests.html +1 -2
  13. package/dist/docs-site/mcp.html +5 -28
  14. package/dist/docs-site/mythical-pro-agents.html +10 -10
  15. package/dist/docs-site/openai-endpoints.html +1 -24
  16. package/dist/docs-site/quickstart.html +1 -1
  17. package/dist/docs-site/sessions.html +5 -6
  18. package/dist/docs-site/spec.html +1 -1
  19. package/dist/docs-site/windows-work.html +0 -1
  20. package/dist/markdansi/types/index.js +4 -0
  21. package/dist/oracle/bin/oracle-cli.js +472 -0
  22. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  23. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  24. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  25. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  26. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  27. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  28. package/dist/oracle/src/browser/config.js +33 -0
  29. package/dist/oracle/src/browser/constants.js +40 -0
  30. package/dist/oracle/src/browser/cookies.js +210 -0
  31. package/dist/oracle/src/browser/domDebug.js +36 -0
  32. package/dist/oracle/src/browser/index.js +331 -0
  33. package/dist/oracle/src/browser/pageActions.js +5 -0
  34. package/dist/oracle/src/browser/prompt.js +88 -0
  35. package/dist/oracle/src/browser/promptSummary.js +20 -0
  36. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  37. package/dist/oracle/src/browser/types.js +1 -0
  38. package/dist/oracle/src/browser/utils.js +62 -0
  39. package/dist/oracle/src/browserMode.js +1 -0
  40. package/dist/oracle/src/cli/browserConfig.js +44 -0
  41. package/dist/oracle/src/cli/dryRun.js +59 -0
  42. package/dist/oracle/src/cli/engine.js +17 -0
  43. package/dist/oracle/src/cli/errorUtils.js +9 -0
  44. package/dist/oracle/src/cli/help.js +70 -0
  45. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  46. package/dist/oracle/src/cli/options.js +103 -0
  47. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  48. package/dist/oracle/src/cli/rootAlias.js +30 -0
  49. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  50. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  51. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  52. package/dist/oracle/src/heartbeat.js +43 -0
  53. package/dist/oracle/src/oracle/client.js +48 -0
  54. package/dist/oracle/src/oracle/config.js +29 -0
  55. package/dist/oracle/src/oracle/errors.js +101 -0
  56. package/dist/oracle/src/oracle/files.js +220 -0
  57. package/dist/oracle/src/oracle/format.js +33 -0
  58. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  59. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  60. package/dist/oracle/src/oracle/request.js +48 -0
  61. package/dist/oracle/src/oracle/run.js +444 -0
  62. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  63. package/dist/oracle/src/oracle/types.js +1 -0
  64. package/dist/oracle/src/oracle.js +9 -0
  65. package/dist/oracle/src/sessionManager.js +205 -0
  66. package/dist/oracle/src/version.js +39 -0
  67. package/dist/scripts/chrome/browser-tools.js +295 -0
  68. package/dist/scripts/git-policy.js +0 -8
  69. package/dist/scripts/runner.js +1 -5
  70. package/dist/src/browser/actions/assistantResponse.js +71 -23
  71. package/dist/src/browser/actions/modelSelection.js +164 -5
  72. package/dist/src/browser/actions/thinkingTime.js +195 -18
  73. package/dist/src/browser/chromeCookies.js +312 -0
  74. package/dist/src/browser/index.js +5 -1
  75. package/dist/src/browser/keytarShim.js +56 -0
  76. package/dist/src/browser/policies.js +4 -0
  77. package/dist/src/browser/profileSync.js +141 -0
  78. package/dist/src/browser/projectSourcesRunner.js +2 -2
  79. package/dist/src/browser/reattach.js +47 -35
  80. package/dist/src/browser/windowsCookies.js +219 -0
  81. package/dist/src/cli/browserConfig.js +13 -4
  82. package/dist/src/cli/browserDefaults.js +6 -1
  83. package/dist/src/cli/options.js +1 -1
  84. package/dist/src/cli/projectSources.js +8 -1
  85. package/dist/src/mcp/tools/consult.js +1 -1
  86. package/dist/src/oracle/thinkingTime.js +6 -0
  87. package/dist/src/sessionManager.js +90 -9
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  89. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  90. package/package.json +6 -6
  91. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  92. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -83,19 +83,78 @@ function matchesThinkingStatusLabel(trimmed) {
83
83
  return true;
84
84
  return trimmed.startsWith("pro thinking") && trimmed.length <= 40;
85
85
  }
86
+ // Single source of truth for the "Answer now" placeholder test.
87
+ //
88
+ // This function is ALSO injected verbatim into page expressions via
89
+ // buildAnswerNowPlaceholderPredicateJs (it is stringified with
90
+ // Function.prototype.toString), so it MUST stay closure-free: no imports, no module
91
+ // constants, no helpers. Anything it references must be declared inside its own body
92
+ // or it will throw a ReferenceError inside the renderer.
93
+ //
94
+ // Learned the hard way: ChatGPT's Pro UI keeps the "Answer now" skip-ahead control
95
+ // mounted for the whole reasoning phase, so a bare `text.includes('answer now')` test
96
+ // matched real answers that merely ended with that chrome and discarded them wholesale
97
+ // at every extraction layer. A placeholder is short UI furniture, so this matches the
98
+ // WHOLE trimmed string against known chrome labels behind a length cap, the way the
99
+ // sibling predicates in this file already do.
100
+ //
101
+ // The cap is 60, taken from isActiveLabel in ./thinkingStatus.ts rather than the 40 used
102
+ // by matchesThinkingStatusLabel above: 40 bounds a single status label, while the longest
103
+ // genuine placeholder here is the composite
104
+ // "chatgpt said: file upload request pro thinking answer now" (57 chars), which a 40 cap
105
+ // would wrongly let through.
86
106
  export function isAnswerNowPlaceholderText(value) {
87
- const text = value.toLowerCase().replace(/\s+/g, " ").trim();
107
+ let raw = "";
108
+ if (typeof value === "string") {
109
+ raw = value;
110
+ }
111
+ else if (value && typeof value === "object" && "text" in value) {
112
+ const candidate = value.text;
113
+ if (typeof candidate === "string")
114
+ raw = candidate;
115
+ }
116
+ const text = raw.toLowerCase().replace(/\s+/g, " ").trim();
88
117
  if (!text)
89
118
  return false;
90
- // Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
91
- // That is not the final answer and must be ignored in browser automation.
92
119
  if (text === "chatgpt said:" || text === "chatgpt said")
93
120
  return true;
94
- if (text.includes("file upload request") &&
95
- (text.includes("pro thinking") || text.includes("chatgpt said"))) {
96
- return true;
121
+ if (text.length > 60)
122
+ return false;
123
+ // Whole-string match: consume the text left to right, longest chrome label first.
124
+ // Any residue means real content is present, so this is an answer, not a placeholder.
125
+ const chromeLabels = [
126
+ "chatgpt said:",
127
+ "chatgpt said",
128
+ "file upload request",
129
+ "pro thinking",
130
+ "answer now",
131
+ ];
132
+ let rest = text;
133
+ let sawOwner = false; // "pro thinking" / "chatgpt said"
134
+ let sawGate = false; // "answer now" / "file upload request"
135
+ while (rest.length > 0) {
136
+ let matched = "";
137
+ for (const label of chromeLabels) {
138
+ if (label.length > matched.length && rest.startsWith(label))
139
+ matched = label;
140
+ }
141
+ if (!matched)
142
+ return false;
143
+ if (matched === "answer now" || matched === "file upload request")
144
+ sawGate = true;
145
+ else
146
+ sawOwner = true;
147
+ rest = rest.slice(matched.length).replace(/^[\s:.,;|\u00b7\u2022-]+/, "");
97
148
  }
98
- return (text.includes("answer now") && (text.includes("pro thinking") || text.includes("chatgpt said")));
149
+ return sawGate && sawOwner;
150
+ }
151
+ // Exported: the two page expressions below and the tests all consume this one builder,
152
+ // so the predicate cannot drift between the node side and the renderer.
153
+ export function buildAnswerNowPlaceholderPredicateJs(fnName) {
154
+ // Inject the node-side definition itself so a correction can never land in only one
155
+ // copy. `const x = function name(){}` is a valid expression, so the exported
156
+ // declaration stringifies straight into the page expression.
157
+ return `const ${fnName} = ${isAnswerNowPlaceholderText.toString()};`;
99
158
  }
100
159
  function buildActiveThinkingStatusPredicateJs(fnName) {
101
160
  const labelsLiteral = JSON.stringify(THINKING_STATUS_LABELS);
@@ -288,6 +347,9 @@ export function buildAssistantExtractorForTest(name) {
288
347
  export function buildAssistantSnapshotExpressionForTest(minTurnIndex, expectedConversationId) {
289
348
  return buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId);
290
349
  }
350
+ export function buildResponseObserverExpressionForTest(timeoutMs, minTurnIndex, expectedConversationId) {
351
+ return buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId);
352
+ }
291
353
  export function buildConversationDebugExpressionForTest() {
292
354
  return buildConversationDebugExpression();
293
355
  }
@@ -629,14 +691,7 @@ function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId)
629
691
  // Learned: the default turn DOM misses project view; keep a fallback extractor.
630
692
  ${buildAssistantExtractor("extractAssistantTurn")}
631
693
  const extracted = extractAssistantTurn();
632
- const isPlaceholder = (snapshot) => {
633
- const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
634
- if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
635
- if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
636
- return true;
637
- }
638
- return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
639
- };
694
+ ${buildAnswerNowPlaceholderPredicateJs("isPlaceholder")}
640
695
  ${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")}
641
696
  if (
642
697
  extracted &&
@@ -682,14 +737,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
682
737
  const currentId = currentConversationId();
683
738
  return !currentId || currentId === EXPECTED_CONVERSATION_ID;
684
739
  };
685
- const isAnswerNowPlaceholder = (snapshot) => {
686
- const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
687
- if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
688
- if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
689
- return true;
690
- }
691
- return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
692
- };
740
+ ${buildAnswerNowPlaceholderPredicateJs("isAnswerNowPlaceholder")}
693
741
  ${buildActiveThinkingStatusPredicateJs("isActiveThinkingStatus")}
694
742
  ${buildThinkingActivePredicateJs("isThinkingActiveNow")}
695
743
 
@@ -224,10 +224,24 @@ function buildModelSelectionExpression(targetModel, strategy) {
224
224
  );
225
225
 
226
226
  const isVisibleElement = (node) => {
227
+ if (typeof HTMLElement === 'undefined') return false;
227
228
  if (!(node instanceof HTMLElement)) return false;
228
- const rect = node.getBoundingClientRect();
229
- const style = window.getComputedStyle(node);
230
- return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
229
+ try {
230
+ const rect = node.getBoundingClientRect?.();
231
+ const style = window.getComputedStyle?.(node);
232
+ if (!rect) return true;
233
+ return (
234
+ rect.width > 0 &&
235
+ rect.height > 0 &&
236
+ style?.display !== 'none' &&
237
+ style?.visibility !== 'hidden'
238
+ );
239
+ } catch {
240
+ // DOM test doubles and older Chromium nodes can omit one of these APIs.
241
+ // Treat them as visible; real hidden picker rows still report a zero rect
242
+ // or display:none and are filtered above.
243
+ return true;
244
+ }
231
245
  };
232
246
  const looksLikeModelPill = (node) => {
233
247
  if (!(node instanceof HTMLElement) || !node.matches('button.__composer-pill')) return false;
@@ -383,6 +397,103 @@ function buildModelSelectionExpression(targetModel, strategy) {
383
397
  }
384
398
  return true;
385
399
  };
400
+
401
+ // ---------- Unified Intelligence picker: Advanced -> Model submenu ----------
402
+ // The slider picker exposes only the current effort in the composer pill. Its
403
+ // model versions live behind Advanced -> Model, next to an identically-shaped
404
+ // Effort submenu. Identify the Model opener positively so a Pro effort label can
405
+ // never be mistaken for a model target.
406
+ const ADVANCED_VIEW_SELECTOR = '[data-testid="composer-model-picker-slider-advanced-view"]';
407
+ const INTELLIGENCE_PICKER_SELECTOR = '[data-testid="composer-intelligence-picker-content"]';
408
+ const SUBMENU_OPENER_SELECTOR = '[role="menuitem"][aria-haspopup="menu"]';
409
+ const ADVANCED_WORDS = ['advanced', 'erweitert', '高级', 'avanzado', 'avancado', 'avance'];
410
+ const MODEL_WORDS = ['model', 'modell', '模型', 'modelo', 'modello', 'modele'];
411
+ const EFFORT_WORDS = [
412
+ 'effort', 'aufwand', '强度', '努力',
413
+ 'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
414
+ ];
415
+ const pickerNodeLabel = (node) => {
416
+ const value =
417
+ (node?.getAttribute?.('aria-label') ?? '') + ' ' + (node?.textContent ?? '');
418
+ try {
419
+ return value
420
+ .toLowerCase()
421
+ .normalize('NFD')
422
+ .replace(/[\u0300-\u036f]/g, '')
423
+ .replace(/\\s+/g, ' ')
424
+ .trim();
425
+ } catch {
426
+ return value.toLowerCase().replace(/\\s+/g, ' ').trim();
427
+ }
428
+ };
429
+ const containsPickerWord = (label, words) =>
430
+ words.some((word) => label.includes(word));
431
+ const isSubmenuOpener = (node) =>
432
+ node?.getAttribute?.('role') === 'menuitem' &&
433
+ node?.getAttribute?.('aria-haspopup') === 'menu';
434
+ const isUnifiedPickerMenu = (menu) =>
435
+ Boolean(
436
+ menu?.getAttribute?.('data-testid') === 'composer-intelligence-picker-content' ||
437
+ menu?.querySelector?.(INTELLIGENCE_PICKER_SELECTOR) ||
438
+ menu?.querySelector?.(ADVANCED_VIEW_SELECTOR),
439
+ );
440
+ const findUnifiedPickerMenu = () =>
441
+ Array.from(document.querySelectorAll(${menuContainerLiteral})).find(isUnifiedPickerMenu) ??
442
+ null;
443
+ const findAdvancedToggle = (menu) => {
444
+ for (const item of (menu || document).querySelectorAll('[role="menuitem"]')) {
445
+ if (!isVisibleElement(item)) continue;
446
+ if (containsPickerWord(pickerNodeLabel(item), ADVANCED_WORDS)) return item;
447
+ }
448
+ return null;
449
+ };
450
+ const findModelSubmenuOpener = (menu) => {
451
+ const scope = menu?.querySelector?.(ADVANCED_VIEW_SELECTOR) || menu || document;
452
+ for (const item of scope.querySelectorAll(SUBMENU_OPENER_SELECTOR)) {
453
+ if (!isVisibleElement(item) || !isSubmenuOpener(item)) continue;
454
+ const label = pickerNodeLabel(item);
455
+ if (
456
+ containsPickerWord(label, MODEL_WORDS) &&
457
+ !containsPickerWord(label, EFFORT_WORDS)
458
+ ) {
459
+ return item;
460
+ }
461
+ }
462
+ return null;
463
+ };
464
+ const advancedModelSignalMatchesTarget = (menu = null) => {
465
+ const parentMenu = menu || findUnifiedPickerMenu();
466
+ const opener = findModelSubmenuOpener(parentMenu);
467
+ if (!opener) return false;
468
+ const label = normalizeText(pickerNodeLabel(opener));
469
+ const version = versionFromLabel(label);
470
+ if (desiredVersion && version !== desiredVersion) return false;
471
+ if (desiredModelVariant && !label.split(' ').includes(desiredModelVariant)) return false;
472
+ if (wantsPro) return labelHasProWord(label) && !labelHasLegacyProVersion(label);
473
+ if (wantsInstant) return label.includes('instant');
474
+ if (wantsThinking) return Boolean(desiredVersion) && !labelHasProWord(label);
475
+ if (desiredVersion) return true;
476
+ return normalizedTokens.some((token) => token && label.includes(token));
477
+ };
478
+ const getAdvancedModelLabel = () => {
479
+ const opener = findModelSubmenuOpener(findUnifiedPickerMenu());
480
+ if (!opener) return '';
481
+ const raw = (opener.textContent ?? '').trim();
482
+ const normalized = normalizeText(pickerNodeLabel(opener));
483
+ const version = versionFromLabel(normalized);
484
+ if (!version) return raw;
485
+ const [major, minor] = version.split('-');
486
+ const suffix = normalized.split(' ').includes('sol') ? ' Sol' : '';
487
+ return 'GPT-' + major + '.' + minor + suffix;
488
+ };
489
+ const isLegacyFlatIntelligencePickerOpen = () => {
490
+ const menu = findUnifiedPickerMenu();
491
+ return Boolean(
492
+ menu &&
493
+ !menu.querySelector?.(ADVANCED_VIEW_SELECTOR) &&
494
+ !findAdvancedToggle(menu),
495
+ );
496
+ };
386
497
  const getResolvedLabel = (observedOptionLabel = '') => {
387
498
  if (configuredSelectionMatchesTarget()) {
388
499
  const variant = getConfiguredVariantLabel();
@@ -390,6 +501,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
390
501
  if (desiredModelVariant === 'sol' && labelHasProWord(normalizeText(variant))) return version;
391
502
  return [variant, version].filter(Boolean).join(' ');
392
503
  }
504
+ if (advancedModelSignalMatchesTarget()) {
505
+ return getAdvancedModelLabel();
506
+ }
393
507
  const composerLabel = getComposerModelLabel();
394
508
  const normalizedComposerLabel = normalizeText(composerLabel);
395
509
  if (
@@ -448,8 +562,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
448
562
  desiredVersion === '5-5' &&
449
563
  !hasProComposerPill() &&
450
564
  isThinkingEffortLabel(normalizedLabel) &&
451
- (isNonProIntelligenceThinkingLabel(normalizedLabel) ||
452
- isTargetGpt55VisibleAlias(readComposerModelSignal()))
565
+ (isTargetGpt55VisibleAlias(readComposerModelSignal()) ||
566
+ (isNonProIntelligenceThinkingLabel(normalizedLabel) &&
567
+ isLegacyFlatIntelligencePickerOpen()))
453
568
  ) {
454
569
  return true;
455
570
  }
@@ -521,6 +636,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
521
636
  return COMPOSER_SIGNAL_INCLUDES.some((token) => token && signal.includes(token));
522
637
  };
523
638
  const activeSelectionMatchesTarget = () => {
639
+ if (advancedModelSignalMatchesTarget()) {
640
+ return true;
641
+ }
524
642
  if (buttonMatchesTarget()) {
525
643
  return true;
526
644
  }
@@ -952,6 +1070,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
952
1070
  for (const menu of menus) {
953
1071
  const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
954
1072
  for (const option of buttons) {
1073
+ if (!isVisibleElement(option)) {
1074
+ continue;
1075
+ }
955
1076
  if (isNestedEffortControl(option, menu)) {
956
1077
  continue;
957
1078
  }
@@ -1038,6 +1159,33 @@ function buildModelSelectionExpression(targetModel, strategy) {
1038
1159
  dispatchHoverSequence(node);
1039
1160
  dispatchClickSequence(node);
1040
1161
  };
1162
+ const descendIntoAdvancedModelPicker = () => {
1163
+ const parentMenu = findUnifiedPickerMenu();
1164
+ if (!parentMenu) return 'unavailable';
1165
+
1166
+ const opener = findModelSubmenuOpener(parentMenu);
1167
+ if (opener) {
1168
+ if (advancedModelSignalMatchesTarget(parentMenu)) {
1169
+ return 'already-selected';
1170
+ }
1171
+ const key = submenuKey(
1172
+ normalizeText(opener.textContent ?? ''),
1173
+ opener.getAttribute?.('data-testid') ?? '',
1174
+ );
1175
+ openedSubmenuKeys.add(key);
1176
+ if (opener.getAttribute?.('aria-expanded') !== 'true') {
1177
+ openSubmenuOption(opener);
1178
+ }
1179
+ return 'opened';
1180
+ }
1181
+
1182
+ const advancedToggle = findAdvancedToggle(parentMenu);
1183
+ if (!advancedToggle) return 'unavailable';
1184
+ if (advancedToggle.getAttribute?.('aria-expanded') !== 'true') {
1185
+ dispatchClickSequence(advancedToggle);
1186
+ }
1187
+ return 'expanded';
1188
+ };
1041
1189
 
1042
1190
  return new Promise((resolve) => {
1043
1191
  const start = performance.now();
@@ -1124,6 +1272,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
1124
1272
  });
1125
1273
  return;
1126
1274
  }
1275
+ const advancedState = descendIntoAdvancedModelPicker();
1276
+ if (advancedState === 'already-selected') {
1277
+ const resolvedLabel = getResolvedLabel();
1278
+ closeMenu();
1279
+ resolve({ status: 'already-selected', label: resolvedLabel });
1280
+ return;
1281
+ }
1282
+ if (advancedState === 'expanded' || advancedState === 'opened') {
1283
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
1284
+ return;
1285
+ }
1127
1286
  if (performance.now() - start > MAX_WAIT_MS) {
1128
1287
  resolve({
1129
1288
  status: 'option-not-found',
@@ -31,7 +31,11 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
31
31
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
32
32
  const targetModelKind = inferThinkingTargetModelKind(desiredModel);
33
33
  const observedModelKind = result && "modelKind" in result ? result.modelKind : null;
34
- const strictProEffort = (targetModelKind === "pro" || observedModelKind === "pro") && level === "extended";
34
+ // Pro is expensive and rate-limited, so a Pro request must never degrade quietly
35
+ // into a cheaper tier. Requesting it explicitly (level "pro") fails closed on its
36
+ // own, independently of the legacy Pro-model + "extended" combination.
37
+ const strictProEffort = level === "pro" ||
38
+ ((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended");
35
39
  switch (result?.status) {
36
40
  case "already-selected":
37
41
  logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
@@ -53,18 +57,24 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
53
57
  : "";
54
58
  const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`;
55
59
  if (strictProEffort) {
56
- throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
60
+ const target = level === "pro" ? "Pro" : "Pro Extended";
61
+ throw new Error(`${message}; refusing to submit without confirmed ${target}.`);
57
62
  }
58
- // Nothing was clicked, so the tab keeps whatever effort it already had
59
- // which is not necessarily ChatGPT's default.
60
- logger(formatBrowserThinkingLog(`${message}; keeping the effort already selected in ChatGPT.`));
63
+ // "selection-unverified" is the one status here that already dispatched a
64
+ // click, so the effort may or may not have moved. Every other status left
65
+ // the tab on whatever effort it had which is not necessarily the default.
66
+ const outcome = result.status === "selection-unverified"
67
+ ? "the effort in ChatGPT is unconfirmed"
68
+ : "keeping the effort already selected in ChatGPT";
69
+ logger(formatBrowserThinkingLog(`${message}; ${outcome}.`));
61
70
  return;
62
71
  }
63
72
  default: {
64
73
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
65
74
  logPickerDiagnostic(result, logger);
66
75
  if (strictProEffort) {
67
- throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
76
+ const target = level === "pro" ? "Pro" : "Pro Extended";
77
+ throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`);
68
78
  }
69
79
  logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`));
70
80
  return;
@@ -74,7 +84,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
74
84
  /**
75
85
  * Best-effort selection of a thinking time level in ChatGPT's composer pill menu.
76
86
  * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
77
- * @param level - The thinking time intensity: 'light', 'standard', 'extended', 'extra-high', or 'heavy'
87
+ * @param level - The thinking time intensity: 'light', 'standard', 'extended', 'extra-high', 'pro', or 'heavy'
78
88
  */
79
89
  export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) {
80
90
  try {
@@ -137,15 +147,20 @@ function buildThinkingTimeExpression(level, desiredModel) {
137
147
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
138
148
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
139
149
 
140
- // Multilingual matchers: English level token + observed German/Chinese variants.
150
+ // Multilingual matchers: English level token + observed German/Japanese/Chinese variants.
141
151
  const LEVEL_TOKENS = {
142
- light: ['light', 'instant', 'sofort', 'leicht', '轻', '极速'],
143
- standard: ['standard', 'medium', 'mittel', '标准', '中'],
144
- extended: ['extended', 'high', 'hoch', 'erweitert', '扩展', '深度', '加强', '高'],
145
- 'extra-high': ['extra high', 'sehr hoch', '极高'],
152
+ light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'],
153
+ standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'],
154
+ extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'],
155
+ 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高'],
146
156
  heavy: ['heavy', 'schwer', '重度', '加重'],
147
157
  };
148
- const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
158
+ // Pro is a tier you can request, but it is also a MODEL name, so it must never
159
+ // be used to decide whether a control or a menu is an effort owner: a model pill
160
+ // reading "Pro" would be claimed as the effort pill, and a model menu listing
161
+ // "Instant"/"Pro" would look like a tier list. Keep it to target matching only.
162
+ const TARGET_LEVEL_TOKENS = { ...LEVEL_TOKENS, pro: ['pro'] };
163
+ const targetTokens = TARGET_LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
149
164
 
150
165
  const INITIAL_WAIT_MS = 150;
151
166
  const STEP_WAIT_MS = 200;
@@ -156,7 +171,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
156
171
  const INTELLIGENCE_WAIT_MS = 2500;
157
172
 
158
173
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
159
- // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
174
+ // Keep CJK characters, including Japanese kana, so localized labels survive
175
+ // normalization before being matched against LEVEL_TOKENS and picker controls.
160
176
  const normalize = (value) => (value || '')
161
177
  // Compose first so NFD umlauts fold too, then map them onto ASCII before
162
178
  // the strip below would drop them (and split the token in half).
@@ -166,7 +182,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
166
182
  .replace(/ö/g, 'o')
167
183
  .replace(/ü/g, 'u')
168
184
  .replace(/ß/g, 'ss')
169
- .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
185
+ .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ')
170
186
  .replace(/\\s+/g, ' ')
171
187
  .trim();
172
188
  const hasToken = (text, token) => normalize(text).split(' ').includes(token);
@@ -195,6 +211,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
195
211
  if (token === 'extra high') return hasPhrase(t, 'extra high');
196
212
  if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
197
213
  if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
214
+ if (token === '高い' || token === '非常に高い') {
215
+ return t === token || hasToken(t, token);
216
+ }
198
217
  if (token === '极速') {
199
218
  const suffix = t.slice(token.length);
200
219
  return t === token || hasToken(t, token) || /^[0-9]/.test(suffix);
@@ -372,7 +391,20 @@ function buildThinkingTimeExpression(level, desiredModel) {
372
391
  diagnostic: collectPickerDiagnostic(),
373
392
  });
374
393
  const findOptionInMenu = (menu, modelKindOverride = null) => {
375
- const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR));
394
+ // Container controls reveal other controls; they are not tiers you can pick.
395
+ // Two shapes exist and both can collide with a tier label: a submenu opener
396
+ // ("ModelGPT-5.6 Pro" would satisfy a Pro request) and a disclosure toggle
397
+ // (German "Erweitert" is literally one of the extended tokens, so the
398
+ // Advanced toggle would satisfy an extended request and be clicked in place
399
+ // of the tier). Detect them structurally rather than by label: a real tier row
400
+ // carries a checked state, while a container carries expansion state.
401
+ const isContainerControl = (node) =>
402
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
403
+ (node?.getAttribute?.('aria-expanded') !== null &&
404
+ node?.getAttribute?.('aria-checked') === null);
405
+ const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter(
406
+ (item) => !isContainerControl(item),
407
+ );
376
408
  const modelKind = modelKindOverride || effectiveTargetModelKind();
377
409
  if (modelKind === 'pro') {
378
410
  // GPT-5.6's unified Intelligence picker exposes Pro as the highest
@@ -414,7 +446,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
414
446
  const itemText = normalize(
415
447
  (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
416
448
  );
417
- if (modelKind !== 'pro' && hasToken(itemText, 'pro')) {
449
+ // Pro rows are skipped for non-Pro targets so "High" never resolves to
450
+ // "Pro". Two things lift this: an explicit TARGET_LEVEL of 'pro', and a
451
+ // legacy Pro-model menu (modelKind === 'pro'), whose rows are all Pro
452
+ // variants so excluding them would leave nothing to match.
453
+ if (TARGET_LEVEL !== 'pro' && modelKind !== 'pro' && hasToken(itemText, 'pro')) {
418
454
  continue;
419
455
  }
420
456
  if (
@@ -644,6 +680,122 @@ function buildThinkingTimeExpression(level, desiredModel) {
644
680
  return null;
645
681
  };
646
682
 
683
+ // ---------- Unified Intelligence picker: Advanced -> Effort submenu ----------
684
+ // A newer ChatGPT layout replaces the flat effort rows with a "power" slider
685
+ // (simple view) plus an "Advanced" view holding two submenu openers: Model and
686
+ // Effort. The tier rows only exist inside the Effort submenu, so the flat scan
687
+ // of the top-level menu finds nothing and we must expand and descend.
688
+ const ADVANCED_VIEW_SELECTOR = '[data-testid="composer-model-picker-slider-advanced-view"]';
689
+ const SUBMENU_OPENER_SELECTOR = '[role="menuitem"][aria-haspopup="menu"]';
690
+ const nodeLabel = (node) =>
691
+ normalize((node?.getAttribute?.('aria-label') ?? '') + ' ' + (node?.textContent ?? ''));
692
+ // Row labels in this menu concatenate without separators ("EffortHigh"), so
693
+ // token matching cannot be used here — substring is deliberate, as in
694
+ // countEffortLevels above.
695
+ const ADVANCED_WORDS = [
696
+ 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示',
697
+ 'avanzado', 'avancado', 'avance',
698
+ ];
699
+ const EFFORT_WORDS = [
700
+ 'effort', 'aufwand', '强度', '努力', '推論レベル',
701
+ 'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
702
+ ];
703
+ const containsAny = (label, words) => words.some((word) => label.includes(word));
704
+ const findAdvancedToggle = (menu) => {
705
+ for (const item of (menu || document).querySelectorAll('[role="menuitem"]')) {
706
+ if (!isVisible(item)) continue;
707
+ if (containsAny(nodeLabel(item), ADVANCED_WORDS)) return item;
708
+ }
709
+ return null;
710
+ };
711
+ // Verify the shape rather than trusting the selector: a tier row must never be
712
+ // mistaken for the opener, or hovering it would silently change the effort.
713
+ const isSubmenuOpener = (node) =>
714
+ node?.getAttribute?.('aria-haspopup') === 'menu' &&
715
+ (node?.getAttribute?.('role') ?? '') === 'menuitem';
716
+ // The Effort opener's label is the word "Effort" plus the tier it currently sits
717
+ // on ("EffortHigh"). Positive identification only: the sibling Model opener has
718
+ // the identical shape and can read "ModelGPT-5.6 Pro", so guessing from tier
719
+ // words would pick it and then click model rows as if they were efforts. An
720
+ // unrecognised language yields no opener, and the caller fails instead of
721
+ // gambling on the wrong control.
722
+ const findEffortSubmenuOpener = (menu) => {
723
+ const scope = menu?.querySelector?.(ADVANCED_VIEW_SELECTOR) || menu || document;
724
+ for (const item of scope.querySelectorAll(SUBMENU_OPENER_SELECTOR)) {
725
+ if (!isVisible(item) || !isSubmenuOpener(item)) continue;
726
+ if (containsAny(nodeLabel(item), EFFORT_WORDS)) return item;
727
+ }
728
+ return null;
729
+ };
730
+ // countEffortLevels reads aggregate menu text, where one "Extra High" row scores
731
+ // twice (once as "high", once as "extra high"). Count distinct levels across
732
+ // distinct rows instead, so ">= 2" really means two selectable tiers.
733
+ const countDistinctTierRows = (menu) => {
734
+ if (!menu) return 0;
735
+ const matched = new Set();
736
+ for (const row of menu.querySelectorAll(MENU_ITEM_SELECTOR)) {
737
+ if (isSubmenuOpener(row)) continue;
738
+ const text = nodeLabel(row);
739
+ for (const [level, tokens] of Object.entries(LEVEL_TOKENS)) {
740
+ if (matchesTokens(text, tokens)) matched.add(level);
741
+ }
742
+ }
743
+ return matched.size;
744
+ };
745
+ const resolveSubmenuFor = (opener, parentMenu) => {
746
+ const id = opener?.getAttribute?.('aria-controls');
747
+ if (id) {
748
+ const node = document.getElementById?.(id);
749
+ if (isVisible(node) && countDistinctTierRows(node) >= 2) return node;
750
+ }
751
+ let best = null;
752
+ for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) {
753
+ if (menu === parentMenu || menu.contains?.(opener) || !isVisible(menu)) continue;
754
+ const hits = countDistinctTierRows(menu);
755
+ if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits };
756
+ }
757
+ return best?.menu ?? null;
758
+ };
759
+ const selectEffortFromAdvancedSubmenu = async (parentMenu, modelKindOverride = null) => {
760
+ if (!parentMenu) return null;
761
+ // React can mount the advanced view well after the click under load, so poll
762
+ // for the opener to the same deadline the submenu gets instead of assuming it
763
+ // rendered within one STEP_WAIT_MS. Re-expand if the toggle collapses again.
764
+ let opener = null;
765
+ const openerDeadline = performance.now() + MAX_WAIT_MS;
766
+ while (!opener && performance.now() < openerDeadline) {
767
+ const toggle = findAdvancedToggle(parentMenu);
768
+ if (toggle && toggle.getAttribute?.('aria-expanded') === 'false') {
769
+ dispatchClickSequence(toggle);
770
+ await sleep(STEP_WAIT_MS);
771
+ }
772
+ opener = findEffortSubmenuOpener(parentMenu);
773
+ if (opener) break;
774
+ await sleep(100);
775
+ }
776
+ if (!opener) return null;
777
+ dispatchHoverSequence(opener);
778
+ if (opener.getAttribute?.('aria-expanded') !== 'true') {
779
+ dispatchClickSequence(opener);
780
+ }
781
+ const deadline = performance.now() + MAX_WAIT_MS;
782
+ while (performance.now() < deadline) {
783
+ const submenu = resolveSubmenuFor(opener, parentMenu);
784
+ if (submenu) {
785
+ return selectAndVerify(
786
+ opener,
787
+ () => {
788
+ const current = resolveSubmenuFor(opener, parentMenu);
789
+ return current ? findOptionInMenu(current, modelKindOverride) : null;
790
+ },
791
+ modelKindOverride,
792
+ );
793
+ }
794
+ await sleep(100);
795
+ }
796
+ return null;
797
+ };
798
+
647
799
  // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
648
800
  // controlled menu contains the effort levels. Prefer this ownership boundary
649
801
  // before probing older model-picker layouts.
@@ -740,9 +892,26 @@ function buildThinkingTimeExpression(level, desiredModel) {
740
892
  }
741
893
  if (composerEffortPill) {
742
894
  if (attemptedModelButton && attemptedModelButton !== composerEffortPill) closeOpenMenus();
895
+ // In the unified Intelligence picker the composer pill shows the current
896
+ // EFFORT ("Pro", "High"), not the model. Reading a Pro *model* out of it would
897
+ // lift the Pro-row exclusion in findOptionInMenu and let a lower-tier request
898
+ // settle on Pro. Only a pill naming a tier and nothing else qualifies: legacy
899
+ // pills read "Pro Extended" (model + effort) and must keep naming their model,
900
+ // or a Pro Extended user asking for extended would be moved down to High.
901
+ const pillLabel = normalize(
902
+ (composerEffortPill.getAttribute?.('aria-label') ?? '') +
903
+ ' ' +
904
+ (composerEffortPill.textContent ?? ''),
905
+ );
906
+ const pillIsBareEffortTier = Object.values(TARGET_LEVEL_TOKENS).some((tokens) =>
907
+ tokens.some((token) => normalize(token) === pillLabel),
908
+ );
909
+ const pillNamesEffortNotModel =
910
+ TARGET_IS_GPT56_MODEL ||
911
+ (pillIsBareEffortTier && Boolean(document.querySelector(INTELLIGENCE_MENU_SELECTOR)));
743
912
  const composerModelKind =
744
913
  TARGET_MODEL_KIND ||
745
- (TARGET_IS_GPT56_MODEL ? 'versioned' : modelKindFromNode(composerEffortPill));
914
+ (pillNamesEffortNotModel ? 'versioned' : modelKindFromNode(composerEffortPill));
746
915
  if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
747
916
  dispatchClickSequence(composerEffortPill);
748
917
  await sleep(INITIAL_WAIT_MS);
@@ -755,6 +924,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
755
924
  if (proEffortResult) {
756
925
  return proEffortResult;
757
926
  }
927
+ // Flat rows win when present; only descend into Advanced -> Effort when
928
+ // this menu has no matching tier of its own (the slider layout).
929
+ if (!findOptionInMenu(menu, composerModelKind)) {
930
+ const advancedResult = await selectEffortFromAdvancedSubmenu(menu, composerModelKind);
931
+ if (advancedResult) {
932
+ return advancedResult;
933
+ }
934
+ }
758
935
  return selectAndVerify(
759
936
  composerEffortPill,
760
937
  () => {