@steipete/oracle 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +20 -9
  2. package/dist/bin/oracle-cli.js +63 -63
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +400 -0
  9. package/dist/docs-site/browser-mode.html +593 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +452 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +387 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +91 -9
  39. package/dist/src/browser/actions/deepResearch.js +180 -52
  40. package/dist/src/browser/actions/modelSelection.js +13 -11
  41. package/dist/src/browser/actions/navigation.js +294 -65
  42. package/dist/src/browser/actions/promptComposer.js +50 -4
  43. package/dist/src/browser/actions/thinkingTime.js +308 -89
  44. package/dist/src/browser/artifacts.js +19 -0
  45. package/dist/src/browser/chatgptFiles.js +797 -0
  46. package/dist/src/browser/chatgptImages.js +130 -3
  47. package/dist/src/browser/chromeLifecycle.js +4 -0
  48. package/dist/src/browser/config.js +2 -0
  49. package/dist/src/browser/index.js +347 -39
  50. package/dist/src/browser/pageActions.js +1 -1
  51. package/dist/src/browser/policies.js +2 -6
  52. package/dist/src/browser/projectSourcesRunner.js +14 -2
  53. package/dist/src/browser/prompt.js +11 -15
  54. package/dist/src/browser/reattach.js +6 -2
  55. package/dist/src/browser/reattachability.js +22 -10
  56. package/dist/src/browser/recoverConversation.js +73 -0
  57. package/dist/src/browser/sessionRunner.js +4 -1
  58. package/dist/src/cli/browserConfig.js +4 -1
  59. package/dist/src/cli/browserTabs.js +129 -54
  60. package/dist/src/cli/followup.js +72 -0
  61. package/dist/src/cli/help.js +1 -1
  62. package/dist/src/cli/options.js +16 -0
  63. package/dist/src/cli/reattachGuidance.js +8 -0
  64. package/dist/src/cli/runOptions.js +2 -12
  65. package/dist/src/cli/sessionCommand.js +4 -0
  66. package/dist/src/cli/sessionDisplay.js +17 -3
  67. package/dist/src/cli/sessionLineage.js +7 -4
  68. package/dist/src/cli/sessionRunner.js +16 -1
  69. package/dist/src/gemini-web/client.js +5 -10
  70. package/dist/src/gemini-web/executor.js +1 -24
  71. package/dist/src/gemini-web/models.js +83 -0
  72. package/dist/src/mcp/server.js +2 -0
  73. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  74. package/dist/src/mcp/tools/consult.js +261 -167
  75. package/dist/src/mcp/types.js +2 -0
  76. package/dist/src/mcp/utils.js +87 -1
  77. package/dist/src/oracle/config.js +27 -1
  78. package/dist/src/oracle/files.js +4 -6
  79. package/dist/src/oracle/geminiModels.js +2 -0
  80. package/dist/src/oracle/markdown.js +36 -3
  81. package/dist/src/oracle/modelResolver.js +21 -13
  82. package/dist/src/oracle/promptAssembly.js +2 -4
  83. package/dist/src/oracle/request.js +3 -5
  84. package/dist/src/oracle/tokenStats.js +5 -1
  85. package/dist/src/oracle.js +1 -1
  86. package/dist/src/sessionManager.js +1 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  89. package/package.json +19 -28
  90. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  91. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -2,14 +2,25 @@ import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } f
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
4
  /**
5
- * Selects a specific thinking time level in ChatGPT's composer.
5
+ * Surfaces the model-picker snapshot captured alongside a failed detection.
6
6
  *
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.
7
+ * The browser prefix routes this through the session runner's non-verbose
8
+ * always-print path. The injected probe bounds and redacts all text values.
9
+ */
10
+ function logPickerDiagnostic(result, logger) {
11
+ const diagnostic = result && "diagnostic" in result
12
+ ? result.diagnostic
13
+ : undefined;
14
+ if (!diagnostic) {
15
+ return;
16
+ }
17
+ logger(`[browser] Model picker diagnostic: ${JSON.stringify(diagnostic)}`);
18
+ }
19
+ /**
20
+ * Selects a thinking-time level in ChatGPT's composer.
11
21
  *
12
- * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
22
+ * Missing controls remain best-effort except Pro Extended, which fails closed
23
+ * unless the selected option is confirmed.
13
24
  */
