@steipete/oracle 0.7.3 → 0.7.5

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.
package/README.md CHANGED
@@ -64,6 +64,12 @@ Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser i
64
64
  ```
65
65
  - Tip: set `browser.chatgptUrl` in config (or `--chatgpt-url`) to a dedicated ChatGPT project folder so browser runs don’t clutter your main history.
66
66
 
67
+ **Codex skill**
68
+ - Copy the bundled skill from this repo to your Codex skills folder:
69
+ - `mkdir -p ~/.codex/skills`
70
+ - `cp -R skills/oracle ~/.codex/skills/oracle`
71
+ - Then reference it in your `AGENTS.md`/`CLAUDE.md` so Codex loads it.
72
+
67
73
  **MCP**
68
74
  - Run the stdio server via `oracle-mcp`.
69
75
  - Configure clients via [steipete/mcporter](https://github.com/steipete/mcporter) or `.mcp.json`; see [docs/mcp.md](docs/mcp.md) for connection examples.
@@ -174,7 +174,9 @@ program
174
174
  .addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
175
175
  .addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
176
176
  .addOption(new Option('--browser-keep-browser', 'Keep Chrome running after completion.').hideHelp())
177
- .addOption(new Option('--browser-extended-thinking', 'Select Extended thinking time for GPT-5.2 Thinking model.').hideHelp())
177
+ .addOption(new Option('--browser-thinking-time <level>', 'Thinking time intensity for Thinking/Pro models: light, standard, extended, heavy.')
178
+ .choices(['light', 'standard', 'extended', 'heavy'])
179
+ .hideHelp())
178
180
  .addOption(new Option('--browser-allow-cookie-errors', 'Continue even if Chrome cookies cannot be copied.').hideHelp())
179
181
  .addOption(new Option('--browser-attachments <mode>', 'How to deliver --file inputs in browser mode: auto (default) pastes inline up to ~60k chars then uploads; never always paste inline; always always upload.')
180
182
  .choices(['auto', 'never', 'always'])
@@ -177,12 +177,15 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
177
177
  }
178
178
  await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
179
179
  await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
180
- const probeDeadline = Date.now() + 4000;
180
+ await delay(350);
181
+ const probeDeadline = Date.now() + 6500;
182
+ const pokeIntervalMs = 1200;
181
183
  let lastPoke = 0;
184
+ let seenInputHasFile = false;
182
185
  while (Date.now() < probeDeadline) {
183
186
  // ChatGPT's composer can take a moment to hydrate the file-input onChange handler after navigation/model switches.
184
187
  // If the UI hasn't reacted yet, poke the input a few times to ensure the handler fires once it's mounted.
185
- if (Date.now() - lastPoke > 650) {
188
+ if (!seenInputHasFile && Date.now() - lastPoke > pokeIntervalMs) {
186
189
  lastPoke = Date.now();
187
190
  await runtime.evaluate({ expression: dispatchEventsFor(idx), returnByValue: true }).catch(() => undefined);
188
191
  }
@@ -198,6 +201,7 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
198
201
  composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
199
202
  };
200
203
  const inputHasFile = finalSnapshot.inputNames.some((name) => name.toLowerCase().includes(expectedName.toLowerCase()));
204
+ seenInputHasFile = seenInputHasFile || inputHasFile;
201
205
  const expectedLower = expectedName.toLowerCase();
202
206
  const expectedNoExt = expectedLower.replace(/\.[a-z0-9]{1,10}$/i, '');
203
207
  const uiAcknowledged = finalSnapshot.chipCount > baselineChipCount ||
@@ -209,7 +213,7 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
209
213
  break;
210
214
  }
211
215
  }
212
- await delay(200);
216
+ await delay(250);
213
217
  }
214
218
  const inputHasFile = finalSnapshot?.inputNames?.some((name) => name.toLowerCase().includes(expectedName.toLowerCase())) ?? false;
215
219
  const uiAcknowledged = (finalSnapshot?.chipCount ?? 0) > baselineChipCount;
@@ -97,6 +97,10 @@ function buildModelSelectionExpression(targetModel) {
97
97
  if (wantsPro && !normalizedLabel.includes(' pro')) return false;
98
98
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
99
99
  if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
100
+ // Also reject if button has variants we DON'T want
101
+ if (!wantsPro && normalizedLabel.includes(' pro')) return false;
102
+ if (!wantsInstant && normalizedLabel.includes('instant')) return false;
103
+ if (!wantsThinking && normalizedLabel.includes('thinking')) return false;
100
104
  return true;
101
105
  };
102
106
 
@@ -172,14 +176,21 @@ function buildModelSelectionExpression(targetModel) {
172
176
  return 0;
173
177
  }
174
178
  }
175
- const matches = TEST_IDS.filter((id) => id && normalizedTestId.includes(id));
176
- if (matches.length > 0) {
177
- // Prefer the most specific match (longest token) instead of treating any hit as equal.
178
- // This prevents generic tokens (e.g. "pro") from outweighing version-specific targets.
179
- const best = matches.reduce((acc, token) => (token.length > acc.length ? token : acc), '');
180
- score += 200 + Math.min(900, best.length * 25);
181
- if (best.startsWith('model-switcher-')) score += 120;
182
- if (best.includes('gpt-')) score += 60;
179
+ // Exact testid matches take priority over substring matches
180
+ const exactMatch = TEST_IDS.find((id) => id && normalizedTestId === id);
181
+ if (exactMatch) {
182
+ score += 1500;
183
+ if (exactMatch.startsWith('model-switcher-')) score += 200;
184
+ } else {
185
+ const matches = TEST_IDS.filter((id) => id && normalizedTestId.includes(id));
186
+ if (matches.length > 0) {
187
+ // Prefer the most specific match (longest token) instead of treating any hit as equal.
188
+ // This prevents generic tokens (e.g. "pro") from outweighing version-specific targets.
189
+ const best = matches.reduce((acc, token) => (token.length > acc.length ? token : acc), '');
190
+ score += 200 + Math.min(900, best.length * 25);
191
+ if (best.startsWith('model-switcher-')) score += 120;
192
+ if (best.includes('gpt-')) score += 60;
193
+ }
183
194
  }
184
195
  }
185
196
  if (normalizedText && normalizedTarget) {
@@ -215,6 +226,22 @@ function buildModelSelectionExpression(targetModel) {
215
226
  } else if (normalizedText.includes(' pro')) {
216
227
  score -= 40;
217
228
  }
229
+ // Similarly for Thinking variant
230
+ if (wantsThinking) {
231
+ if (!normalizedText.includes('thinking') && !normalizedTestId.includes('thinking')) {
232
+ score -= 80;
233
+ }
234
+ } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
235
+ score -= 40;
236
+ }
237
+ // Similarly for Instant variant
238
+ if (wantsInstant) {
239
+ if (!normalizedText.includes('instant') && !normalizedTestId.includes('instant')) {
240
+ score -= 80;
241
+ }
242
+ } else if (normalizedText.includes('instant') || normalizedTestId.includes('instant')) {
243
+ score -= 40;
244
+ }
218
245
  return Math.max(score, 0);
219
246
  };
220
247
 
@@ -377,10 +404,24 @@ function buildModelMatchersLiteral(targetModel) {
377
404
  push('gpt5-2', labelTokens);
378
405
  push('gpt52', labelTokens);
379
406
  push('chatgpt 5.2', labelTokens);
380
- if (base.includes('thinking'))
407
+ // Thinking variant: explicit testid for "Thinking" picker option
408
+ if (base.includes('thinking')) {
381
409
  push('thinking', labelTokens);
382
- if (base.includes('instant'))
410
+ testIdTokens.add('model-switcher-gpt-5-2-thinking');
411
+ testIdTokens.add('gpt-5-2-thinking');
412
+ testIdTokens.add('gpt-5.2-thinking');
413
+ }
414
+ // Instant variant: explicit testid for "Instant" picker option
415
+ if (base.includes('instant')) {
383
416
  push('instant', labelTokens);
417
+ testIdTokens.add('model-switcher-gpt-5-2-instant');
418
+ testIdTokens.add('gpt-5-2-instant');
419
+ testIdTokens.add('gpt-5.2-instant');
420
+ }
421
+ // Base 5.2 testids (for "Auto" mode when no suffix specified)
422
+ if (!base.includes('thinking') && !base.includes('instant') && !base.includes('pro')) {
423
+ testIdTokens.add('model-switcher-gpt-5-2');
424
+ }
384
425
  testIdTokens.add('gpt-5-2');
385
426
  testIdTokens.add('gpt5-2');
386
427
  testIdTokens.add('gpt52');
@@ -1,14 +1,19 @@
1
1
  import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR } from '../constants.js';
2
2
  import { logDomFailure } from '../domDebug.js';
3
3
  import { buildClickDispatcher } from './domEvents.js';
4
- export async function ensureExtendedThinking(Runtime, logger) {
5
- const result = await evaluateThinkingTimeSelection(Runtime);
4
+ /**
5
+ * Selects a specific thinking time level in ChatGPT's composer pill menu.
6
+ * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
7
+ */
8
+ export async function ensureThinkingTime(Runtime, level, logger) {
9
+ const result = await evaluateThinkingTimeSelection(Runtime, level);
10
+ const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
6
11
  switch (result?.status) {
7
- case 'already-extended':
8
- logger(`Thinking time: ${result.label ?? 'Extended'} (already selected)`);
12
+ case 'already-selected':
13
+ logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
9
14
  return;
10
15
  case 'switched':
11
- logger(`Thinking time: ${result.label ?? 'Extended'}`);
16
+ logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
12
17
  return;
13
18
  case 'chip-not-found': {
14
19
  await logDomFailure(Runtime, logger, 'thinking-chip');
@@ -18,33 +23,35 @@ export async function ensureExtendedThinking(Runtime, logger) {
18
23
  await logDomFailure(Runtime, logger, 'thinking-time-menu');
19
24
  throw new Error('Unable to find the Thinking time dropdown menu.');
20
25
  }
21
- case 'extended-not-found': {
22
- await logDomFailure(Runtime, logger, 'extended-option');
23
- throw new Error('Unable to find the Extended option in the Thinking time menu.');
26
+ case 'option-not-found': {
27
+ await logDomFailure(Runtime, logger, `${level}-option`);
28
+ throw new Error(`Unable to find the ${capitalizedLevel} option in the Thinking time menu.`);
24
29
  }
25
30
  default: {
26
31
  await logDomFailure(Runtime, logger, 'thinking-time-unknown');
27
- throw new Error('Unknown error selecting Extended thinking time.');
32
+ throw new Error(`Unknown error selecting ${capitalizedLevel} thinking time.`);
28
33
  }
29
34
  }
30
35
  }
31
36
  /**
32
- * Best-effort selection of the "Extended" thinking-time option in ChatGPT's composer pill menu.
37
+ * Best-effort selection of a thinking time level in ChatGPT's composer pill menu.
33
38
  * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
39
+ * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
34
40
  */
35
- export async function ensureExtendedThinkingIfAvailable(Runtime, logger) {
41
+ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger) {
36
42
  try {
37
- const result = await evaluateThinkingTimeSelection(Runtime);
43
+ const result = await evaluateThinkingTimeSelection(Runtime, level);
44
+ const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1);
38
45
  switch (result?.status) {
39
- case 'already-extended':
40
- logger(`Thinking time: ${result.label ?? 'Extended'} (already selected)`);
46
+ case 'already-selected':
47
+ logger(`Thinking time: ${result.label ?? capitalizedLevel} (already selected)`);
41
48
  return true;
42
49
  case 'switched':
43
- logger(`Thinking time: ${result.label ?? 'Extended'}`);
50
+ logger(`Thinking time: ${result.label ?? capitalizedLevel}`);
44
51
  return true;
45
52
  case 'chip-not-found':
46
53
  case 'menu-not-found':
47
- case 'extended-not-found':
54
+ case 'option-not-found':
48
55
  if (logger.verbose) {
49
56
  logger(`Thinking time: ${result.status.replaceAll('-', ' ')}; continuing with default.`);
50
57
  }
@@ -65,22 +72,24 @@ export async function ensureExtendedThinkingIfAvailable(Runtime, logger) {
65
72
  return false;
66
73
  }
67
74
  }
68
- async function evaluateThinkingTimeSelection(Runtime) {
75
+ async function evaluateThinkingTimeSelection(Runtime, level) {
69
76
  const outcome = await Runtime.evaluate({
70
- expression: buildThinkingTimeExpression(),
77
+ expression: buildThinkingTimeExpression(level),
71
78
  awaitPromise: true,
72
79
  returnByValue: true,
73
80
  });
74
81
  return outcome.result?.value;
75
82
  }
76
- function buildThinkingTimeExpression() {
83
+ function buildThinkingTimeExpression(level) {
77
84
  const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
78
85
  const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
86
+ const targetLevelLiteral = JSON.stringify(level.toLowerCase());
79
87
  return `(async () => {
80
88
  ${buildClickDispatcher()}
81
89
 
82
90
  const MENU_CONTAINER_SELECTOR = ${menuContainerLiteral};
83
91
  const MENU_ITEM_SELECTOR = ${menuItemLiteral};
92
+ const TARGET_LEVEL = ${targetLevelLiteral};
84
93
 
85
94
  const CHIP_SELECTORS = [
86
95
  '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
@@ -136,11 +145,11 @@ function buildThinkingTimeExpression() {
136
145
  return null;
137
146
  };
138
147
 
139
- const findExtendedOption = (menu) => {
148
+ const findTargetOption = (menu) => {
140
149
  const items = menu.querySelectorAll(MENU_ITEM_SELECTOR);
141
150
  for (const item of items) {
142
151
  const text = normalize(item.textContent ?? '');
143
- if (text.includes('extended')) {
152
+ if (text.includes(TARGET_LEVEL)) {
144
153
  return item;
145
154
  }
146
155
  }
@@ -167,24 +176,24 @@ function buildThinkingTimeExpression() {
167
176
  return;
168
177
  }
169
178
 
170
- const extendedOption = findExtendedOption(menu);
171
- if (!extendedOption) {
172
- resolve({ status: 'extended-not-found' });
179
+ const targetOption = findTargetOption(menu);
180
+ if (!targetOption) {
181
+ resolve({ status: 'option-not-found' });
173
182
  return;
174
183
  }
175
184
 
176
185
  const alreadySelected =
177
- optionIsSelected(extendedOption) ||
178
- optionIsSelected(extendedOption.querySelector?.('[aria-checked="true"], [data-state="checked"], [data-state="selected"]'));
179
- const label = extendedOption.textContent?.trim?.() || null;
180
- dispatchClickSequence(extendedOption);
181
- resolve({ status: alreadySelected ? 'already-extended' : 'switched', label });
186
+ optionIsSelected(targetOption) ||
187
+ optionIsSelected(targetOption.querySelector?.('[aria-checked="true"], [data-state="checked"], [data-state="selected"]'));
188
+ const label = targetOption.textContent?.trim?.() || null;
189
+ dispatchClickSequence(targetOption);
190
+ resolve({ status: alreadySelected ? 'already-selected' : 'switched', label });
182
191
  };
183
192
 
184
193
  setTimeout(attempt, INITIAL_WAIT_MS);
185
194
  });
186
195
  })()`;
187
196
  }
188
- export function buildThinkingTimeExpressionForTest() {
189
- return buildThinkingTimeExpression();
197
+ export function buildThinkingTimeExpressionForTest(level = 'extended') {
198
+ return buildThinkingTimeExpression(level);
190
199
  }
@@ -24,7 +24,6 @@ export const DEFAULT_BROWSER_CONFIG = {
24
24
  remoteChrome: null,
25
25
  manualLogin: false,
26
26
  manualLoginProfileDir: null,
27
- extendedThinking: false,
28
27
  };
29
28
  export function resolveBrowserConfig(config) {
30
29
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
@@ -64,6 +63,7 @@ export function resolveBrowserConfig(config) {
64
63
  chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
65
64
  debug: config?.debug ?? DEFAULT_BROWSER_CONFIG.debug,
66
65
  allowCookieErrors: config?.allowCookieErrors ?? envAllowCookieErrors ?? DEFAULT_BROWSER_CONFIG.allowCookieErrors,
66
+ thinkingTime: config?.thinkingTime,
67
67
  manualLogin,
68
68
  manualLoginProfileDir: manualLogin ? resolvedProfileDir : null,
69
69
  };
@@ -7,7 +7,7 @@ import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChro
7
7
  import { syncCookies } from './cookies.js';
8
8
  import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
- import { ensureExtendedThinking } from './actions/thinkingTime.js';
10
+ import { ensureThinkingTime } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
12
12
  import { formatElapsed } from '../oracle/format.js';
13
13
  import { CHATGPT_URL } from './constants.js';
@@ -266,13 +266,15 @@ export async function runBrowserMode(options) {
266
266
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
267
267
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
268
268
  }
269
- if (config.extendedThinking) {
270
- await raceWithDisconnect(withRetries(() => ensureExtendedThinking(Runtime, logger), {
269
+ // Handle thinking time selection if specified
270
+ const thinkingTime = config.thinkingTime;
271
+ if (thinkingTime) {
272
+ await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
271
273
  retries: 2,
272
274
  delayMs: 300,
273
275
  onRetry: (attempt, error) => {
274
276
  if (options.verbose) {
275
- logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
277
+ logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
276
278
  }
277
279
  },
278
280
  }));
@@ -286,6 +288,7 @@ export async function runBrowserMode(options) {
286
288
  for (const attachment of submissionAttachments) {
287
289
  logger(`Uploading attachment: ${attachment.displayPath}`);
288
290
  await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
291
+ await delay(500);
289
292
  }
290
293
  // Scale timeout based on number of files: base 30s + 15s per additional file
291
294
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
@@ -317,7 +320,7 @@ export async function runBrowserMode(options) {
317
320
  }
318
321
  }
319
322
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
320
- const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
323
+ const answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger));
321
324
  answerText = answer.text;
322
325
  answerHtml = answer.html ?? '';
323
326
  const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
@@ -663,13 +666,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
663
666
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
664
667
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
665
668
  }
666
- if (config.extendedThinking) {
667
- await withRetries(() => ensureExtendedThinking(Runtime, logger), {
669
+ // Handle thinking time selection if specified
670
+ const thinkingTime = config.thinkingTime;
671
+ if (thinkingTime) {
672
+ await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
668
673
  retries: 2,
669
674
  delayMs: 300,
670
675
  onRetry: (attempt, error) => {
671
676
  if (options.verbose) {
672
- logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
677
+ logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
673
678
  }
674
679
  },
675
680
  });
@@ -684,6 +689,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
684
689
  for (const attachment of submissionAttachments) {
685
690
  logger(`Uploading attachment: ${attachment.displayPath}`);
686
691
  await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
692
+ await delay(500);
687
693
  }
688
694
  // Scale timeout based on number of files: base 30s + 15s per additional file
689
695
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
@@ -711,7 +717,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
711
717
  }
712
718
  }
713
719
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
714
- const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
720
+ const answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger);
715
721
  answerText = answer.text;
716
722
  answerHtml = answer.html ?? '';
717
723
  const copiedMarkdown = await withRetries(async () => {
@@ -861,6 +867,42 @@ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
861
867
  const statusLabel = message ? ` — ${message}` : '';
862
868
  return `${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
863
869
  }
870
+ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger) {
871
+ try {
872
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger);
873
+ }
874
+ catch (error) {
875
+ if (!shouldReloadAfterAssistantError(error)) {
876
+ throw error;
877
+ }
878
+ const conversationUrl = await readConversationUrl(Runtime);
879
+ if (!conversationUrl || !isConversationUrl(conversationUrl)) {
880
+ throw error;
881
+ }
882
+ logger('Assistant response stalled; reloading conversation and retrying once');
883
+ await Page.navigate({ url: conversationUrl });
884
+ await delay(1000);
885
+ return await waitForAssistantResponse(Runtime, timeoutMs, logger);
886
+ }
887
+ }
888
+ function shouldReloadAfterAssistantError(error) {
889
+ if (!(error instanceof Error))
890
+ return false;
891
+ const message = error.message.toLowerCase();
892
+ return message.includes('assistant-response') || message.includes('watchdog') || message.includes('timeout');
893
+ }
894
+ async function readConversationUrl(Runtime) {
895
+ try {
896
+ const currentUrl = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
897
+ return typeof currentUrl.result?.value === 'string' ? currentUrl.result.value : null;
898
+ }
899
+ catch {
900
+ return null;
901
+ }
902
+ }
903
+ function isConversationUrl(url) {
904
+ return /\/c\/[a-z0-9-]+/i.test(url);
905
+ }
864
906
  function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
