@steipete/oracle 0.11.1 → 0.12.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 (49) hide show
  1. package/README.md +55 -10
  2. package/dist/bin/oracle-cli.js +440 -98
  3. package/dist/src/browser/actions/modelSelection.js +74 -20
  4. package/dist/src/browser/actions/navigation.js +5 -3
  5. package/dist/src/browser/actions/promptComposer.js +76 -18
  6. package/dist/src/browser/actions/thinkingTime.js +133 -19
  7. package/dist/src/browser/constants.js +1 -1
  8. package/dist/src/browser/index.js +78 -9
  9. package/dist/src/browser/manualLoginProfile.js +54 -0
  10. package/dist/src/browser/projectSourcesRunner.js +16 -5
  11. package/dist/src/browser/prompt.js +56 -37
  12. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  13. package/dist/src/browser/reattachability.js +22 -0
  14. package/dist/src/browser/sessionRunner.js +73 -1
  15. package/dist/src/browser/utils.js +1 -47
  16. package/dist/src/browser/zipBundle.js +152 -0
  17. package/dist/src/cli/browserConfig.js +13 -11
  18. package/dist/src/cli/browserDefaults.js +2 -1
  19. package/dist/src/cli/docsCheck.js +186 -0
  20. package/dist/src/cli/engine.js +11 -4
  21. package/dist/src/cli/options.js +12 -6
  22. package/dist/src/cli/perfTrace.js +242 -0
  23. package/dist/src/cli/promptRequirement.js +2 -0
  24. package/dist/src/cli/providerDoctor.js +85 -0
  25. package/dist/src/cli/runOptions.js +46 -16
  26. package/dist/src/cli/sessionDisplay.js +47 -4
  27. package/dist/src/cli/sessionLifecycle.js +38 -0
  28. package/dist/src/cli/sessionRunner.js +272 -3
  29. package/dist/src/cli/sessionTable.js +2 -1
  30. package/dist/src/duration.js +47 -0
  31. package/dist/src/mcp/tools/consult.js +19 -3
  32. package/dist/src/mcp/types.js +1 -0
  33. package/dist/src/mcp/utils.js +4 -1
  34. package/dist/src/oracle/baseUrl.js +17 -0
  35. package/dist/src/oracle/client.js +1 -22
  36. package/dist/src/oracle/config.js +17 -4
  37. package/dist/src/oracle/gemini.js +2 -22
  38. package/dist/src/oracle/geminiModels.js +21 -0
  39. package/dist/src/oracle/modelResolver.js +7 -1
  40. package/dist/src/oracle/multiModelRunner.js +20 -2
  41. package/dist/src/oracle/providerFailures.js +204 -0
  42. package/dist/src/oracle/providerRoutePlan.js +308 -0
  43. package/dist/src/oracle/providerRouting.js +92 -0
  44. package/dist/src/oracle/run.js +104 -107
  45. package/dist/src/oracle.js +1 -0
  46. package/dist/src/remote/client.js +8 -0
  47. package/dist/src/remote/server.js +26 -0
  48. package/dist/src/sessionManager.js +43 -23
  49. package/package.json +15 -12
