@steipete/oracle 0.5.6 → 0.6.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 (34) hide show
  1. package/README.md +3 -3
  2. package/dist/bin/oracle-cli.js +8 -2
  3. package/dist/src/browser/actions/assistantResponse.js +65 -6
  4. package/dist/src/browser/actions/modelSelection.js +22 -0
  5. package/dist/src/browser/actions/promptComposer.js +67 -3
  6. package/dist/src/browser/actions/thinkingTime.js +190 -0
  7. package/dist/src/browser/config.js +1 -0
  8. package/dist/src/browser/constants.js +1 -1
  9. package/dist/src/browser/index.js +106 -74
  10. package/dist/src/browser/pageActions.js +1 -1
  11. package/dist/src/browser/profileState.js +171 -0
  12. package/dist/src/browser/prompt.js +63 -19
  13. package/dist/src/browser/sessionRunner.js +10 -6
  14. package/dist/src/cli/browserConfig.js +6 -2
  15. package/dist/src/cli/sessionDisplay.js +8 -1
  16. package/dist/src/cli/sessionRunner.js +2 -8
  17. package/dist/src/cli/tui/index.js +1 -0
  18. package/dist/src/config.js +2 -3
  19. package/dist/src/oracle/config.js +38 -3
  20. package/dist/src/oracle/errors.js +3 -3
  21. package/dist/src/oracle/gemini.js +4 -1
  22. package/dist/src/oracle/run.js +4 -7
  23. package/dist/src/oracleHome.js +13 -0
  24. package/dist/src/remote/server.js +17 -11
  25. package/dist/src/sessionManager.js +10 -8
  26. package/dist/src/sessionStore.js +2 -2
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  28. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  29. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  31. package/package.json +22 -38
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  34. package/vendor/oracle-notifier/README.md +0 -24
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License"></a>
12
12
  </p>
13
13
 
14
- Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default), GPT-5.1 Codex (API-only), GPT-5.1, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; API remains the most reliable path, and `--copy` is an easy manual fallback.
14
+ Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default alias to GPT-5.2 Pro on the API), GPT-5.1 Codex (API-only), GPT-5.1, GPT-5.2, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; API remains the most reliable path, and `--copy` is an easy manual fallback.
15
15
 
16
16
  ## Quick start
17
17
 
@@ -95,7 +95,7 @@ npx -y @steipete/oracle oracle-mcp
95
95
  | `-p, --prompt <text>` | Required prompt. |
96
96
  | `-f, --file <paths...>` | Attach files/dirs (globs + `!` excludes). |
97
97
  | `-e, --engine <api\|browser>` | Choose API or browser (browser is experimental). |
98
- | `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
98
+ | `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gpt-5.2`, `gpt-5.2-instant`, `gpt-5.2-pro`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
99
99
  | `--models <list>` | Comma-separated API models (mix built-ins and OpenRouter ids) for multi-model runs. |
100
100
  | `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
