@steipete/oracle 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +22 -11
  2. package/dist/bin/oracle-cli.js +71 -71
  3. package/dist/src/browser/actions/assistantResponse.js +93 -10
  4. package/dist/src/browser/actions/deepResearch.js +180 -52
  5. package/dist/src/browser/actions/modelSelection.js +357 -40
  6. package/dist/src/browser/actions/navigation.js +294 -65
  7. package/dist/src/browser/actions/promptComposer.js +50 -4
  8. package/dist/src/browser/actions/thinkingTime.js +517 -105
  9. package/dist/src/browser/artifacts.js +19 -0
  10. package/dist/src/browser/chatgptFiles.js +797 -0
  11. package/dist/src/browser/chatgptImages.js +256 -27
  12. package/dist/src/browser/chromeLifecycle.js +4 -0
  13. package/dist/src/browser/config.js +2 -0
  14. package/dist/src/browser/index.js +404 -41
  15. package/dist/src/browser/pageActions.js +1 -1
  16. package/dist/src/browser/policies.js +2 -6
  17. package/dist/src/browser/projectSourcesRunner.js +16 -3
  18. package/dist/src/browser/prompt.js +161 -36
  19. package/dist/src/browser/reattach.js +6 -2
  20. package/dist/src/browser/reattachability.js +22 -10
  21. package/dist/src/browser/recoverConversation.js +73 -0
  22. package/dist/src/browser/sessionRunner.js +4 -1
  23. package/dist/src/cli/browserConfig.js +6 -2
  24. package/dist/src/cli/browserDefaults.js +2 -1
  25. package/dist/src/cli/browserTabs.js +129 -54
  26. package/dist/src/cli/followup.js +72 -0
  27. package/dist/src/cli/help.js +1 -1
  28. package/dist/src/cli/options.js +24 -0
  29. package/dist/src/cli/reattachGuidance.js +8 -0
  30. package/dist/src/cli/runOptions.js +2 -12
  31. package/dist/src/cli/sessionCommand.js +4 -0
  32. package/dist/src/cli/sessionDisplay.js +17 -3
  33. package/dist/src/cli/sessionLineage.js +7 -4
  34. package/dist/src/cli/sessionRunner.js +16 -1
  35. package/dist/src/gemini-web/client.js +5 -10
  36. package/dist/src/gemini-web/executor.js +1 -24
  37. package/dist/src/gemini-web/models.js +83 -0
  38. package/dist/src/mcp/server.js +2 -0
  39. package/dist/src/mcp/tools/chatgptImage.js +132 -0
  40. package/dist/src/mcp/tools/consult.js +270 -175
  41. package/dist/src/mcp/types.js +13 -2
  42. package/dist/src/mcp/utils.js +87 -1
  43. package/dist/src/oracle/config.js +27 -1
  44. package/dist/src/oracle/files.js +4 -6
  45. package/dist/src/oracle/geminiModels.js +2 -0
  46. package/dist/src/oracle/markdown.js +36 -3
  47. package/dist/src/oracle/modelResolver.js +21 -13
  48. package/dist/src/oracle/promptAssembly.js +2 -4
  49. package/dist/src/oracle/request.js +3 -5
  50. package/dist/src/oracle/thinkingTime.js +40 -0
  51. package/dist/src/oracle/tokenStats.js +5 -1
  52. package/dist/src/oracle.js +1 -1
  53. package/dist/src/sessionManager.js +1 -0
  54. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  55. package/package.json +47 -58
  56. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  57. package/dist/bin/oracle.js +0 -569
  58. package/dist/src/browser/chromeCookies.js +0 -312
  59. package/dist/src/browser/keytarShim.js +0 -56
  60. package/dist/src/browser/windowsCookies.js +0 -219
  61. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  62. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  63. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  64. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  65. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  68. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  71. package/vendor/oracle-notifier/README.md +0 -26
@@ -1,33 +1,51 @@
1
1
  import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
+ const BROWSER_THINKING_LOG_PREFIX = "[browser] Thinking time:";
5
+ function formatBrowserThinkingLog(message) {
6
+ return `${BROWSER_THINKING_LOG_PREFIX} ${message.replace(/^Thinking time:\s*/, "")}`;
7
+ }
4
8
  /**
5
- * Selects a specific thinking time level in ChatGPT's composer.
9
+ * Surfaces the model-picker snapshot captured alongside a failed detection.
6
10
  *
7
- * Best-effort: if the chip / menu / option is missing (e.g. ChatGPT moved the
8
- * effort selector into the per-model trailing button and we can't navigate it,
9
- * or the language pack uses tokens we don't yet match), we log a debug dump
10
- * and continue with whatever effort the UI defaults to.
11
+ * The browser prefix routes this through the session runner's non-verbose
12
+ * always-print path. The injected probe bounds and redacts all text values.
13
+ */
14
+ function logPickerDiagnostic(result, logger) {
15
+ const diagnostic = result && "diagnostic" in result
16
+ ? result.diagnostic
17
+ : undefined;
18
+ if (!diagnostic) {
19
+ return;
20
+ }
21
+ logger(`[browser] Model picker diagnostic: ${JSON.stringify(diagnostic)}`);
22
+ }
23
+ /**
24
+ * Selects a thinking-time level in ChatGPT's composer.
11
25
  *
12
- * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
26
+ * Missing controls remain best-effort except Pro Extended, which fails closed
27
+ * unless the selected option is confirmed.
13
28
  */