@@ -1,6 +1,8 @@
1
1
  import { COMPOSER_MODEL_SIGNAL_SELECTOR, 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 LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
5
+ const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
4
6
  export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
5
7
  const outcome = await Runtime.evaluate({
6
8
  expression: buildModelSelectionExpression(desiredModel, strategy),
@@ -17,7 +19,15 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
17
19
  assertResolvedModelSelection(desiredModel, label);
18
20
  }
19
21
  logger(`Model picker: ${label}`);
20
- return;
22
+ return {
23
+ requestedModel: desiredModel,
24
+ resolvedLabel: label,
25
+ strategy,
26
+ status: result.status,
27
+ verified: strategy !== "current",
28
+ source: "chatgpt-model-picker",
29
+ capturedAt: new Date().toISOString(),
30
+ };
21
31
  }
22
32
  case "option-not-found": {
23
33
  await logDomFailure(Runtime, logger, "model-switcher-option");
@@ -38,23 +48,35 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
38
48
  function assertResolvedModelSelection(desiredModel, resolvedLabel) {
39
49
  const desired = desiredModel.toLowerCase();
40
50
  const resolved = resolvedLabel.toLowerCase();
41
- const wantsGpt55Pro = desired === "gpt-5.5-pro" ||
51
+ const wantsGpt55Pro = desired === "pro" ||
52
+ desired === "chatgpt pro" ||
53
+ desired === "gpt-5.5-pro" ||
42
54
  desired.includes("5.5 pro") ||
43
55
  desired.includes("5-5 pro") ||
44
56
  (desired.includes("pro") && desired.includes("extended"));
45
57
  if (!wantsGpt55Pro || !resolved) {
46
58
  return;
47
59
  }
48
- const hasProSignal = resolved.includes(" pro") ||
49
- resolved.endsWith("pro") ||
50
- resolved.includes("pro ") ||
51
- resolved.includes("extended") ||
52
- resolved.includes("gpt-5.5-pro") ||
53
- resolved.includes("gpt 5 5 pro");
54
- if (!hasProSignal || (resolved.includes("thinking") && !resolved.includes("pro"))) {
60
+ if (!hasCurrentProSignal(resolved) ||
61
+ hasLegacyProVersionLabel(resolved) ||
62
+ resolved.includes("thinking")) {
55
63
  throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
56
64
  }
57
65
  }
66
+ function normalizeResolvedModelLabel(value) {
67
+ return value
68
+ .replace(/[^a-z0-9]+/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+ function hasCurrentProSignal(resolved) {
73
+ return normalizeResolvedModelLabel(resolved).split(" ").includes("pro");
74
+ }
75
+ function hasLegacyProVersionLabel(resolved) {
76
+ const normalized = normalizeResolvedModelLabel(resolved);
77
+ return (LEGACY_PRO_VERSION_WORD_TOKENS.some((token) => normalized.includes(token)) ||
78
+ LEGACY_PRO_VERSION_COMPACT_TOKENS.some((token) => resolved.includes(token)));
79
+ }
58
80
  export function assertResolvedModelSelectionForTest(desiredModel, resolvedLabel) {
59
81
  assertResolvedModelSelection(desiredModel, resolvedLabel);
60
82
  }
@@ -101,6 +123,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
101
123
  .replace(/\\s+/g, ' ')
102
124
  .trim();
103
125
  };
126
+ const hasToken = (value, token) => normalizeText(value).split(' ').includes(token);
104
127
  // Normalize every candidate token to keep fuzzy matching deterministic.
105
128
  const normalizedTarget = normalizeText(PRIMARY_LABEL);
106
129
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
@@ -121,8 +144,16 @@ function buildModelSelectionExpression(targetModel, strategy) {
121
144
  const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
122
145
  const wantsInstant = normalizedTarget.includes('instant');
123
146
  const wantsThinking = normalizedTarget.includes('thinking');
147
+ const targetUsesCurrentGpt55Alias =
148
+ desiredVersion === '5-5' || normalizedTarget === 'pro' || normalizedTarget === 'chatgpt pro';
149
+ const labelHasProWord = (label) => label === 'pro' || label.startsWith('pro ') || label.includes(' pro ') || label.endsWith(' pro');
150
+ const legacyProVersionTokens = ['5 4', '5 2', '5 1', '5 0', 'gpt54', 'gpt52', 'gpt51', 'gpt50', 'gpt 5 pro'];
151
+ const labelHasLegacyProVersion = (value) => {
152
+ const label = normalizeText(value);
153
+ return legacyProVersionTokens.some((token) => label.includes(token));
154
+ };
124
155
  const isTargetGpt55VisibleAlias = (value) => {
125
- if (desiredVersion !== '5-5') return false;
156
+ if (!targetUsesCurrentGpt55Alias) return false;
126
157
  const label = normalizeText(value);
127
158
  if (wantsPro) {
128
159
  // ChatGPT UI as of 2026-05: the picker shows just "Pro" (no longer "Pro Extended").
@@ -138,7 +169,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
138
169
  return false;
139
170
  };
140
171
  const hasProComposerPill = () => Boolean(
141
- document.querySelector('button.__composer-pill, button[aria-label="Pro, click to remove"]')
172
+ Array.from(document.querySelectorAll('button.__composer-pill, button[aria-label]'))
173
+ .filter((node) => {
174
+ const label = normalizeText(node.getAttribute?.('aria-label') ?? '');
175
+ return node.matches?.('button.__composer-pill') || label.includes('click to remove');
176
+ })
177
+ .some((node) => {
178
+ const label = normalizeText(
179
+ (node.getAttribute?.('aria-label') ?? '') + ' ' + (node.textContent ?? '')
180
+ );
181
+ return hasToken(label, 'pro') && !hasToken(label, 'thinking');
182
+ })
142
183
  );
143
184
 
144
185
  const button = document.querySelector(BUTTON_SELECTOR);
@@ -174,7 +215,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
174
215
  const resolved = label || '';
175
216
  if (!wantsPro || !hasProComposerPill()) return resolved;
176
217
  const normalized = normalizeText(resolved);
177
- if (!normalized || normalized.includes('pro')) return resolved;
218
+ if (!normalized) return resolved;
219
+ if (normalized.includes('thinking')) return 'Pro';
220
+ if (normalized.includes('pro')) return resolved;
178
221
  return resolved + ' + Pro';
179
222
  };
180
223
  const getResolvedLabel = (fallback) =>
@@ -190,7 +233,15 @@ function buildModelSelectionExpression(targetModel, strategy) {
190
233
  const normalizedLabel = normalizeText(getButtonLabel());
191
234
  if (!normalizedLabel) return false;
192
235
  if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
193
- if (wantsPro && normalizedLabel === 'chatgpt' && hasProComposerPill()) {
236
+ if (
237
+ wantsPro &&
238
+ hasProComposerPill() &&
239
+ (normalizedLabel === 'chatgpt' ||
240
+ normalizedLabel === 'extended' ||
241
+ normalizedLabel === 'standard' ||
242
+ normalizedLabel === 'heavy' ||
243
+ normalizedLabel === 'light')
244
+ ) {
194
245
  return true;
195
246
  }
196
247
  if (desiredVersion) {
@@ -200,7 +251,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
200
251
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
201
252
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
202
253
  }
203
- if (wantsPro && !normalizedLabel.includes(' pro')) return false;
254
+ if (wantsPro && labelHasLegacyProVersion(normalizedLabel)) return false;
255
+ if (wantsPro && !labelHasProWord(normalizedLabel)) return false;
204
256
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
205
257
  if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
206
258
  // Also reject if button has variants we DON'T want
@@ -218,6 +270,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
218
270
  if (!signal) {
219
271
  return COMPOSER_SIGNAL_ALLOW_BLANK;
220
272
  }
273
+ if (wantsPro && labelHasLegacyProVersion(signal)) {
274
+ return false;
275
+ }
221
276
  if (COMPOSER_SIGNAL_EXCLUDES.some((token) => token && signal.includes(token))) {
222
277
  return false;
223
278
  }
@@ -356,15 +411,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
356
411
  const candidateGpt55VisibleAlias = isTargetGpt55VisibleAlias(normalizedText);
357
412
  const candidateHasThinking =
358
413
  normalizedText.includes('thinking') || normalizedTestId.includes('thinking');
414
+ const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
359
415
  const candidateHasPro =
360
416
  candidateGpt55VisibleAlias ||
361
- normalizedText === 'pro' ||
362
- normalizedText.startsWith('pro ') ||
363
- normalizedText.includes(' pro ') ||
364
- normalizedText.endsWith(' pro') ||
417
+ labelHasProWord(normalizedText) ||
365
418
  normalizedText.includes('proresearch') ||
366
419
  normalizedTestId.includes('pro');
367
420
  if (wantsPro && candidateHasThinking) return 0;
421
+ if (wantsPro && candidateHasLegacyProVersion) return 0;
368
422
  if (wantsPro && !candidateHasPro) return 0;
369
423
  if (wantsThinking && candidateHasPro) return 0;
370
424
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
@@ -407,10 +461,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
407
461
  }
408
462
  // If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
409
463
  if (wantsPro) {
410
- if (!normalizedText.includes(' pro')) {
464
+ if (!labelHasProWord(normalizedText)) {
411
465
  score -= 80;
412
466
  }
413
- } else if (normalizedText.includes(' pro')) {
467
+ } else if (labelHasProWord(normalizedText)) {
414
468
  score -= 40;
415
469
  }
416
470
  // Similarly for Thinking variant
@@ -388,9 +388,8 @@ function buildLoginProbeExpression(timeoutMs) {
388
388
  if (!text) return false;
389
389
  const normalized = text.toLowerCase().trim();
390
390
  return (
391
- ['log in', 'login', 'sign in', 'signin', 'continue with', 'sign up for free'].some(
392
- (needle) => normalized.startsWith(needle),
393
- ) ||
391
+ ['log in', 'login', 'sign in', 'signin', 'sign up for free'].includes(normalized) ||
392
+ normalized.startsWith('continue with') ||
394
393
  normalized.includes('get responses tailored to you') ||
395
394
  normalized.includes('log in to get answers')
396
395
  );
@@ -490,3 +489,6 @@ function normalizeLoginProbe(raw) {
490
489
  onAuthPage: Boolean(value.onAuthPage),
491
490
  };
492
491
  }
492
+ export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
493
+ return buildLoginProbeExpression(timeoutMs);
494
+ }
@@ -183,6 +183,7 @@ export async function submitPrompt(deps, prompt, logger) {
183
183
  else {
184
184
  logger("Clicked send button");
185
185
  }
186
+ await deps.onPromptSubmitted?.();
186
187
  const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
187
188
  // Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
188
189
  return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
@@ -290,17 +291,35 @@ function buildAttachmentReadyExpression(attachmentNames) {
290
291
  document.querySelector('form') ||
291
292
  document.body ||
292
293
  document;
293
- const labelText = (node) =>
294
- [
295
- node?.textContent,
296
- node?.getAttribute?.('aria-label'),
297
- node?.getAttribute?.('title'),
298
- node?.getAttribute?.('data-testid'),
299
- ]
300
- .filter(Boolean)
301
- .join(' ')
302
- .toLowerCase();
303
- const match = (node, name) => labelText(node).includes(name);
294
+ // Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
295
+ // ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
296
+ // only the node's own textContent/aria/title misses the match.
297
+ const collectLabelHaystack = (node) => {
298
+ if (!node) return '';
299
+ const pieces = [];
300
+ const pushAttrs = (el) => {
301
+ if (!el || typeof el.getAttribute !== 'function') return;
302
+ for (const attr of ['aria-label', 'title', 'data-testid', 'data-tooltip', 'data-tooltip-content']) {
303
+ const v = el.getAttribute(attr);
304
+ if (v) pieces.push(v);
305
+ }
306
+ };
307
+ const pushText = (el) => {
308
+ if (!el) return;
309
+ const text = (el.innerText ?? el.textContent ?? '').trim();
310
+ if (text) pieces.push(text);
311
+ };
312
+ pushAttrs(node);
313
+ pushText(node);
314
+ const parent = node.parentElement;
315
+ pushAttrs(parent);
316
+ pushText(parent);
317
+ const grandparent = parent?.parentElement;
318
+ pushAttrs(grandparent);
319
+ pushText(grandparent);
320
+ return pieces.join(' ').toLowerCase();
321
+ };
322
+ const match = (node, name) => collectLabelHaystack(node).includes(name);
304
323
 
305
324
  // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
306
325
  const attachmentSelectors = [
@@ -312,13 +331,32 @@ function buildAttachmentReadyExpression(attachmentNames) {
312
331
  'button[aria-label*="Remove file"]',
313
332
  '[aria-label*="remove file"]',
314
333
  'button[aria-label*="remove file"]',
334
+ '[aria-label*="Remove attachment"]',
335
+ 'button[aria-label*="Remove attachment"]',
336
+ '[aria-label*="remove attachment"]',
337
+ 'button[aria-label*="remove attachment"]',
315
338
  ];
316
- const attachmentRoots = Array.from(new Set([composer, document])).filter(Boolean);
339
+ const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
340
+ const collectChipNodes = () => {
341
+ const seen = new Set();
342
+ const collected = [];
343
+ for (const root of attachmentRoots) {
344
+ for (const node of Array.from(root.querySelectorAll(attachmentSelectors.join(',')))) {
345
+ if (!(node instanceof HTMLElement)) continue;
346
+ // Skip elements clearly inside the editable input (composer textarea may contain
347
+ // filename text in the user's prompt — avoid mistaking that for a chip).
348
+ if (node.closest('textarea,[contenteditable="true"]')) continue;
349
+ if (seen.has(node)) continue;
350
+ seen.add(node);
351
+ collected.push(node);
352
+ }
353
+ }
354
+ return collected;
355
+ };
356
+ const chipNodes = collectChipNodes();
317
357
 
318
358
  const chipsReady = names.every((name) =>
319
- attachmentRoots.some((root) =>
320
- Array.from(root.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
321
- ),
359
+ chipNodes.some((node) => match(node, name)),
322
360
  );
323
361
  const inputsReady = names.every((name) =>
324
362
  attachmentRoots.some((root) =>
@@ -329,14 +367,28 @@ function buildAttachmentReadyExpression(attachmentNames) {
329
367
  ),
330
368
  ),
331
369
  );
370
+ // Count-based fallback: if we cannot match names individually (ChatGPT may strip
371
+ // the filename out of attribute-readable text into a deeply nested span), but we
372
+ // do see at least as many distinct chip-shaped nodes as attachments we uploaded,
373
+ // and a sibling "Remove" affordance exists per chip, trust the upload.
374
+ const removeAffordanceCount = chipNodes.filter((node) => {
375
+ const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
376
+ if (aria.includes('remove')) return true;
377
+ const removeSibling = node.querySelector?.(
378
+ '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
379
+ );
380
+ return Boolean(removeSibling);
381
+ }).length;
382
+ const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
332
383
 
333
- return chipsReady || inputsReady;
384
+ return chipsReady || inputsReady || countReady;
334
385
  })()`;
335
386
  }
336
387
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
337
388
  return buildAttachmentReadyExpression(attachmentNames);
338
389
  }
339
390
  async function attemptSendButton(Runtime, _logger, attachmentNames) {
391
+ const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
340
392
  const script = `(() => {
341
393
  ${buildClickDispatcher()}
342
394
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -369,9 +421,11 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
369
421
  dispatchClickSequence(button);
370
422
  return 'clicked';
371
423
  })()`;
372
- const deadline = Date.now() + 20_000;
424
+ // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
425
+ // settle slowly for multi-file uploads, but plain text sends should keep the
426
+ // shorter historical deadline.
427
+ const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
373
428
  while (Date.now() < deadline) {
374
- const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
375
429
  if (needAttachment) {
376
430
  const ready = await Runtime.evaluate({
377
431
  expression: buildAttachmentReadyExpression(attachmentNames),
@@ -400,6 +454,9 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
400
454
  }
401
455
  return false;
402
456
  }
457
+ function sendButtonTimeoutMs(attachmentNames) {
458
+ return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
459
+ }
403
460
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
404
461
  const deadline = Date.now() + timeoutMs;
405
462
  const encodedPrompt = JSON.stringify(prompt.trim());
@@ -542,5 +599,6 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
542
599
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
543
600
  export const __test__ = {
544
601
  attemptSendButton,
602
+ sendButtonTimeoutMs,
545
603
  verifyPromptCommitted,
546
604
  };
@@ -11,9 +11,11 @@ import { buildClickDispatcher } from "./domEvents.js";
11
11
  *
12
12
  * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
13
13
  */
14
- export async function ensureThinkingTime(Runtime, level, logger) {
15
- const result = await evaluateThinkingTimeSelection(Runtime, level);
14
+ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
15
+ const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
16
16
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
17
+ const targetModelKind = inferThinkingTargetModelKind(desiredModel);
18
+ const strictProEffort = targetModelKind === "pro" && level === "extended";
17
19
  switch (result?.status) {
18
20
  case "already-selected":
19
21
  logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
@@ -23,13 +25,26 @@ export async function ensureThinkingTime(Runtime, level, logger) {
23
25
  return;
24
26
  case "chip-not-found":
25
27
  case "menu-not-found":
26
- case "option-not-found": {
28
+ case "option-not-found":
29
+ case "model-kind-not-found": {
27
30
  await logDomFailure(Runtime, logger, `thinking-${result.status}`);
28
- logger(`Thinking time: ${result.status.replaceAll("-", " ")} (requested ${capitalizedLevel}); continuing with ChatGPT default.`);
31
+ const kindHint = result.status === "model-kind-not-found" && result.modelKind
32
+ ? ` for ${result.modelKind}`
33
+ : targetModelKind
34
+ ? ` for ${targetModelKind}`
35
+ : "";
36
+ const message = `Thinking time: ${result.status.replaceAll("-", " ")}${kindHint} (requested ${capitalizedLevel})`;
37
+ if (strictProEffort) {
38
+ throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
39
+ }
40
+ logger(`${message}; continuing with ChatGPT default.`);
29
41
  return;
30
42
  }
31
43
  default: {
32
44
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
45
+ if (strictProEffort) {
46
+ throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed Pro Extended.`);
47
+ }
33
48
  logger(`Thinking time: unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`);
34
49
  return;
35
50
  }
@@ -40,9 +55,9 @@ export async function ensureThinkingTime(Runtime, level, logger) {
40
55
  * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
41
56
  * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
42
57
  */
43
- export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
58
+ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) {
44
59
  try {
45
- const result = await evaluateThinkingTimeSelection(Runtime, level);
60
+ const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
46
61
  const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
47
62
  switch (result?.status) {
48
63
  case "already-selected":
@@ -54,6 +69,7 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
54
69
  case "chip-not-found":
55
70
  case "menu-not-found":
56
71
  case "option-not-found":
72
+ case "model-kind-not-found":
57
73
  if (logger.verbose) {
58
74
  logger(`Thinking time: ${result.status.replaceAll("-", " ")}; continuing with default.`);
59
75
  }
@@ -74,19 +90,20 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
74
90
  return false;
75
91
  }
76
92
  }
77
- async function evaluateThinkingTimeSelection(Runtime, level) {
93
+ async function evaluateThinkingTimeSelection(Runtime, level, desiredModel) {
78
94
  const outcome = await Runtime.evaluate({
79
- expression: buildThinkingTimeExpression(level),
95
+ expression: buildThinkingTimeExpression(level, desiredModel),
80
96
  awaitPromise: true,
81
97
  returnByValue: true,
82
98
  });
83
99
  return outcome.result?.value;
84
100
  }
85
- function buildThinkingTimeExpression(level) {
101
+ function buildThinkingTimeExpression(level, desiredModel) {
86
102
  const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
87
103
  const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
88
104
  const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
89
105
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
106
+ const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
90
107
  return `(async () => {
91
108
  ${buildClickDispatcher()}
92
109
 
@@ -94,6 +111,7 @@ function buildThinkingTimeExpression(level) {
94
111
  const MENU_ITEM_SELECTOR = ${menuItemLiteral};
95
112
  const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
96
113
  const TARGET_LEVEL = ${targetLevelLiteral};
114
+ const TARGET_MODEL_KIND = ${targetModelKindLiteral};
97
115
 
98
116
  // Bilingual matchers: English level token + observed Chinese variants.
99
117
  const LEVEL_TOKENS = {
@@ -119,6 +137,7 @@ function buildThinkingTimeExpression(level) {
119
137
  const t = normalize(text);
120
138
  return targetTokens.some((tok) => t.includes(String(tok).toLowerCase()));
121
139
  };
140
+ const hasToken = (text, token) => normalize(text).split(' ').includes(token);
122
141
  const optionIsSelected = (node) => {
123
142
  if (!(node instanceof HTMLElement)) return false;
124
143
  const ariaChecked = node.getAttribute('aria-checked');
@@ -207,28 +226,99 @@ function buildThinkingTimeExpression(level) {
207
226
 
208
227
  const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
209
228
  const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
229
+ const KIND_NOT_FOUND = { kindNotFound: true };
230
+ const findEffortRow = (node) => {
231
+ let current = node instanceof HTMLElement ? node.parentElement : null;
232
+ while (current && current !== document.body) {
233
+ if (current.getAttribute?.('data-model-picker-thinking-effort-row') === 'true') {
234
+ return current;
235
+ }
236
+ current = current.parentElement;
237
+ }
238
+ return null;
239
+ };
240
+ const rowIsSelected = (row) => {
241
+ if (!(row instanceof HTMLElement)) return false;
242
+ const modelItem = row.querySelector('[data-model-picker-thinking-effort-menu-item="true"], [role="menuitemradio"]');
243
+ if (optionIsSelected(modelItem)) return true;
244
+ return Boolean(
245
+ row.querySelector(
246
+ '[aria-checked="true"], [aria-selected="true"], [aria-current="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
247
+ ),
248
+ );
249
+ };
250
+ const rowForTrailing = (trailing) =>
251
+ trailing.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
252
+ const rowTextForTrailing = (trailing) => {
253
+ const row = rowForTrailing(trailing) || findEffortRow(trailing);
254
+ return normalize(
255
+ (row?.getAttribute?.('aria-label') ?? '') + ' ' +
256
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
257
+ (row?.textContent ?? '') + ' ' +
258
+ (trailing.getAttribute?.('aria-label') ?? '') + ' ' +
259
+ (trailing.getAttribute?.('data-testid') ?? '')
260
+ );
261
+ };
262
+ const testIdTextForTrailing = (trailing) => {
263
+ const row = rowForTrailing(trailing) || findEffortRow(trailing);
264
+ return normalize(
265
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
266
+ (trailing.getAttribute?.('data-testid') ?? '')
267
+ );
268
+ };
269
+ const modelKindFromTrailing = (trailing) => {
270
+ const idText = testIdTextForTrailing(trailing);
271
+ if (!idText.includes('model switcher')) return null;
272
+ const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
273
+ if (hasToken(modelPart, 'pro')) return 'pro';
274
+ if (hasToken(modelPart, 'thinking')) return 'thinking';
275
+ if (hasToken(modelPart, 'instant')) return 'instant';
276
+ return null;
277
+ };
278
+ const trailingMatchesTargetModelKind = (trailing) => {
279
+ if (!TARGET_MODEL_KIND) return false;
280
+ const idKind = modelKindFromTrailing(trailing);
281
+ if (idKind) return idKind === TARGET_MODEL_KIND;
282
+ const text = rowTextForTrailing(trailing);
283
+ if (TARGET_MODEL_KIND === 'pro') {
284
+ return hasToken(text, 'pro') && !hasToken(text, 'thinking');
285
+ }
286
+ if (TARGET_MODEL_KIND === 'thinking') {
287
+ return hasToken(text, 'thinking') && !hasToken(text, 'pro');
288
+ }
289
+ if (TARGET_MODEL_KIND === 'instant') {
290
+ return hasToken(text, 'instant') && !hasToken(text, 'thinking') && !hasToken(text, 'pro');
291
+ }
292
+ return false;
293
+ };
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
+ const pickSingleStableTrailing = (trailings) => {
299
+ const visible = trailings.filter((t) => hasStableBox(t));
300
+ return visible.length === 1 ? visible[0] : null;
301
+ };
210
302
  const pickTrailingForCurrentModel = () => {
211
303
  const trailings = findTrailingButtons();
212
304
  if (trailings.length === 0) return null;
213
305
  if (trailings.length === 1) return trailings[0];
214
306
  // Prefer the trailing button whose model row is currently selected.
215
307
  for (const t of trailings) {
216
- const row = t.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
217
- if (row && (optionIsSelected(row) || row.querySelector('[aria-checked="true"]'))) return t;
308
+ const row = findEffortRow(t);
309
+ if (rowIsSelected(row)) return t;
218
310
  }
219
- // Fallback: first one with non-zero box.
220
- for (const t of trailings) {
221
- const r = t.getBoundingClientRect?.();
222
- if (r && r.width > 0 && r.height > 0) return t;
311
+ if (TARGET_MODEL_KIND) {
312
+ const targetTrailings = trailings.filter((t) => trailingMatchesTargetModelKind(t));
313
+ return pickSingleStableTrailing(targetTrailings) || KIND_NOT_FOUND;
223
314
  }
224
- return trailings[0];
315
+ return null;
225
316
  };
226
317
 
227
318
  const modelBtn = findModelButton();
228
319
  if (!modelBtn) {
229
320
  return { status: 'chip-not-found' };
230
321
  }
231
-
232
322
  // Open model menu (idempotent — leaves it open if already open).
233
323
  if (modelBtn.getAttribute('aria-expanded') !== 'true') {
234
324
  dispatchClickSequence(modelBtn);
@@ -246,6 +336,10 @@ function buildThinkingTimeExpression(level) {
246
336
  closeOpenMenus();
247
337
  return { status: 'chip-not-found' };
248
338
  }
339
+ if (trailing.kindNotFound) {
340
+ closeOpenMenus();
341
+ return { status: 'model-kind-not-found', modelKind: TARGET_MODEL_KIND };
342
+ }
249
343
 
250
344
  dispatchClickSequence(trailing);
251
345
  await sleep(STEP_WAIT_MS);
@@ -298,6 +392,26 @@ function buildThinkingTimeExpression(level) {
298
392
  return { status: already ? 'already-selected' : 'switched', label };
299
393
  })()`;
300
394
  }
301
- export function buildThinkingTimeExpressionForTest(level = "extended") {
302
- return buildThinkingTimeExpression(level);
395
+ export function buildThinkingTimeExpressionForTest(level = "extended", desiredModel) {
396
+ return buildThinkingTimeExpression(level, desiredModel);
397
+ }
398
+ function inferThinkingTargetModelKind(desiredModel) {
399
+ const normalized = (desiredModel ?? "")
400
+ .toLowerCase()
401
+ .replace(/[^a-z0-9]+/g, " ")
402
+ .replace(/\s+/g, " ")
403
+ .trim();
404
+ if (!normalized)
405
+ return null;
406
+ const tokens = normalized.split(" ");
407
+ if (tokens.includes("pro"))
408
+ return "pro";
409
+ if (tokens.includes("thinking"))
410
+ return "thinking";
411
+ if (tokens.includes("instant"))
412
+ return "instant";
413
+ return null;
414
+ }
415
+ export function inferThinkingTargetModelKindForTest(desiredModel) {
416
+ return inferThinkingTargetModelKind(desiredModel);
303
417
  }
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = "https://chatgpt.com/";
2
- export const DEFAULT_MODEL_TARGET = "GPT-5.5 Pro";
2
+ export const DEFAULT_MODEL_TARGET = "Pro";
3
3
  export const DEFAULT_MODEL_STRATEGY = "select";
4
4
  export const COOKIE_URLS = [
5
5
  "https://chatgpt.com",