101
101
  | `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
@@ -131,7 +131,7 @@ Advanced flags
131
131
 
132
132
  | Area | Flags |
133
133
  | --- | --- |
134
- | Browser | `--browser-timeout`, `--browser-input-timeout`, `--browser-inline-cookies[(-file)]`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
134
+ | Browser | `--browser-timeout`, `--browser-input-timeout`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
135
135
  | Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
136
136
 
137
137
  Remote browser example
@@ -110,7 +110,7 @@ program
110
110
  .addOption(new Option('--copy-markdown', 'Copy the assembled markdown bundle to the clipboard; pair with --render to print it too.').default(false))
111
111
  .addOption(new Option('--copy').hideHelp().default(false))
112
112
  .option('-s, --slug <words>', 'Custom session slug (3-5 words).')
113
- .option('-m, --model <model>', 'Model to target (gpt-5.1-pro default; also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.1 Instant" for browser runs).', normalizeModelOption)
113
+ .option('-m, --model <model>', 'Model to target (gpt-5.1-pro default; aliases to gpt-5.2-pro on API. Also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).', normalizeModelOption)
114
114
  .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.1-pro,gemini-3-pro").')
115
115
  .argParser(collectModelList)
116
116
  .default([]))
@@ -172,11 +172,15 @@ program
172
172
  .addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
173
173
  .addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
174
174
  .addOption(new Option('--browser-keep-browser', 'Keep Chrome running after completion.').hideHelp())
175
+ .addOption(new Option('--browser-extended-thinking', 'Select Extended thinking time for GPT-5.2 Thinking model.').hideHelp())
175
176
  .addOption(new Option('--browser-allow-cookie-errors', 'Continue even if Chrome cookies cannot be copied.').hideHelp())
177
+ .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.')
178
+ .choices(['auto', 'never', 'always'])
179
+ .default('auto'))
176
180
  .addOption(new Option('--remote-chrome <host:port>', 'Connect to remote Chrome DevTools Protocol (e.g., 192.168.1.10:9222 or [2001:db8::1]:9222 for IPv6).'))
177
181
  .addOption(new Option('--remote-host <host:port>', 'Delegate browser runs to a remote `oracle serve` instance.'))
178
182
  .addOption(new Option('--remote-token <token>', 'Access token for the remote `oracle serve` instance.'))
179
- .addOption(new Option('--browser-inline-files', 'Paste files directly into the ChatGPT composer instead of uploading attachments.').default(false))
183
+ .addOption(new Option('--browser-inline-files', 'Alias for --browser-attachments never (force pasting file contents inline).').default(false))
180
184
  .addOption(new Option('--browser-bundle-files', 'Bundle all attachments into a single archive before uploading.').default(false))
181
185
  .option('--retain-hours <hours>', 'Prune stored sessions older than this many hours before running (set 0 to disable).', parseFloatOption)
182
186
  .option('--force', 'Force start a new session even if an identical prompt is already running.', false)
@@ -317,6 +321,7 @@ function buildRunOptions(options, overrides = {}) {
317
321
  sessionId: overrides.sessionId ?? options.sessionId,
318
322
  verbose: overrides.verbose ?? options.verbose,
319
323
  heartbeatIntervalMs: overrides.heartbeatIntervalMs ?? resolveHeartbeatIntervalMs(options.heartbeat),
324
+ browserAttachments: overrides.browserAttachments ?? options.browserAttachments ?? 'auto',
320
325
  browserInlineFiles: overrides.browserInlineFiles ?? options.browserInlineFiles ?? false,
321
326
  browserBundleFiles: overrides.browserBundleFiles ?? options.browserBundleFiles ?? false,
322
327
  background: overrides.background ?? undefined,
@@ -359,6 +364,7 @@ function buildRunOptionsFromMetadata(metadata) {
359
364
  sessionId: metadata.id,
360
365
  verbose: stored.verbose,
361
366
  heartbeatIntervalMs: stored.heartbeatIntervalMs,
367
+ browserAttachments: stored.browserAttachments,
362
368
  browserInlineFiles: stored.browserInlineFiles,
363
369
  browserBundleFiles: stored.browserBundleFiles,
364
370
  background: stored.background,
@@ -183,7 +183,9 @@ async function pollAssistantCompletion(Runtime, timeoutMs) {
183
183
  isStopButtonVisible(Runtime),
184
184
  isCompletionVisible(Runtime),
185
185
  ]);
186
- if (completionVisible || (!stopVisible && stableCycles >= requiredStableCycles)) {
186
+ // Require at least 2 stable cycles even when completion buttons are visible
187
+ // to ensure DOM text has fully rendered (buttons can appear before text settles)
188
+ if ((completionVisible && stableCycles >= 2) || (!stopVisible && stableCycles >= requiredStableCycles)) {
187
189
  return normalized;
188
190
  }
189
191
  }
@@ -211,10 +213,36 @@ async function isCompletionVisible(Runtime) {
211
213
  try {
212
214
  const { result } = await Runtime.evaluate({
213
215
  expression: `(() => {
214
- if (document.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
216
+ // Find the LAST assistant turn to check completion status
217
+ // Must match the same logic as buildAssistantExtractor for consistency
218
+ const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
219
+ const isAssistantTurn = (node) => {
220
+ if (!(node instanceof HTMLElement)) return false;
221
+ const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
222
+ if (role === 'assistant') return true;
223
+ const testId = (node.getAttribute('data-testid') || '').toLowerCase();
224
+ if (testId.includes('assistant')) return true;
225
+ return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
226
+ };
227
+
228
+ const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
229
+ let lastAssistantTurn = null;
230
+ for (let i = turns.length - 1; i >= 0; i--) {
231
+ if (isAssistantTurn(turns[i])) {
232
+ lastAssistantTurn = turns[i];
233
+ break;
234
+ }
235
+ }
236
+ if (!lastAssistantTurn) {
237
+ return false;
238
+ }
239
+ // Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
240
+ if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
215
241
  return true;
216
242
  }
217
- return Array.from(document.querySelectorAll('.markdown')).some((n) => (n.textContent || '').trim() === 'Done');
243
+ // Also check for "Done" text in the last assistant turn's markdown
244
+ const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
245
+ return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
218
246
  })()`,
219
247
  returnByValue: true,
220
248
  });
@@ -257,12 +285,27 @@ function buildAssistantSnapshotExpression() {
257
285
  }
258
286
  function buildResponseObserverExpression(timeoutMs) {
259
287
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
288
+ const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
289
+ const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
260
290
  return `(() => {
261
291
  ${buildClickDispatcher()}
262
292
  const SELECTORS = ${selectorsLiteral};
263
293
  const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
264
294
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
295
+ const CONVERSATION_SELECTOR = ${conversationLiteral};
296
+ const ASSISTANT_SELECTOR = ${assistantLiteral};
265
297
  const settleDelayMs = 800;
298
+
299
+ // Helper to detect assistant turns - matches buildAssistantExtractor logic
300
+ const isAssistantTurn = (node) => {
301
+ if (!(node instanceof HTMLElement)) return false;
302
+ const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
303
+ if (role === 'assistant') return true;
304
+ const testId = (node.getAttribute('data-testid') || '').toLowerCase();
305
+ if (testId.includes('assistant')) return true;
306
+ return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
307
+ };
308
+
266
309
  ${buildAssistantExtractor('extractFromTurns')}
267
310
 
268
311
  const captureViaObserver = () =>
@@ -307,6 +350,24 @@ function buildResponseObserverExpression(timeoutMs) {
307
350
  }, ${timeoutMs});
308
351
  });