14
25
  export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
15
26
  const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
@@ -26,8 +37,10 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
26
37
  case "chip-not-found":
27
38
  case "menu-not-found":
28
39
  case "option-not-found":
40
+ case "selection-unverified":
29
41
  case "model-kind-not-found": {
30
42
  await logDomFailure(Runtime, logger, `thinking-${result.status}`);
43
+ logPickerDiagnostic(result, logger);
31
44
  const kindHint = result.status === "model-kind-not-found" && result.modelKind
32
45
  ? ` for ${result.modelKind}`
33
46
  : targetModelKind
@@ -42,6 +55,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
42
55
  }
43
56
  default: {
44
57
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
58
+ logPickerDiagnostic(result, logger);
45
59
  if (strictProEffort) {
46
60
  throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
47
61
  }
@@ -69,6 +83,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
69
83
  case "chip-not-found":
70
84
  case "menu-not-found":
71
85
  case "option-not-found":
86
+ case "selection-unverified":
72
87
  case "model-kind-not-found":
73
88
  if (logger.verbose) {
74
89
  logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
@@ -125,6 +140,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
125
140
  const INITIAL_WAIT_MS = 150;
126
141
  const STEP_WAIT_MS = 200;
127
142
  const MAX_WAIT_MS = 8000;
143
+ // The "Intelligence" menu renders right after opening the composer pill, so
144
+ // a short probe is enough; if it's absent this is an older UI and we fall
145
+ // back to the legacy paths without paying the full MAX_WAIT_MS.
146
+ const INTELLIGENCE_WAIT_MS = 2500;
128
147
 
129
148
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
130
149
  // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
@@ -141,9 +160,18 @@ function buildThinkingTimeExpression(level, desiredModel) {
141
160
  const optionIsSelected = (node) => {
142
161
  if (!(node instanceof HTMLElement)) return false;
143
162
  const ariaChecked = node.getAttribute('aria-checked');
163
+ const ariaSelected = node.getAttribute('aria-selected');
164
+ const ariaCurrent = node.getAttribute('aria-current');
165
+ const dataSelected = node.getAttribute('data-selected');
144
166
  const dataState = (node.getAttribute('data-state') || '').toLowerCase();
145
- if (ariaChecked === 'true') return true;
146
- return dataState === 'checked' || dataState === 'selected' || dataState === 'on';
167
+ if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') return true;
168
+ return (
169
+ dataSelected === 'true' ||
170
+ dataState === 'checked' ||
171
+ dataState === 'selected' ||
172
+ dataState === 'on' ||
173
+ dataState === 'true'
174
+ );
147
175
  };
148
176
  const closeOpenMenus = () => {
149
177
  try {
@@ -153,34 +181,100 @@ function buildThinkingTimeExpression(level, desiredModel) {
153
181
  } catch {}
154
182
  };
155
183
 
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;
184
+ const TRAILING_SELECTOR = '[data-model-picker-thinking-effort-action="true"]';
185
+ const INTELLIGENCE_MENU_SELECTOR = '[data-testid="composer-intelligence-picker-content"]';
186
+
187
+ const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
188
+ const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
189
+ const KIND_NOT_FOUND = { kindNotFound: true };
190
+
191
+ const isVisible = (node) => {
192
+ if (!node || node.getAttribute?.('aria-hidden') === 'true') return false;
193
+ const rect = node.getBoundingClientRect?.();
194
+ return Boolean(rect && rect.width > 0 && rect.height > 0);
195
+ };
196
+ const redactDiagnosticText = (value, maxLength = 120) =>
197
+ String(value ?? '')
198
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/gi, '[redacted-email]')
199
+ .replace(/\\b[A-Za-z0-9_-]{32,}\\b/g, '[redacted]')
200
+ .replace(/\\s+/g, ' ')
201
+ .trim()
202
+ .slice(0, maxLength);
203
+ const describeNode = (el) => {
204
+ if (!el || typeof el.getAttribute !== 'function') return null;
205
+ let rect = null;
206
+ try {
207
+ const r = el.getBoundingClientRect?.();
208
+ if (r) {
209
+ rect = {
210
+ w: Math.round(r.width),
211
+ h: Math.round(r.height),
212
+ visible: r.width > 0 && r.height > 0,
213
+ };
170
214
  }
171
- }
172
- return null;
215
+ } catch {}
216
+ return {
217
+ tag: el.tagName || null,
218
+ testid: el.getAttribute('data-testid'),
219
+ role: el.getAttribute('role'),
220
+ ariaLabel: redactDiagnosticText(el.getAttribute('aria-label')),
221
+ ariaExpanded: el.getAttribute('aria-expanded'),
222
+ ariaChecked: el.getAttribute('aria-checked'),
223
+ ariaSelected: el.getAttribute('aria-selected'),
224
+ ariaHaspopup: el.getAttribute('aria-haspopup'),
225
+ dataState: el.getAttribute('data-state'),
226
+ text: redactDiagnosticText(el.textContent, 80),
227
+ rect,
228
+ };
173
229
  };
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;
230
+ const describeMenu = (menu) => {
231
+ if (!menu || typeof menu.querySelectorAll !== 'function') return null;
232
+ const items = Array.from(
233
+ menu.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"], button, [data-testid]'),
234
+ )
235
+ .slice(0, 30)
236
+ .map(describeNode);
237
+ return {
238
+ role: menu.getAttribute?.('role') ?? null,
239
+ testid: menu.getAttribute?.('data-testid') ?? null,
240
+ itemCount: items.length,
241
+ items,
242
+ };
243
+ };
244
+ const collectPickerDiagnostic = () => {
245
+ try {
246
+ const trailings = findTrailingButtons();
247
+ const switchers = Array.from(document.querySelectorAll('[data-testid*="model-switcher"]'));
248
+ const composerButtons = Array.from(
249
+ document.querySelectorAll(
250
+ 'form button[aria-haspopup="menu"], [data-testid="model-switcher-dropdown-button"]',
251
+ ),
252
+ );
253
+ const menus = Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).filter(
254
+ isVisible,
255
+ );
256
+ const modelBtn = findModelButton();
257
+ return {
258
+ targetModelKind: TARGET_MODEL_KIND,
259
+ targetLevel: TARGET_LEVEL,
260
+ modelButton: describeNode(modelBtn),
261
+ composerButtons: composerButtons.slice(0, 12).map(describeNode),
262
+ trailingCount: trailings.length,
263
+ trailings: trailings.slice(0, 12).map(describeNode),
264
+ modelSwitcherCount: switchers.length,
265
+ modelSwitcher: switchers.slice(0, 12).map(describeNode),
266
+ menuCount: menus.length,
267
+ menus: menus.slice(0, 4).map(describeMenu),
268
+ };
269
+ } catch (err) {
270
+ return { error: redactDiagnosticText(err && err.message ? err.message : err) };
181
271
  }
182
- return null;
183
272
  };
273
+ const failure = (status, extra = {}) => ({
274
+ status,
275
+ ...extra,
276
+ diagnostic: collectPickerDiagnostic(),
277
+ });
184
278
  const findOptionInMenu = (menu) => {
185
279
  for (const item of menu.querySelectorAll(MENU_ITEM_SELECTOR)) {
186
280
  if (
@@ -192,41 +286,135 @@ function buildThinkingTimeExpression(level, desiredModel) {
192
286
  }
193
287
  return null;
194
288
  };
289
+ const countEffortLevels = (menu) => {
290
+ const text = normalize(menu?.textContent ?? '');
291
+ let hits = 0;
292
+ for (const tokens of Object.values(LEVEL_TOKENS)) {
293
+ if (tokens.some((token) => text.includes(String(token).toLowerCase()))) hits += 1;
294
+ }
295
+ return hits;
296
+ };
297
+ const isEffortMenu = (menu) => {
298
+ if (!isVisible(menu)) return false;
299
+ const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
300
+ const labelText = normalize(label?.textContent ?? '');
301
+ return (
302
+ labelText.includes('thinking time') ||
303
+ labelText.includes('thinking effort') ||
304
+ countEffortLevels(menu) >= 2
305
+ );
306
+ };
307
+ const controlledMenu = (trigger) => {
308
+ const id = trigger?.getAttribute?.('aria-controls');
309
+ if (!id) return null;
310
+ const menu = document.getElementById?.(id);
311
+ return isEffortMenu(menu) ? menu : null;
312
+ };
313
+ const findVisibleEffortMenu = (trigger) => {
314
+ const controlled = controlledMenu(trigger);
315
+ if (controlled) return controlled;
316
+ for (const menu of document.querySelectorAll(MENU_CONTAINER_SELECTOR)) {
317
+ if (isEffortMenu(menu)) return menu;
318
+ }
319
+ return null;
320
+ };
321
+ const selectAndVerify = async (trigger, findOption) => {
322
+ const option = findOption();
323
+ if (!option) return failure('option-not-found');
324
+ const label = option.textContent?.trim?.() || null;
325
+ if (optionIsSelected(option)) {
326
+ closeOpenMenus();
327
+ return { status: 'already-selected', label };
328
+ }
195
329
 
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) {
330
+ dispatchClickSequence(option);
331
+ await sleep(STEP_WAIT_MS);
332
+ const refreshed = findOption();
333
+ if (refreshed && optionIsSelected(refreshed)) {
334
+ closeOpenMenus();
335
+ return { status: 'switched', label: refreshed.textContent?.trim?.() || label };
336
+ }
337
+
338
+ if (!refreshed && trigger && trigger.getAttribute?.('aria-expanded') !== 'true') {
339
+ dispatchClickSequence(trigger);
340
+ await sleep(INITIAL_WAIT_MS);
341
+ }
342
+ const deadline = performance.now() + 2000;
343
+ while (performance.now() < deadline) {
344
+ const selected = findOption();
345
+ if (selected && optionIsSelected(selected)) {
206
346
  closeOpenMenus();
207
- return { status: 'option-not-found' };
347
+ return { status: 'switched', label: selected.textContent?.trim?.() || label };
208
348
  }
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 };
349
+ await sleep(100);
215
350
  }
351
+ const result = failure('selection-unverified');
216
352
  closeOpenMenus();
217
- return { status: 'menu-not-found' };
218
- }
353
+ return result;
354
+ };
219
355
 
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"]';
356
+ // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
357
+ // controlled menu contains the effort levels. Prefer this ownership boundary
358
+ // before probing older model-picker layouts.
359
+ const COMPOSER_EFFORT_PILL_SELECTORS = [
360
+ 'form button.__composer-pill[aria-haspopup="menu"]',
361
+ '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
362
+ '.__composer-pill-composite button[aria-haspopup="menu"]',
363
+ ];
364
+ const findComposerEffortPill = () => {
365
+ const candidates = [];
366
+ const seen = new Set();
367
+ for (const selector of COMPOSER_EFFORT_PILL_SELECTORS) {
368
+ for (const button of document.querySelectorAll(selector)) {
369
+ if (seen.has(button) || !isVisible(button)) continue;
370
+ seen.add(button);
371
+ if (button.getAttribute?.('aria-haspopup') !== 'menu') continue;
372
+ if (button.getAttribute?.('data-testid') === 'model-switcher-dropdown-button') continue;
373
+ const label = normalize(
374
+ (button.getAttribute?.('aria-label') ?? '') + ' ' +
375
+ (button.getAttribute?.('data-testid') ?? '') + ' ' +
376
+ (button.textContent ?? ''),
377
+ );
378
+ if (
379
+ (TARGET_MODEL_KIND === 'pro' && hasToken(label, 'pro') && !hasToken(label, 'thinking')) ||
380
+ (TARGET_MODEL_KIND === 'thinking' && hasToken(label, 'thinking') && !hasToken(label, 'pro')) ||
381
+ (!TARGET_MODEL_KIND && hasToken(label, 'thinking'))
382
+ ) {
383
+ return button;
384
+ }
385
+ candidates.push(button);
386
+ }
387
+ }
388
+ const composerPills = candidates.filter((button) =>
389
+ button.matches?.('button.__composer-pill'),
390
+ );
391
+ return composerPills.length === 1 ? composerPills[0] : null;
392
+ };
226
393
 
227
- const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
228
- const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
229
- const KIND_NOT_FOUND = { kindNotFound: true };
394
+ const composerEffortPill = findComposerEffortPill();
395
+ if (composerEffortPill) {
396
+ if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
397
+ dispatchClickSequence(composerEffortPill);
398
+ await sleep(INITIAL_WAIT_MS);
399
+ }
400
+ const deadline = performance.now() + MAX_WAIT_MS;
401
+ while (performance.now() < deadline) {
402
+ const menu = findVisibleEffortMenu(composerEffortPill);
403
+ if (menu) {
404
+ return selectAndVerify(composerEffortPill, () => {
405
+ const currentMenu = findVisibleEffortMenu(composerEffortPill);
406
+ return currentMenu ? findOptionInMenu(currentMenu) : null;
407
+ });
408
+ }
409
+ await sleep(100);
410
+ }
411
+ const result = failure('menu-not-found');
412
+ closeOpenMenus();
413
+ return result;
414
+ }
415
+
416
+ // Older ChatGPT layouts attach effort controls to rows inside the model
417
+ // picker. Keep these compatibility paths after the standalone pill owner.
230
418
  const findEffortRow = (node) => {
231
419
  let current = node instanceof HTMLElement ? node.parentElement : null;
232
420
  while (current && current !== document.body) {
@@ -291,12 +479,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
291
479
  }
292
480
  return false;
293
481
  };
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
482
  const pickSingleStableTrailing = (trailings) => {
299
- const visible = trailings.filter((t) => hasStableBox(t));
483
+ const visible = trailings.filter((trailing) => isVisible(trailing));
300
484
  return visible.length === 1 ? visible[0] : null;
301
485
  };