14
29
  export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
15
30
  const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
16
31
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
17
32
  const targetModelKind = inferThinkingTargetModelKind(desiredModel);
18
- const strictProEffort = targetModelKind === "pro" && level === "extended";
33
+ const observedModelKind = result && "modelKind" in result ? result.modelKind : null;
34
+ const strictProEffort = (targetModelKind === "pro" || observedModelKind === "pro") && level === "extended";
19
35
  switch (result?.status) {
20
36
  case "already-selected":
21
- logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
37
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
22
38
  return;
23
39
  case "switched":
24
- logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
40
+ logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
25
41
  return;
26
42
  case "chip-not-found":
27
43
  case "menu-not-found":
28
44
  case "option-not-found":
45
+ case "selection-unverified":
29
46
  case "model-kind-not-found": {
30
47
  await logDomFailure(Runtime, logger, `thinking-${result.status}`);
48
+ logPickerDiagnostic(result, logger);
31
49
  const kindHint = result.status === "model-kind-not-found" && result.modelKind
32
50
  ? ` for ${result.modelKind}`
33
51
  : targetModelKind
@@ -37,15 +55,16 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
37
55
  if (strictProEffort) {
38
56
  throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
39
57
  }
40
- logger(`${message}; continuing with ChatGPT default.`);
58
+ logger(formatBrowserThinkingLog(`${message}; continuing with ChatGPT default.`));
41
59
  return;
42
60
  }
43
61
  default: {
44
62
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
63
+ logPickerDiagnostic(result, logger);
45
64
  if (strictProEffort) {
46
65
  throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
47
66
  }
48
- logger(`Thinking time: unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`);
67
+ logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`));
49
68
  return;
50
69
  }
51
70
  }
@@ -61,22 +80,23 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
61
80
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
62
81
  switch (result?.status) {
63
82
  case "already-selected":
64
- logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
83
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
65
84
  return true;
66
85
  case "switched":
67
- logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
86
+ logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
68
87
  return true;
69
88
  case "chip-not-found":
70
89
  case "menu-not-found":
71
90
  case "option-not-found":
91
+ case "selection-unverified":
72
92
  case "model-kind-not-found":
73
93
  if (logger.verbose) {
74
- logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
94
+ logger(formatBrowserThinkingLog(`${result.status.replaceAll("-", " ")}; continuing with default.`));
75
95
  }
76
96
  return false;
77
97
  default:
78
98
  if (logger.verbose) {
79
- logger("Thinking time: unknown outcome; continuing with default.");
99
+ logger(formatBrowserThinkingLog("unknown outcome; continuing with default."));
80
100
  }
81
101
  return false;
82
102
  }
@@ -84,7 +104,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
84
104
  catch (error) {
85
105
  const message = error instanceof Error ? error.message : String(error);
86
106
  if (logger.verbose) {
87
- logger(`Thinking time selection failed (${message}); continuing with default.`);
107
+ logger(formatBrowserThinkingLog(`selection failed (${message}); continuing with default.`));
88
108
  await logDomFailure(Runtime, logger, "thinking-time");
89
109
  }
90
110
  return false;
@@ -115,16 +135,20 @@ function buildThinkingTimeExpression(level, desiredModel) {
115
135
 
116
136
  // Bilingual matchers: English level token + observed Chinese variants.
117
137
  const LEVEL_TOKENS = {
118
- light: ['light', '轻'],
119
- standard: ['standard', '标准'],
120
- extended: ['extended', '扩展', '深度', '加强'],
121
- heavy: ['heavy', '重度', '加重', '高'],
138
+ light: ['light', 'instant', '轻'],
139
+ standard: ['standard', 'medium', '标准'],
140
+ extended: ['extended', 'high', '扩展', '深度', '加强'],
141
+ heavy: ['heavy', 'extra high', '重度', '加重', '高'],
122
142
  };
123
143
  const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
124
144
 
125
145
  const INITIAL_WAIT_MS = 150;
126
146
  const STEP_WAIT_MS = 200;
127
147
  const MAX_WAIT_MS = 8000;
148
+ // The "Intelligence" menu renders right after opening the composer pill, so
149
+ // a short probe is enough; if it's absent this is an older UI and we fall
150
+ // back to the legacy paths without paying the full MAX_WAIT_MS.
151
+ const INTELLIGENCE_WAIT_MS = 2500;
128
152
 
129
153
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
130
154
  // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
@@ -133,17 +157,51 @@ function buildThinkingTimeExpression(level, desiredModel) {
133
157
  .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
134
158
  .replace(/\\s+/g, ' ')
135
159
  .trim();
160
+ const hasToken = (text, token) => normalize(text).split(' ').includes(token);
136
161
  const matchesLevel = (text) => {
137
162
  const t = normalize(text);
138
- return targetTokens.some((tok) => t.includes(String(tok).toLowerCase()));
163
+ if (!t) return false;
164
+ return targetTokens.some((tok) => {
165
+ const token = normalize(tok);
166
+ if (!token) return false;
167
+ if (token === 'high') return hasToken(t, 'high') && !hasToken(t, 'extra');
168
+ if (token === 'extra high') return hasToken(t, 'extra') && hasToken(t, 'high');
169
+ return t === token || hasToken(t, token) || t.includes(token);
170
+ });
171
+ };
172
+ const matchesAnyEffortLevel = (text) => {
173
+ const normalizedText = normalize(text);
174
+ if (!normalizedText) return false;
175
+ for (const tokens of Object.values(LEVEL_TOKENS)) {
176
+ for (const rawToken of tokens) {
177
+ const token = normalize(rawToken);
178
+ if (!token) continue;
179
+ if (token.includes(' ')) {
180
+ if (token.split(' ').every((part) => hasToken(normalizedText, part))) return true;
181
+ } else if (/^[a-z0-9]+$/.test(token)) {
182
+ if (hasToken(normalizedText, token)) return true;
183
+ } else if (normalizedText.includes(token)) {
184
+ return true;
185
+ }
186
+ }
187
+ }
188
+ return false;
139
189
  };
140
- const hasToken = (text, token) => normalize(text).split(' ').includes(token);
141
190
  const optionIsSelected = (node) => {
142
191
  if (!(node instanceof HTMLElement)) return false;
143
192
  const ariaChecked = node.getAttribute('aria-checked');
193
+ const ariaSelected = node.getAttribute('aria-selected');
194
+ const ariaCurrent = node.getAttribute('aria-current');
195
+ const dataSelected = node.getAttribute('data-selected');
144
196
  const dataState = (node.getAttribute('data-state') || '').toLowerCase();
145
- if (ariaChecked === 'true') return true;
146
- return dataState === 'checked' || dataState === 'selected' || dataState === 'on';
197
+ if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') return true;
198
+ return (
199
+ dataSelected === 'true' ||
200
+ dataState === 'checked' ||
201
+ dataState === 'selected' ||
202
+ dataState === 'on' ||
203
+ dataState === 'true'
204
+ );
147
205
  };
148
206
  const closeOpenMenus = () => {
149
207
  try {
@@ -152,37 +210,166 @@ function buildThinkingTimeExpression(level, desiredModel) {
152
210
  );
153
211
  } catch {}
154
212
  };
213
+ const dispatchHoverSequence = (target) => {
214
+ if (!target || !(target instanceof EventTarget)) return false;
215
+ const types = ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove'];
216
+ for (const type of types) {
217
+ try {
218
+ const common = { bubbles: true, cancelable: true, view: window };
219
+ const event =
220
+ type.startsWith('pointer') && 'PointerEvent' in window
221
+ ? new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' })
222
+ : new MouseEvent(type, common);
223
+ target.dispatchEvent(event);
224
+ } catch {}
225
+ }
226
+ try {
227
+ target.focus?.();
228
+ } catch {}
229
+ return true;
230
+ };
155
231
 
156
- // ---------- OLD UI: standalone composer chip labelled "Thinking" ----------
157
- const OLD_CHIP_SELECTORS = [
158
- '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
159
- '.__composer-pill-composite button[aria-haspopup="menu"]',
160
- ];
161
- const findOldChip = () => {
162
- for (const selector of OLD_CHIP_SELECTORS) {
163
- for (const btn of document.querySelectorAll(selector)) {
164
- if (btn.getAttribute?.('aria-haspopup') !== 'menu') continue;
165
- // The new model picker pill also reuses .__composer-pill skip it.
166
- if (btn.matches?.(MODEL_BUTTON_SELECTOR)) continue;
167
- const aria = normalize(btn.getAttribute?.('aria-label') ?? '');
168
- const text = normalize(btn.textContent ?? '');
169
- if (aria.includes('thinking') || text.includes('thinking')) return btn;
232
+ const TRAILING_SELECTOR = '[data-model-picker-thinking-effort-action="true"]';
233
+ const INTELLIGENCE_MENU_SELECTOR = '[data-testid="composer-intelligence-picker-content"]';
234
+ const PRO_EFFORT_TRIGGER_SELECTOR = '[data-testid="composer-intelligence-pro-thinking-effort-trigger"]';
235
+
236
+ const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
237
+ const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
238
+ const KIND_NOT_FOUND = { kindNotFound: true };
239
+
240
+ const isVisible = (node) => {
241
+ if (!node || node.getAttribute?.('aria-hidden') === 'true') return false;
242
+ const rect = node.getBoundingClientRect?.();
243
+ return Boolean(rect && rect.width > 0 && rect.height > 0);
244
+ };
245
+ const redactDiagnosticText = (value, maxLength = 120) =>
246
+ String(value ?? '')
247
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi, '[redacted-email]')
248
+ .replace(/\\b[A-Za-z0-9_-]{32,}\\b/g, '[redacted]')
249
+ .replace(/\\s+/g, ' ')
250
+ .trim()
251
+ .slice(0, maxLength);
252
+ const describeNode = (el) => {
253
+ if (!el || typeof el.getAttribute !== 'function') return null;
254
+ let rect = null;
255
+ try {
256
+ const r = el.getBoundingClientRect?.();
257
+ if (r) {
258
+ rect = {
259
+ w: Math.round(r.width),
260
+ h: Math.round(r.height),
261
+ visible: r.width > 0 && r.height > 0,
262
+ };
170
263
  }
264
+ } catch {}
265
+ return {
266
+ tag: el.tagName || null,
267
+ testid: el.getAttribute('data-testid'),
268
+ role: el.getAttribute('role'),
269
+ ariaLabel: redactDiagnosticText(el.getAttribute('aria-label')),
270
+ ariaExpanded: el.getAttribute('aria-expanded'),
271
+ ariaChecked: el.getAttribute('aria-checked'),
272
+ ariaSelected: el.getAttribute('aria-selected'),
273
+ ariaHaspopup: el.getAttribute('aria-haspopup'),
274
+ dataState: el.getAttribute('data-state'),
275
+ text: redactDiagnosticText(el.textContent, 80),
276
+ rect,
277
+ };
278
+ };
279
+ const describeMenu = (menu) => {
280
+ if (!menu || typeof menu.querySelectorAll !== 'function') return null;
281
+ const items = Array.from(
282
+ menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"], button, [data-testid]'),
283
+ )
284
+ .slice(0, 30)
285
+ .map(describeNode);
286
+ return {
287
+ role: menu.getAttribute?.('role') ?? null,
288
+ testid: menu.getAttribute?.('data-testid') ?? null,
289
+ itemCount: items.length,
290
+ items,
291
+ };
292
+ };
293
+ const collectPickerDiagnostic = () => {
294
+ try {
295
+ const trailings = findTrailingButtons();
296
+ const switchers = Array.from(document.querySelectorAll('[data-testid*="model-switcher"]'));
297
+ const composerButtons = Array.from(
298
+ document.querySelectorAll(
299
+ 'form button[aria-haspopup="menu"], [data-testid="model-switcher-dropdown-button"]',
300
+ ),
301
+ );
302
+ const menus = Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).filter(
303
+ isVisible,
304
+ );
305
+ const modelBtn = findModelButton();
306
+ return {
307
+ targetModelKind: TARGET_MODEL_KIND,
308
+ targetLevel: TARGET_LEVEL,
309
+ modelButton: describeNode(modelBtn),
310
+ composerButtons: composerButtons.slice(0, 12).map(describeNode),
311
+ trailingCount: trailings.length,
312
+ trailings: trailings.slice(0, 12).map(describeNode),
313
+ modelSwitcherCount: switchers.length,
314
+ modelSwitcher: switchers.slice(0, 12).map(describeNode),
315
+ menuCount: menus.length,
316
+ menus: menus.slice(0, 4).map(describeMenu),
317
+ };
318
+ } catch (err) {
319
+ return { error: redactDiagnosticText(err && err.message ? err.message : err) };
171
320
  }
321
+ };
322
+ const modelKindFromNode = (button) => {
323
+ const label = normalize(
324
+ (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''),
325
+ );
326
+ if (hasToken(label, 'pro')) return 'pro';
327
+ if (hasToken(label, 'thinking')) return 'thinking';
328
+ if (hasToken(label, 'instant')) return 'instant';
172
329
  return null;
173
330
  };
174
- const findOldEffortMenu = () => {
175
- const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
176
- for (const menu of menus) {
177
- const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
178
- if (normalize(label?.textContent ?? '').includes('thinking time')) return menu;
179
- const text = normalize(menu.textContent ?? '');
180
- if (text.includes('standard') && text.includes('extended')) return menu;
331
+ const currentModelKind = () => modelKindFromNode(findModelButton());
332
+ const effectiveTargetModelKind = () => TARGET_MODEL_KIND || currentModelKind();
333
+ const isIntelligenceEffortMenu = (menu) => {
334
+ if (menu?.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') {
335
+ return true;
181
336
  }
182
- return null;
337
+ const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]');
338
+ return normalize(label?.textContent ?? '').includes('intelligence');
183
339
  };
184
- const findOptionInMenu = (menu) => {
185
- for (const item of menu.querySelectorAll(MENU_ITEM_SELECTOR)) {
340
+ const failure = (status, extra = {}) => ({
341
+ status,
342
+ modelKind: effectiveTargetModelKind(),
343
+ ...extra,
344
+ diagnostic: collectPickerDiagnostic(),
345
+ });
346
+ const findOptionInMenu = (menu, modelKindOverride = null) => {
347
+ const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR));
348
+ const modelKind = modelKindOverride || effectiveTargetModelKind();
349
+ if (modelKind === 'pro') {
350
+ for (const item of items) {
351
+ const itemText = normalize(
352
+ (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
353
+ );
354
+ if (
355
+ hasToken(itemText, 'pro') &&
356
+ (matchesLevel(item.textContent ?? '') ||
357
+ matchesLevel(item.getAttribute?.('aria-label') ?? ''))
358
+ ) {
359
+ return item;
360
+ }
361
+ }
362
+ if (isIntelligenceEffortMenu(menu)) {
363
+ return null;
364
+ }
365
+ }
366
+ for (const item of items) {
367
+ const itemText = normalize(
368
+ (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
369
+ );
370
+ if (modelKind && modelKind !== 'pro' && hasToken(itemText, 'pro')) {
371
+ continue;
372
+ }
186
373
  if (
187
374
  matchesLevel(item.textContent ?? '') ||
188
375
  matchesLevel(item.getAttribute?.('aria-label') ?? '')
@@ -192,41 +379,241 @@ function buildThinkingTimeExpression(level, desiredModel) {
192
379
  }
193
380
  return null;
194
381
  };
382
+ const countEffortLevels = (menu) => {
383
+ const text = normalize(menu?.textContent ?? '');
384
+ let hits = 0;
385
+ for (const tokens of Object.values(LEVEL_TOKENS)) {
386
+ if (tokens.some((token) => text.includes(String(token).toLowerCase()))) hits += 1;
387
+ }
388
+ return hits;
389
+ };
390
+ const isEffortMenu = (menu) => {
391
+ if (!isVisible(menu)) return false;
392
+ if (menu.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') return true;
393
+ const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
394
+ const labelText = normalize(label?.textContent ?? '');
395
+ return (
396
+ labelText.includes('intelligence') ||
397
+ labelText.includes('thinking time') ||
398
+ labelText.includes('thinking effort') ||
399
+ countEffortLevels(menu) >= 2
400
+ );
401
+ };
402
+ const isProEffortMenu = (menu) => {
403
+ if (!isVisible(menu)) return false;
404
+ const text = normalize(menu?.textContent ?? '');
405
+ return text.includes('pro standard') && text.includes('pro extended');
406
+ };
407
+ const controlledMenu = (trigger) => {
408
+ const id = trigger?.getAttribute?.('aria-controls');
409
+ if (!id) return null;
410
+ const menu = document.getElementById?.(id);
411
+ return isEffortMenu(menu) ? menu : null;
412
+ };
413
+ const findVisibleEffortMenu = (trigger) => {
414
+ const controlled = controlledMenu(trigger);
415
+ if (controlled) return controlled;
416
+ for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) {
417
+ if (isEffortMenu(menu)) return menu;
418
+ }
419
+ return null;
420
+ };
421
+ const controlledProEffortMenu = (trigger) => {
422
+ const id = trigger?.getAttribute?.('aria-controls');
423
+ if (!id) return null;
424
+ const menu = document.getElementById?.(id);
425
+ return isProEffortMenu(menu) ? menu : null;
426
+ };
427
+ const findVisibleProEffortMenu = (trigger) => {
428
+ const controlled = controlledProEffortMenu(trigger);
429
+ if (controlled) return controlled;
430
+ for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) {
431
+ if (isProEffortMenu(menu)) return menu;
432
+ }
433
+ return null;
434
+ };
435
+ const matchesProEffortLevel = (node) => {
436
+ const text = normalize(
437
+ (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
438
+ );
439
+ if (TARGET_LEVEL === 'standard') {
440
+ return text.includes('pro') && text.includes('standard');
441
+ }
442
+ if (TARGET_LEVEL === 'extended') {
443
+ return text.includes('pro') && text.includes('extended');
444
+ }
445
+ return false;
446
+ };
447
+ const findProEffortOptionInMenu = (menu) => {
448
+ for (const item of menu.querySelectorAll(MENU_ITEM_SELECTOR)) {
449
+ if (matchesProEffortLevel(item)) return item;
450
+ }
451
+ return null;
452
+ };
453
+ const currentProEffortPillMatchesTarget = (trigger, modelKindOverride = null) => {
454
+ const button = trigger?.matches?.('button.__composer-pill') ? trigger : findModelButton();
455
+ if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) !== 'pro') {
456
+ return false;
457
+ }
458
+ const label = normalize(button?.textContent ?? '');
459
+ if (TARGET_LEVEL === 'standard') {
460
+ return hasToken(label, 'pro') && !hasToken(label, 'extended');
461
+ }
462
+ if (TARGET_LEVEL === 'extended') {
463
+ return hasToken(label, 'pro') && hasToken(label, 'extended');
464
+ }
465
+ return false;
466
+ };
467
+ const currentEffortPillMatchesTarget = (trigger, modelKindOverride = null) => {
468
+ if (currentProEffortPillMatchesTarget(trigger, modelKindOverride)) return true;
469
+ const button = trigger?.matches?.('button.__composer-pill') ? trigger : findModelButton();
470
+ if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') {
471
+ return false;
472
+ }
473
+ const label = (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? '');
474
+ return matchesLevel(label);
475
+ };
476
+ const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => {
477
+ const option = findOption();
478
+ const triggerModelKind =
479
+ modelKindOverride ||
480
+ TARGET_MODEL_KIND ||
481
+ modelKindFromNode(trigger) ||
482
+ effectiveTargetModelKind();
483
+ if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
484
+ const label = option.textContent?.trim?.() || null;
485
+ if (optionIsSelected(option)) {
486
+ closeOpenMenus();
487
+ return { status: 'already-selected', label };
488
+ }
195
489
 
196
- const oldChip = findOldChip();
197
- if (oldChip) {
198
- dispatchClickSequence(oldChip);
199
- const start = performance.now();
200
- while (performance.now() - start < MAX_WAIT_MS) {
201
- await sleep(100);
202
- const menu = findOldEffortMenu();
203
- if (!menu) continue;
204
- const opt = findOptionInMenu(menu);
205
- if (!opt) {
490
+ dispatchClickSequence(option);
491
+ await sleep(STEP_WAIT_MS);
492
+ const refreshed = findOption();
493
+ if (refreshed && optionIsSelected(refreshed)) {
494
+ closeOpenMenus();
495
+ return { status: 'switched', label: refreshed.textContent?.trim?.() || label };
496
+ }
497
+ if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) {
498
+ closeOpenMenus();
499
+ return { status: 'switched', label };
500
+ }
501
+
502
+ if (!refreshed && trigger && trigger.getAttribute?.('aria-expanded') !== 'true') {
503
+ dispatchClickSequence(trigger);
504
+ await sleep(INITIAL_WAIT_MS);
505
+ }
506
+ const deadline = performance.now() + 2000;
507
+ while (performance.now() < deadline) {
508
+ const selected = findOption();
509
+ if (selected && optionIsSelected(selected)) {
206
510
  closeOpenMenus();
207
- return { status: 'option-not-found' };
511
+ return { status: 'switched', label: selected.textContent?.trim?.() || label };
208
512
  }
209
- const already = optionIsSelected(opt);
210
- const label = opt.textContent?.trim?.() || null;
211
- dispatchClickSequence(opt);
212
- await sleep(STEP_WAIT_MS);
213
- closeOpenMenus();
214
- return { status: already ? 'already-selected' : 'switched', label };
513
+ if (currentEffortPillMatchesTarget(trigger, triggerModelKind)) {
514
+ closeOpenMenus();
515
+ return { status: 'switched', label };
516
+ }
517
+ await sleep(100);
215
518
  }
519
+ const result = failure('selection-unverified', { modelKind: triggerModelKind });
216
520
  closeOpenMenus();
217
- return { status: 'menu-not-found' };
218
- }
521
+ return result;
522
+ };
523
+ const selectProEffortFromSubmenu = async () => {
524
+ if (TARGET_MODEL_KIND !== 'pro' || (TARGET_LEVEL !== 'standard' && TARGET_LEVEL !== 'extended')) {
525
+ return null;
526
+ }
527
+ const trigger = document.querySelector(PRO_EFFORT_TRIGGER_SELECTOR);
528
+ if (!trigger) {
529
+ return null;
530
+ }
531
+ dispatchHoverSequence(trigger);
532
+ if (trigger.getAttribute?.('aria-expanded') !== 'true') {
533
+ dispatchClickSequence(trigger);
534
+ }
535
+ const deadline = performance.now() + MAX_WAIT_MS;
536
+ while (performance.now() < deadline) {
537
+ const menu = findVisibleProEffortMenu(trigger);
538
+ if (menu) {
539
+ return selectAndVerify(trigger, () => {
540
+ const currentMenu = findVisibleProEffortMenu(trigger);
541
+ return currentMenu ? findProEffortOptionInMenu(currentMenu) : null;
542
+ });
543
+ }
544
+ await sleep(100);
545
+ }
546
+ return null;
547
+ };
219
548
 
220
- // ---------- NEW UI: thinking effort lives inside the model picker ----------
221
- // Each eligible model row carries a trailing button:
222
- // [data-model-picker-thinking-effort-action="true"] (role="menuitem", aria-haspopup="menu")
223
- // Clicking it expands a submenu of effort options. We use aria-controls to
224
- // resolve the submenu deterministically rather than scoring menu contents.
225
- const TRAILING_SELECTOR = '[data-model-picker-thinking-effort-action="true"]';
549
+ // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
550
+ // controlled menu contains the effort levels. Prefer this ownership boundary
551
+ // before probing older model-picker layouts.
552
+ const COMPOSER_EFFORT_PILL_SELECTORS = [
553
+ 'form button.__composer-pill',
554
+ '[data-testid="composer-footer-actions"] button.__composer-pill',
555
+ '.__composer-pill-composite button.__composer-pill',
556
+ ];
557
+ const findComposerEffortPill = () => {
558
+ const seen = new Set();
559
+ for (const selector of COMPOSER_EFFORT_PILL_SELECTORS) {
560
+ for (const button of document.querySelectorAll(selector)) {
561
+ if (seen.has(button) || !isVisible(button)) continue;
562
+ seen.add(button);
563
+ if (button.getAttribute?.('data-testid') === 'model-switcher-dropdown-button') continue;
564
+ const label = normalize(
565
+ (button.getAttribute?.('aria-label') ?? '') + ' ' +
566
+ (button.getAttribute?.('data-testid') ?? '') + ' ' +
567
+ (button.textContent ?? ''),
568
+ );
569
+ if (
570
+ (TARGET_MODEL_KIND === 'pro' && hasToken(label, 'pro') && !hasToken(label, 'thinking')) ||
571
+ (TARGET_MODEL_KIND === 'thinking' && hasToken(label, 'thinking') && !hasToken(label, 'pro')) ||
572
+ (!TARGET_MODEL_KIND && hasToken(label, 'thinking')) ||
573
+ (button.matches?.('button.__composer-pill') && matchesAnyEffortLevel(label))
574
+ ) {
575
+ return button;
576
+ }
577
+ }
578
+ }
579
+ return null;
580
+ };
226
581
 
227
- const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
228
- const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
229
- const KIND_NOT_FOUND = { kindNotFound: true };
582
+ const composerEffortPill = findComposerEffortPill();
583
+ if (composerEffortPill) {
584
+ const composerModelKind = TARGET_MODEL_KIND || modelKindFromNode(composerEffortPill);
585
+ if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
586
+ dispatchClickSequence(composerEffortPill);
587
+ await sleep(INITIAL_WAIT_MS);
588
+ }
589
+ const deadline = performance.now() + MAX_WAIT_MS;
590
+ while (performance.now() < deadline) {
591
+ const menu = findVisibleEffortMenu(composerEffortPill);
592
+ if (menu) {
593
+ const proEffortResult = await selectProEffortFromSubmenu();
594
+ if (proEffortResult) {
595
+ return proEffortResult;
596
+ }
597
+ return selectAndVerify(
598
+ composerEffortPill,
599
+ () => {
600
+ const currentMenu = findVisibleEffortMenu(composerEffortPill);
601
+ return currentMenu ? findOptionInMenu(currentMenu, composerModelKind) : null;
602
+ },
603
+ composerModelKind,
604
+ );
605
+ }
606
+ await sleep(100);
607
+ }
608
+ const result = failure('menu-not-found', {
609
+ modelKind: composerModelKind,
610
+ });
611
+ closeOpenMenus();
612
+ return result;
613
+ }
614
+
615
+ // Older ChatGPT layouts attach effort controls to rows inside the model
616
+ // picker. Keep these compatibility paths after the standalone pill owner.
230
617
  const findEffortRow = (node) => {
231
618
  let current = node instanceof HTMLElement ? node.parentElement : null;
232
619
  while (current && current !== document.body) {
@@ -291,12 +678,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
291
678
  }
292
679
  return false;
293
680
  };
294
- const hasStableBox = (node) => {
295
- const r = node.getBoundingClientRect?.();
296
- return Boolean(r && r.width > 0 && r.height > 0 && node.getAttribute?.('aria-hidden') !== 'true');
297
- };
298
681
  const pickSingleStableTrailing = (trailings) => {
299
- const visible = trailings.filter((t) => hasStableBox(t));
682
+ const visible = trailings.filter((trailing) => isVisible(trailing));
300
683
  return visible.length === 1 ? visible[0] : null;
301
684
  };
302
685
  const pickTrailingForCurrentModel = () => {
@@ -317,7 +700,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
317
700
 
318
701
  const modelBtn = findModelButton();
319
702
  if (!modelBtn) {
320
- return { status: 'chip-not-found' };
703
+ return failure('chip-not-found');
321
704
  }
322
705
  // Open model menu (idempotent — leaves it open if already open).
323
706
  if (modelBtn.getAttribute('aria-expanded') !== 'true') {
@@ -325,6 +708,43 @@ function buildThinkingTimeExpression(level, desiredModel) {
325
708
  await sleep(INITIAL_WAIT_MS);
326
709
  }
327
710
 
711
+ // ---------- COMPATIBILITY UI: unified "Intelligence" effort picker ----------
712
+ // One observed ChatGPT layout replaced the per-model trailing buttons with a single
713
+ // "Intelligence" menu ([data-testid="composer-intelligence-picker-content"]),
714
+ // whose role="menuitemradio" rows are the effort tiers. We verify the checked
715
+ // radio instead of trusting the composer-pill label; non-Pro targets also
716
+ // explicitly skip Pro rows before matching effort labels.
717
+ if (TARGET_MODEL_KIND === 'pro' && TARGET_LEVEL === 'extended') {
718
+ const matchesProExtended = (node) => {
719
+ const text = normalize(
720
+ (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
721
+ );
722
+ return text.includes('pro') && text.includes('extended');
723
+ };
724
+ const findProExtendedOption = () => {
725
+ const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR);
726
+ if (!isVisible(menu)) return null;
727
+ for (const item of menu.querySelectorAll(
728
+ '[role="menuitemradio"], [role="menuitem"], [role="option"]',
729
+ )) {
730
+ if (matchesProExtended(item)) return item;
731
+ }
732
+ return null;
733
+ };
734
+ let proExtended = null;
735
+ const intelligenceDeadline = performance.now() + INTELLIGENCE_WAIT_MS;
736
+ while (performance.now() < intelligenceDeadline) {
737
+ proExtended = findProExtendedOption();
738
+ if (proExtended) break;
739
+ await sleep(100);
740
+ }
741
+ if (proExtended) {
742
+ return selectAndVerify(modelBtn, findProExtendedOption);
743
+ }
744
+ // Intelligence menu absent (older UI) or its Pro Extended row is missing:
745
+ // fall through to the legacy trailing-button path below.
746
+ }
747
+
328
748
  let trailing = null;
329
749
  const trailingDeadline = performance.now() + MAX_WAIT_MS;
330
750
  while (performance.now() < trailingDeadline) {
@@ -333,12 +753,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
333
753
  await sleep(100);
334
754
  }
335
755
  if (!trailing) {
756
+ const result = failure('chip-not-found');
336
757
  closeOpenMenus();
337
- return { status: 'chip-not-found' };
758
+ return result;
338
759
  }
339
760
  if (trailing.kindNotFound) {
761
+ const result = failure('model-kind-not-found', { modelKind: TARGET_MODEL_KIND });
340
762
  closeOpenMenus();
341
- return { status: 'model-kind-not-found', modelKind: TARGET_MODEL_KIND };
763
+ return result;
342
764
  }
343
765
 
344
766
  dispatchClickSequence(trailing);
@@ -349,18 +771,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
349
771
  const resolveEffortMenu = () => {
350
772
  const id = trailing.getAttribute('aria-controls');
351
773
  if (id) {
352
- const node = document.getElementById(id);
353
- if (node) return node;
774
+ const node = document.getElementById?.(id);
775
+ if (isEffortMenu(node)) return node;
354
776
  }
355
- const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
777
+ const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR);
356
778
  let best = null;
357
779
  for (const menu of menus) {
358
780
  if (menu === modelBtn || menu.contains(trailing)) continue;
359
- const text = normalize(menu.textContent ?? '');
360
- let hits = 0;
361
- for (const tokens of Object.values(LEVEL_TOKENS)) {
362
- if (tokens.some((tok) => text.includes(String(tok).toLowerCase()))) hits += 1;
363
- }
781
+ if (!isVisible(menu)) continue;
782
+ const hits = countEffortLevels(menu);
364
783
  if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits };
365
784
  }
366
785
  return best?.menu ?? null;
@@ -374,22 +793,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
374
793
  await sleep(100);
375
794
  }
376
795
  if (!effortMenu) {
796
+ const result = failure('menu-not-found');
377
797
  closeOpenMenus();
378
- return { status: 'menu-not-found' };
379
- }
380
-
381
- const targetOption = findOptionInMenu(effortMenu);
382
- if (!targetOption) {
383
- closeOpenMenus();
384
- return { status: 'option-not-found' };
798
+ return result;
385
799
  }
386
800
 
387
- const already = optionIsSelected(targetOption);
388
- const label = targetOption.textContent?.trim?.() || null;
389
- dispatchClickSequence(targetOption);
390
- await sleep(STEP_WAIT_MS);
391
- closeOpenMenus();
392
- return { status: already ? 'already-selected' : 'switched', label };
801
+ return selectAndVerify(trailing, () => {
802
+ const currentMenu = resolveEffortMenu();
803
+ return currentMenu ? findOptionInMenu(currentMenu) : null;
804
+ });
393
805
  })()`;
394
806
  }
395
807
  export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) {