309
352
 
353
+ // Check if the last assistant turn has finished (scoped to avoid detecting old turns)
354
+ const isLastAssistantTurnFinished = () => {
355
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
356
+ let lastAssistantTurn = null;
357
+ for (let i = turns.length - 1; i >= 0; i--) {
358
+ if (isAssistantTurn(turns[i])) {
359
+ lastAssistantTurn = turns[i];
360
+ break;
361
+ }
362
+ }
363
+ if (!lastAssistantTurn) return false;
364
+ // Check for action buttons in this specific turn
365
+ if (lastAssistantTurn.querySelector(FINISHED_SELECTOR)) return true;
366
+ // Check for "Done" text in this turn's markdown
367
+ const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
368
+ return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
369
+ };
370
+
310
371
  const waitForSettle = async (snapshot) => {
311
372
  const settleWindowMs = 5000;
312
373
  const settleIntervalMs = 400;
@@ -321,9 +382,7 @@ function buildResponseObserverExpression(timeoutMs) {
321
382
  lastLength = refreshed.text?.length ?? lastLength;
322
383
  }
323
384
  const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
324
- const finishedVisible =
325
- Boolean(document.querySelector(FINISHED_SELECTOR)) ||
326
- Array.from(document.querySelectorAll('.markdown')).some((n) => (n.textContent || '').trim() === 'Done');
385
+ const finishedVisible = isLastAssistantTurnFinished();
327
386
 
328
387
  if (!stopVisible || finishedVisible) {
329
388
  break;
@@ -248,6 +248,23 @@ function buildModelMatchersLiteral(targetModel) {
248
248
  testIdTokens.add('gpt5-0');
249
249
  testIdTokens.add('gpt50');
250
250
  }
251
+ // Numeric variations (5.2 ↔ 52 ↔ gpt-5-2)
252
+ if (base.includes('5.2') || base.includes('5-2') || base.includes('52')) {
253
+ push('5.2', labelTokens);
254
+ push('gpt-5.2', labelTokens);
255
+ push('gpt5.2', labelTokens);
256
+ push('gpt-5-2', labelTokens);
257
+ push('gpt5-2', labelTokens);
258
+ push('gpt52', labelTokens);
259
+ push('chatgpt 5.2', labelTokens);
260
+ if (base.includes('thinking'))
261
+ push('thinking', labelTokens);
262
+ if (base.includes('instant'))
263
+ push('instant', labelTokens);
264
+ testIdTokens.add('gpt-5-2');
265
+ testIdTokens.add('gpt5-2');
266
+ testIdTokens.add('gpt52');
267
+ }
251
268
  // Pro / research variants
252
269
  if (base.includes('pro')) {
253
270
  push('proresearch', labelTokens);
@@ -263,6 +280,11 @@ function buildModelMatchersLiteral(targetModel) {
263
280
  testIdTokens.add('gpt-5-0-pro');
264
281
  testIdTokens.add('gpt50pro');
265
282
  }
283
+ if (base.includes('5.2') || base.includes('5-2') || base.includes('52')) {
284
+ testIdTokens.add('gpt-5.2-pro');
285
+ testIdTokens.add('gpt-5-2-pro');
286
+ testIdTokens.add('gpt52pro');
287
+ }
266
288
  testIdTokens.add('pro');
267
289
  testIdTokens.add('proresearch');
268
290
  }
@@ -2,6 +2,7 @@ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEN
2
2
  import { delay } from '../utils.js';
3
3
  import { logDomFailure } from '../domDebug.js';
4
4
  import { buildClickDispatcher } from './domEvents.js';
5
+ import { BrowserAutomationError } from '../../oracle/errors.js';
5
6
  const ENTER_KEY_EVENT = {
6
7
  key: 'Enter',
7
8
  code: 'Enter',
@@ -70,9 +71,11 @@ export async function submitPrompt(deps, prompt, logger) {
70
71
  })()`,
71
72
  returnByValue: true,
72
73
  });
73
- const editorText = verification.result?.value?.editorText?.trim?.() ?? '';
74
- const fallbackValue = verification.result?.value?.fallbackValue?.trim?.() ?? '';
75
- if (!editorText && !fallbackValue) {
74
+ const editorTextRaw = verification.result?.value?.editorText ?? '';
75
+ const fallbackValueRaw = verification.result?.value?.fallbackValue ?? '';
76
+ const editorTextTrimmed = editorTextRaw?.trim?.() ?? '';
77
+ const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? '';
78
+ if (!editorTextTrimmed && !fallbackValueTrimmed) {
76
79
  await runtime.evaluate({
77
80
  expression: `(() => {
78
81
  const fallback = document.querySelector(${fallbackSelectorLiteral});
@@ -90,6 +93,30 @@ export async function submitPrompt(deps, prompt, logger) {
90
93
  })()`,
91
94
  });
92
95
  }
96
+ const promptLength = prompt.length;
97
+ const postVerification = await runtime.evaluate({
98
+ expression: `(() => {
99
+ const editor = document.querySelector(${primarySelectorLiteral});
100
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
101
+ return {
102
+ editorText: editor?.innerText ?? '',
103
+ fallbackValue: fallback?.value ?? '',
104
+ };
105
+ })()`,
106
+ returnByValue: true,
107
+ });
108
+ const observedEditor = postVerification.result?.value?.editorText ?? '';
109
+ const observedFallback = postVerification.result?.value?.fallbackValue ?? '';
110
+ const observedLength = Math.max(observedEditor.length, observedFallback.length);
111
+ if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
112
+ await logDomFailure(runtime, logger, 'prompt-too-large');
113
+ throw new BrowserAutomationError('Prompt appears truncated in the composer (likely too large).', {
114
+ stage: 'submit-prompt',
115
+ code: 'prompt-too-large',
116
+ promptLength,
117
+ observedLength,
118
+ });
119
+ }
93
120
  const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
94
121
  if (!clicked) {
95
122
  await input.dispatchKeyEvent({
@@ -110,6 +137,35 @@ export async function submitPrompt(deps, prompt, logger) {
110
137
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
111
138
  await clickAnswerNowIfPresent(runtime, logger);
112
139
  }
140
+ export async function clearPromptComposer(Runtime, logger) {
141
+ const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
142
+ const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
143
+ const result = await Runtime.evaluate({
144
+ expression: `(() => {
145
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
146
+ const editor = document.querySelector(${primarySelectorLiteral});
147
+ let cleared = false;
148
+ if (fallback) {
149
+ fallback.value = '';
150
+ fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
151
+ fallback.dispatchEvent(new Event('change', { bubbles: true }));
152
+ cleared = true;
153
+ }
154
+ if (editor) {
155
+ editor.textContent = '';
156
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
157
+ cleared = true;
158
+ }
159
+ return { cleared };
160
+ })()`,
161
+ returnByValue: true,
162
+ });
163
+ if (!result.result?.value?.cleared) {
164
+ await logDomFailure(Runtime, logger, 'clear-composer');
165
+ throw new Error('Failed to clear prompt composer');
166
+ }
167
+ await delay(250);
168
+ }
113
169
  async function waitForDomReady(Runtime, logger) {
114
170
  const deadline = Date.now() + 10_000;
115
171
  while (Date.now() < deadline) {
@@ -264,5 +320,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
264
320
  }).then((res) => JSON.stringify(res?.result?.value)).catch(() => 'unavailable')}`);
265
321
  await logDomFailure(Runtime, logger, 'prompt-commit');
266
322
  }
323
+ if (prompt.trim().length >= 50_000) {
324
+ throw new BrowserAutomationError('Prompt did not appear in conversation before timeout (likely too large).', {
325
+ stage: 'submit-prompt',
326
+ code: 'prompt-too-large',
327
+ promptLength: prompt.trim().length,
328
+ timeoutMs,
329
+ });
330
+ }
267
331
  throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
268
332
  }