865
907
  let stopped = false;
866
908
  let pending = false;
@@ -5,17 +5,20 @@ import { getOracleHomeDir } from '../oracleHome.js';
5
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
6
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
7
7
  const DEFAULT_CHROME_PROFILE = 'Default';
8
- const BROWSER_MODEL_LABELS = {
9
- // Browser engine supports GPT-5.2 and GPT-5.2 Pro (legacy/Pro aliases normalize to those targets).
10
- 'gpt-5-pro': 'GPT-5.2 Pro',
11
- 'gpt-5.1-pro': 'GPT-5.2 Pro',
12
- 'gpt-5.1': 'GPT-5.2',
13
- 'gpt-5.2': 'GPT-5.2',
14
- // ChatGPT UI doesn't expose "instant" as a separate picker option; treat it as GPT-5.2 for browser automation.
15
- 'gpt-5.2-instant': 'GPT-5.2',
16
- 'gpt-5.2-pro': 'GPT-5.2 Pro',
17
- 'gemini-3-pro': 'Gemini 3 Pro',
18
- };
8
+ // Ordered array: most specific models first to ensure correct selection.
9
+ // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
10
+ const BROWSER_MODEL_LABELS = [
11
+ // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
12
+ ['gpt-5.2-thinking', 'GPT-5.2 Thinking'],
13
+ ['gpt-5.2-instant', 'GPT-5.2 Instant'],
14
+ ['gpt-5.2-pro', 'GPT-5.2 Pro'],
15
+ ['gpt-5.1-pro', 'GPT-5.2 Pro'],
16
+ ['gpt-5-pro', 'GPT-5.2 Pro'],
17
+ // Base models last (least specific)
18
+ ['gpt-5.2', 'GPT-5.2'], // Selects "Auto" in ChatGPT UI
19
+ ['gpt-5.1', 'GPT-5.2'], // Legacy alias → Auto
20
+ ['gemini-3-pro', 'Gemini 3 Pro'],
21
+ ];
19
22
  export function normalizeChatGptModelForBrowser(model) {
20
23
  const normalized = model.toLowerCase();
21
24
  if (!normalized.startsWith('gpt-') || normalized.includes('codex')) {
@@ -25,10 +28,11 @@ export function normalizeChatGptModelForBrowser(model) {
25
28
  if (normalized === 'gpt-5-pro' || normalized === 'gpt-5.1-pro' || normalized.endsWith('-pro')) {
26
29
  return 'gpt-5.2-pro';
27
30
  }
28
- // Legacy / UI-mismatch variants: map to the closest ChatGPT picker target.
29
- if (normalized === 'gpt-5.2-instant') {
30
- return 'gpt-5.2';
31
+ // Explicit model variants: keep as-is (they have their own browser labels)
32
+ if (normalized === 'gpt-5.2-thinking' || normalized === 'gpt-5.2-instant') {
33
+ return normalized;
31
34
  }
35
+ // Legacy aliases: map to base GPT-5.2 (Auto)
32
36
  if (normalized === 'gpt-5.1') {
33
37
  return 'gpt-5.2';
34
38
  }
@@ -86,7 +90,7 @@ export async function buildBrowserConfig(options) {
86
90
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
87
91
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
88
92
  remoteChrome,
89
- extendedThinking: options.browserExtendedThinking ? true : undefined,
93
+ thinkingTime: options.browserThinkingTime,
90
94
  };
91
95
  }
92
96
  function selectBrowserPort(options) {
@@ -100,7 +104,13 @@ function selectBrowserPort(options) {
100
104
  }
101
105
  export function mapModelToBrowserLabel(model) {
102
106
  const normalized = normalizeChatGptModelForBrowser(model);
103
- return BROWSER_MODEL_LABELS[normalized] ?? DEFAULT_MODEL_TARGET;
107
+ // Iterate ordered array to find first match (most specific first)
108
+ for (const [key, label] of BROWSER_MODEL_LABELS) {
109
+ if (key === normalized) {
110
+ return label;
111
+ }
112
+ }
113
+ return DEFAULT_MODEL_TARGET;
104
114
  }
105
115
  export function resolveBrowserModelLabel(input, model) {
106
116
  const trimmed = input?.trim?.() ?? '';
@@ -187,6 +187,13 @@ export function inferModelFromLabel(modelValue) {
187
187
  if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('pro')) {
188
188
  return 'gpt-5.2-pro';
189
189
  }
190
+ // Browser-only: pass through 5.2 thinking/instant variants for browser label mapping
191
+ if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('thinking')) {
192
+ return 'gpt-5.2-thinking';
193
+ }
194
+ if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('instant')) {
195
+ return 'gpt-5.2-instant';
196
+ }
190
197
  if (normalized.includes('5.0') || normalized.includes('5-pro')) {
191
198
  return 'gpt-5-pro';
192
199
  }
@@ -205,8 +212,11 @@ export function inferModelFromLabel(modelValue) {
205
212
  if (normalized.includes('5.1') || normalized.includes('5_1')) {
206
213
  return 'gpt-5.1';
207
214
  }
208
- if (normalized.includes('instant') || normalized.includes('thinking') || normalized.includes('fast')) {
209
- return 'gpt-5.1';
215
+ if (normalized.includes('thinking')) {
216
+ return 'gpt-5.2-thinking';
217
+ }
218
+ if (normalized.includes('instant') || normalized.includes('fast')) {
219
+ return 'gpt-5.2-instant';
210
220
  }
211
- return 'gpt-5.1';
221
+ return 'gpt-5.2';
212
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -83,7 +83,7 @@
83
83
  "shiki": "^3.20.0",
84
84
  "sqlite3": "^5.1.7",
85
85
  "toasted-notifier": "^10.1.0",
86
- "tokentally": "github:steipete/tokentally#v0.1.0",
86
+ "tokentally": "^0.1.1",
87
87
  "zod": "^4.2.1"
88
88
  },
89
89
  "devDependencies": {