302
486
  const pickTrailingForCurrentModel = () => {
@@ -317,7 +501,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
317
501
 
318
502
  const modelBtn = findModelButton();
319
503
  if (!modelBtn) {
320
- return { status: 'chip-not-found' };
504
+ return failure('chip-not-found');
321
505
  }
322
506
  // Open model menu (idempotent — leaves it open if already open).
323
507
  if (modelBtn.getAttribute('aria-expanded') !== 'true') {
@@ -325,6 +509,49 @@ function buildThinkingTimeExpression(level, desiredModel) {
325
509
  await sleep(INITIAL_WAIT_MS);
326
510
  }
327
511
 
512
+ // ---------- COMPATIBILITY UI: unified "Intelligence" effort picker ----------
513
+ // One observed ChatGPT layout replaced the per-model trailing buttons with a single
514
+ // "Intelligence" menu ([data-testid="composer-intelligence-picker-content"]),
515
+ // whose role="menuitemradio" rows are the effort tiers. For the Pro model the
516
+ // combined "Pro Extended" row carries aria-checked when active, and Pro
517
+ // sub-options live behind
518
+ // [data-testid="composer-intelligence-pro-thinking-effort-trigger"]. We
519
+ // confirm Pro Extended by the radio's checked state (real proof), never by
520
+ // the composer-pill label. The new non-pro tiers (Instant/Medium/High/Extra
521
+ // High) don't map cleanly onto light/standard/extended/heavy, so we only
522
+ // drive this picker for the strict Pro Extended target and let other levels
523
+ // fall through to the legacy paths below.
524
+ if (TARGET_MODEL_KIND === 'pro' && TARGET_LEVEL === 'extended') {
525
+ const matchesProExtended = (node) => {
526
+ const text = normalize(
527
+ (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
528
+ );
529
+ return text.includes('pro') && text.includes('extended');
530
+ };
531
+ const findProExtendedOption = () => {
532
+ const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR);
533
+ if (!isVisible(menu)) return null;
534
+ for (const item of menu.querySelectorAll(
535
+ '[role="menuitemradio"], [role="menuitem"], [role="option"]',
536
+ )) {
537
+ if (matchesProExtended(item)) return item;
538
+ }
539
+ return null;
540
+ };
541
+ let proExtended = null;
542
+ const intelligenceDeadline = performance.now() + INTELLIGENCE_WAIT_MS;
543
+ while (performance.now() < intelligenceDeadline) {
544
+ proExtended = findProExtendedOption();
545
+ if (proExtended) break;
546
+ await sleep(100);
547
+ }
548
+ if (proExtended) {
549
+ return selectAndVerify(modelBtn, findProExtendedOption);
550
+ }
551
+ // Intelligence menu absent (older UI) or its Pro Extended row is missing:
552
+ // fall through to the legacy trailing-button path below.
553
+ }
554
+
328
555
  let trailing = null;
329
556
  const trailingDeadline = performance.now() + MAX_WAIT_MS;
330
557
  while (performance.now() < trailingDeadline) {
@@ -333,12 +560,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
333
560
  await sleep(100);
334
561
  }
335
562
  if (!trailing) {
563
+ const result = failure('chip-not-found');
336
564
  closeOpenMenus();
337
- return { status: 'chip-not-found' };
565
+ return result;
338
566
  }
339
567
  if (trailing.kindNotFound) {
568
+ const result = failure('model-kind-not-found', { modelKind: TARGET_MODEL_KIND });
340
569
  closeOpenMenus();
341
- return { status: 'model-kind-not-found', modelKind: TARGET_MODEL_KIND };
570
+ return result;
342
571
  }
343
572
 
344
573
  dispatchClickSequence(trailing);
@@ -349,18 +578,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
349
578
  const resolveEffortMenu = () => {
350
579
  const id = trailing.getAttribute('aria-controls');
351
580
  if (id) {
352
- const node = document.getElementById(id);
353
- if (node) return node;
581
+ const node = document.getElementById?.(id);
582
+ if (isEffortMenu(node)) return node;
354
583
  }
355
- const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
584
+ const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR);
356
585
  let best = null;
357
586
  for (const menu of menus) {
358
587
  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
- }
588
+ if (!isVisible(menu)) continue;
589
+ const hits = countEffortLevels(menu);
364
590
  if (hits >= 2 && (!best || hits > best.hits)) best = { menu, hits };
365
591
  }
366
592
  return best?.menu ?? null;
@@ -374,22 +600,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
374
600
  await sleep(100);
375
601
  }