@@ -0,0 +1,190 @@
1
+ import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR } from '../constants.js';
2
+ import { logDomFailure } from '../domDebug.js';
3
+ import { buildClickDispatcher } from './domEvents.js';
4
+ export async function ensureExtendedThinking(Runtime, logger) {
5
+ const result = await evaluateThinkingTimeSelection(Runtime);
6
+ switch (result?.status) {
7
+ case 'already-extended':
8
+ logger(`Thinking time: ${result.label ?? 'Extended'} (already selected)`);
9
+ return;
10
+ case 'switched':
11
+ logger(`Thinking time: ${result.label ?? 'Extended'}`);
12
+ return;
13
+ case 'chip-not-found': {
14
+ await logDomFailure(Runtime, logger, 'thinking-chip');
15
+ throw new Error('Unable to find the Thinking chip button in the composer area.');
16
+ }
17
+ case 'menu-not-found': {
18
+ await logDomFailure(Runtime, logger, 'thinking-time-menu');
19
+ throw new Error('Unable to find the Thinking time dropdown menu.');
20
+ }
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.');
24
+ }
25
+ default: {
26
+ await logDomFailure(Runtime, logger, 'thinking-time-unknown');
27
+ throw new Error('Unknown error selecting Extended thinking time.');
28
+ }
29
+ }
30
+ }
31
+ /**
32
+ * Best-effort selection of the "Extended" thinking-time option in ChatGPT's composer pill menu.
33
+ * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
34
+ */
35
+ export async function ensureExtendedThinkingIfAvailable(Runtime, logger) {
36
+ try {
37
+ const result = await evaluateThinkingTimeSelection(Runtime);
38
+ switch (result?.status) {
39
+ case 'already-extended':
40
+ logger(`Thinking time: ${result.label ?? 'Extended'} (already selected)`);
41
+ return true;
42
+ case 'switched':
43
+ logger(`Thinking time: ${result.label ?? 'Extended'}`);
44
+ return true;
45
+ case 'chip-not-found':
46
+ case 'menu-not-found':
47
+ case 'extended-not-found':
48
+ if (logger.verbose) {
49
+ logger(`Thinking time: ${result.status.replaceAll('-', ' ')}; continuing with default.`);
50
+ }
51
+ return false;
52
+ default:
53
+ if (logger.verbose) {
54
+ logger('Thinking time: unknown outcome; continuing with default.');
55
+ }
56
+ return false;
57
+ }
58
+ }
59
+ catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ if (logger.verbose) {
62
+ logger(`Thinking time selection failed (${message}); continuing with default.`);
63
+ await logDomFailure(Runtime, logger, 'thinking-time');
64
+ }
65
+ return false;
66
+ }
67
+ }
68
+ async function evaluateThinkingTimeSelection(Runtime) {
69
+ const outcome = await Runtime.evaluate({
70
+ expression: buildThinkingTimeExpression(),
71
+ awaitPromise: true,
72
+ returnByValue: true,
73
+ });
74
+ return outcome.result?.value;
75
+ }
76
+ function buildThinkingTimeExpression() {
77
+ const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
78
+ const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
79
+ return `(async () => {
80
+ ${buildClickDispatcher()}
81
+
82
+ const MENU_CONTAINER_SELECTOR = ${menuContainerLiteral};
83
+ const MENU_ITEM_SELECTOR = ${menuItemLiteral};
84
+
85
+ const CHIP_SELECTORS = [
86
+ '[data-testid="composer-footer-actions"] button[aria-haspopup="menu"]',
87
+ 'button.__composer-pill[aria-haspopup="menu"]',
88
+ '.__composer-pill-composite button[aria-haspopup="menu"]',
89
+ ];
90
+
91
+ const INITIAL_WAIT_MS = 150;
92
+ const MAX_WAIT_MS = 10000;
93
+
94
+ const normalize = (value) => (value || '')
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, ' ')
97
+ .replace(/\\s+/g, ' ')
98
+ .trim();
99
+
100
+ const findThinkingChip = () => {
101
+ for (const selector of CHIP_SELECTORS) {
102
+ const buttons = document.querySelectorAll(selector);
103
+ for (const btn of buttons) {
104
+ const aria = normalize(btn.getAttribute?.('aria-label') ?? '');
105
+ const text = normalize(btn.textContent ?? '');
106
+ if (aria.includes('thinking') || text.includes('thinking')) {
107
+ return btn;
108
+ }
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ const chip = findThinkingChip();
115
+ if (!chip) {
116
+ return { status: 'chip-not-found' };
117
+ }
118
+
119
+ dispatchClickSequence(chip);
120
+
121
+ return new Promise((resolve) => {
122
+ const start = performance.now();
123
+
124
+ const findMenu = () => {
125
+ const menus = document.querySelectorAll(MENU_CONTAINER_SELECTOR + ', [role="group"]');
126
+ for (const menu of menus) {
127
+ const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
128
+ if (normalize(label?.textContent ?? '').includes('thinking time')) {
129
+ return menu;
130
+ }
131
+ const text = normalize(menu.textContent ?? '');
132
+ if (text.includes('standard') && text.includes('extended')) {
133
+ return menu;
134
+ }
135
+ }
136
+ return null;
137
+ };
138
+
139
+ const findExtendedOption = (menu) => {
140
+ const items = menu.querySelectorAll(MENU_ITEM_SELECTOR);
141
+ for (const item of items) {
142
+ const text = normalize(item.textContent ?? '');
143
+ if (text.includes('extended')) {
144
+ return item;
145
+ }
146
+ }
147
+ return null;
148
+ };
149
+
150
+ const optionIsSelected = (node) => {
151
+ if (!(node instanceof HTMLElement)) return false;
152
+ const ariaChecked = node.getAttribute('aria-checked');
153
+ const dataState = (node.getAttribute('data-state') || '').toLowerCase();
154
+ if (ariaChecked === 'true') return true;
155
+ if (dataState === 'checked' || dataState === 'selected' || dataState === 'on') return true;
156
+ return false;
157
+ };
158
+
159
+ const attempt = () => {
160
+ const menu = findMenu();
161
+ if (!menu) {
162
+ if (performance.now() - start > MAX_WAIT_MS) {
163
+ resolve({ status: 'menu-not-found' });
164
+ return;
165
+ }
166
+ setTimeout(attempt, 100);
167
+ return;
168
+ }
169
+
170
+ const extendedOption = findExtendedOption(menu);
171
+ if (!extendedOption) {
172
+ resolve({ status: 'extended-not-found' });
173
+ return;
174
+ }
175
+
176
+ 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 });
182
+ };
183
+
184
+ setTimeout(attempt, INITIAL_WAIT_MS);
185
+ });
186
+ })()`;
187
+ }
188
+ export function buildThinkingTimeExpressionForTest() {
189
+ return buildThinkingTimeExpression();
190
+ }
@@ -24,6 +24,7 @@ export const DEFAULT_BROWSER_CONFIG = {
24
24
  remoteChrome: null,
25
25
  manualLogin: false,
26
26
  manualLoginProfileDir: null,
27
+ extendedThinking: false,
27
28
  };
28
29
  export function resolveBrowserConfig(config) {
29
30
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = 'https://chatgpt.com/';
2
- export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.1';
2
+ export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.2';
3
3
  export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com', 'https://atlas.openai.com'];
4
4
  export const INPUT_SELECTORS = [
5
5
  'textarea[data-id="prompt-textarea"]',