376
602
  if (!effortMenu) {
603
+ const result = failure('menu-not-found');
377
604
  closeOpenMenus();
378
- return { status: 'menu-not-found' };
605
+ return result;
379
606
  }
380
607
 
381
- const targetOption = findOptionInMenu(effortMenu);
382
- if (!targetOption) {
383
- closeOpenMenus();
384
- return { status: 'option-not-found' };
385
- }
386
-
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 };
608
+ return selectAndVerify(trailing, () => {
609
+ const currentMenu = resolveEffortMenu();
610
+ return currentMenu ? findOptionInMenu(currentMenu) : null;
611
+ });
393
612
  })()`;
394
613
  }
395
614
  export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) {
@@ -66,6 +66,25 @@ export async function writeTextBrowserArtifact(params) {
66
66
  sourceUrl: params.sourceUrl,
67
67
  };
68
68
  }
69
+ export async function writeBinaryBrowserArtifact(params) {
70
+ if (!params.sessionId || params.contents.length === 0) {
71
+ return null;
72
+ }
73
+ const dir = resolveSessionArtifactsDir(params.sessionId);
74
+ await fs.mkdir(dir, { recursive: true });
75
+ const filename = sanitizePathSegment(params.filename, "artifact.bin");
76
+ const targetPath = await resolveUniquePath(path.join(dir, filename));
77
+ await fs.writeFile(targetPath, params.contents);
78
+ params.logger?.(`[browser] Saved ${params.kind} artifact to ${targetPath}`);
79
+ return {
80
+ kind: params.kind,
81
+ path: targetPath,
82
+ label: params.label,
83
+ mimeType: params.mimeType,
84
+ sizeBytes: params.contents.length,
85
+ sourceUrl: params.sourceUrl,
86
+ };
87
+ }
69
88
  function isToolOnlyPlaceholder(text) {
70
89
  const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
71
90
  return (normalized === "called tool" ||