@steipete/oracle 0.5.4 → 0.6.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.
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,
@@ -135,9 +135,27 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
135
135
  await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
136
136
  };
137
137
  await tryFileInput();
138
+ // Snapshot the attachment state immediately after setting files so we can detect silent failures.
139
+ const snapshotExpr = `(() => {
140
+ const chips = Array.from(document.querySelectorAll('[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label="Remove file"]'))
141
+ .map((node) => (node?.textContent || '').trim())
142
+ .filter(Boolean);
143
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]')).map((el) => ({
144
+ files: Array.from(el.files || []).map((f) => f?.name ?? ''),
145
+ }));
146
+ return { chips, inputs };
147
+ })()`;
148
+ const snapshot = await runtime
149
+ .evaluate({ expression: snapshotExpr, returnByValue: true })
150
+ .then((res) => res?.result?.value)
151
+ .catch(() => undefined);
152
+ if (snapshot) {
153
+ logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(snapshot.chips || [])} inputs=${JSON.stringify(snapshot.inputs || [])}`);
154
+ }
155
+ const inputHasFile = snapshot?.inputs?.some((entry) => (entry.files || []).some((name) => name?.toLowerCase?.().includes(expectedName.toLowerCase()))) ?? false;
138
156
  if (await waitForAttachmentAnchored(runtime, expectedName, 20_000)) {
139
157
  await waitForAttachmentVisible(runtime, expectedName, 20_000, logger);
140
- logger('Attachment queued (file input)');
158
+ logger(inputHasFile ? 'Attachment queued (file input, confirmed present)' : 'Attachment queued (file input)');
141
159
  return;
142
160
  }
143
161
  await logDomFailure(runtime, logger, 'file-upload-missing');
@@ -189,10 +207,6 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
189
207
  btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
190
208
  );
191
209
  attachedNames.push(...cardTexts.filter(Boolean));
192
- const filesPills = Array.from(document.querySelectorAll('button,div'))
193
- .map((node) => (node?.textContent || '').toLowerCase())
194
- .filter((text) => /\bfiles\b/.test(text));
195
- attachedNames.push(...filesPills);
196
210
  const filesAttached = attachedNames.length > 0;
197
211
  return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
198
212
  })()`;
@@ -274,13 +288,6 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
274
288
  return { found: true, userTurns: userTurns.length, source: 'attachment-cards' };
275
289
  }
276
290
 
277
- const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
278
- (node?.textContent || '').toLowerCase(),
279
- );
280
- if (filesPills.some((text) => /\bfiles\b/.test(text))) {
281
- return { found: true, userTurns: userTurns.length, source: 'files-pill' };
282
- }
283
-
284
291
  const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
285
292
  if (attrMatch) {
286
293
  return { found: true, userTurns: userTurns.length, source: 'attrs' };
@@ -328,13 +335,6 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
328
335
  return { found: true, text: cards.find((t) => t.includes(normalized)) };
329
336
  }
330
337
 
331
- const filesPills = Array.from(document.querySelectorAll('button,div')).map((node) =>
332
- (node?.textContent || '').toLowerCase(),
333
- );
334
- if (filesPills.some((text) => /\bfiles\b/.test(text))) {
335
- return { found: true, text: filesPills.find((t) => /\bfiles\b/.test(t)) };
336
- }
337
-
338
338
  // As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
339
339
  const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
340
340
  Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
@@ -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) {
@@ -231,25 +287,46 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
231
287
  const fallback = document.querySelector(${fallbackSelectorLiteral});
232
288
  const normalize = (value) => value?.toLowerCase?.().replace(/\\s+/g, ' ').trim() ?? '';
233
289
  const normalizedPrompt = normalize(${encodedPrompt});
290
+ const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
234
291
  const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
235
292
  const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
236
- const userMatched = articles.some((node) => normalize(node?.innerText).includes(normalizedPrompt));
293
+ const normalizedTurns = articles.map((node) => normalize(node?.innerText));
294
+ const userMatched = normalizedTurns.some((text) => text.includes(normalizedPrompt));
295
+ const prefixMatched =
296
+ normalizedPromptPrefix.length > 30 &&
297
+ normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
298
+ const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
237
299
  return {
238
300
  userMatched,
301
+ prefixMatched,
239
302
  fallbackValue: fallback?.value ?? '',
240
303
  editorValue: editor?.innerText ?? '',
304
+ lastTurn,
305
+ turnsCount: normalizedTurns.length,
241
306
  };
242
307
  })()`;
243
308
  while (Date.now() < deadline) {
244
309
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
245
310
  const info = result.value;
246
- if (info?.userMatched) {
311
+ if (info?.userMatched || info?.prefixMatched) {
247
312
  return;
248
313
  }
249
314
  await delay(100);
250
315
  }
251
316
  if (logger) {
317
+ logger(`Prompt commit check failed; latest state: ${await Runtime.evaluate({
318
+ expression: script,
319
+ returnByValue: true,
320
+ }).then((res) => JSON.stringify(res?.result?.value)).catch(() => 'unavailable')}`);
252
321
  await logDomFailure(Runtime, logger, 'prompt-commit');
253
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
+ }
254
331
  throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
255
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);
@@ -5,8 +5,9 @@ import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
+ import { ensureExtendedThinking } from './actions/thinkingTime.js';
10
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
11
12
  import { formatElapsed } from '../oracle/format.js';
12
13
  import { CHATGPT_URL } from './constants.js';
@@ -19,6 +20,7 @@ export async function runBrowserMode(options) {
19
20
  throw new Error('Prompt text is required when using browser mode.');
20
21
  }
21
22
  const attachments = options.attachments ?? [];
23
+ const fallbackSubmission = options.fallbackSubmission;
22
24
  let config = resolveBrowserConfig(options.config);
23
25
  const logger = options.log ?? ((_message) => { });
24
26
  if (logger.verbose === undefined) {
@@ -254,20 +256,49 @@ export async function runBrowserMode(options) {
254
256
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
255
257
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
256
258
  }
257
- const attachmentNames = attachments.map((a) => path.basename(a.path));
258
- if (attachments.length > 0) {
259
- if (!DOM) {
260
- throw new Error('Chrome DOM domain unavailable while uploading attachments.');
259
+ if (config.extendedThinking) {
260
+ await raceWithDisconnect(withRetries(() => ensureExtendedThinking(Runtime, logger), {
261
+ retries: 2,
262
+ delayMs: 300,
263
+ onRetry: (attempt, error) => {
264
+ if (options.verbose) {
265
+ logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
266
+ }
267
+ },
268
+ }));
269
+ }
270
+ const submitOnce = async (prompt, submissionAttachments) => {
271
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
272
+ if (submissionAttachments.length > 0) {
273
+ if (!DOM) {
274
+ throw new Error('Chrome DOM domain unavailable while uploading attachments.');
275
+ }
276
+ for (const attachment of submissionAttachments) {
277
+ logger(`Uploading attachment: ${attachment.displayPath}`);
278
+ await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
279
+ }
280
+ const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
281
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
282
+ logger('All attachments uploaded');
261
283
  }
262
- for (const attachment of attachments) {
263
- logger(`Uploading attachment: ${attachment.displayPath}`);
264
- await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
284
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
285
+ };
286
+ try {
287
+ await raceWithDisconnect(submitOnce(promptText, attachments));
288
+ }
289
+ catch (error) {
290
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
291
+ error.details?.code === 'prompt-too-large';
292
+ if (fallbackSubmission && isPromptTooLarge) {
293
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
294
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
295
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
296
+ await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
297
+ }
298
+ else {
299
+ throw error;
265
300
  }
266
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
267
- await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
268
- logger('All attachments uploaded');
269
301
  }
270
- await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger));
271
302
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
272
303
  const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
273
304
  answerText = answer.text;
@@ -636,21 +667,50 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
636
667
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
637
668
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
638
669
  }
639
- const attachmentNames = attachments.map((a) => path.basename(a.path));
640
- if (attachments.length > 0) {
641
- if (!DOM) {
642
- throw new Error('Chrome DOM domain unavailable while uploading attachments.');
670
+ if (config.extendedThinking) {
671
+ await withRetries(() => ensureExtendedThinking(Runtime, logger), {
672
+ retries: 2,
673
+ delayMs: 300,
674
+ onRetry: (attempt, error) => {
675
+ if (options.verbose) {
676
+ logger(`[retry] Extended thinking attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
677
+ }
678
+ },
679
+ });
680
+ }
681
+ const submitOnce = async (prompt, submissionAttachments) => {
682
+ const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
683
+ if (submissionAttachments.length > 0) {
684
+ if (!DOM) {
685
+ throw new Error('Chrome DOM domain unavailable while uploading attachments.');
686
+ }
687
+ // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
688
+ for (const attachment of submissionAttachments) {
689
+ logger(`Uploading attachment: ${attachment.displayPath}`);
690
+ await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
691
+ }
692
+ const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
693
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
694
+ logger('All attachments uploaded');
695
+ }
696
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
697
+ };
698
+ try {
699
+ await submitOnce(promptText, attachments);
700
+ }
701
+ catch (error) {
702
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
703
+ error.details?.code === 'prompt-too-large';
704
+ if (options.fallbackSubmission && isPromptTooLarge) {
705
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
706
+ await clearPromptComposer(Runtime, logger);
707
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
708
+ await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
643
709
  }
644
- // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
645
- for (const attachment of attachments) {
646
- logger(`Uploading attachment: ${attachment.displayPath}`);
647
- await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
710
+ else {
711
+ throw error;
648
712
  }
649
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
650
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
651
- logger('All attachments uploaded');
652
713
  }
653
- await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger);
654
714
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
655
715
  const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
656
716
  answerText = answer.text;
@@ -1,5 +1,5 @@
1
1
  export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
- export { submitPrompt } from './actions/promptComposer.js';
3
+ export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
4
  export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
5
5
  export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -5,6 +5,7 @@ import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, format
5
5
  import { isKnownModel } from '../oracle/modelResolver.js';
6
6
  import { buildPromptMarkdown } from '../oracle/promptAssembly.js';
7
7
  import { buildAttachmentPlan } from './policies.js';
8
+ const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
8
9
  export async function assembleBrowserPrompt(runOptions, deps = {}) {
9
10
  const cwd = deps.cwd ?? process.cwd();
10
11
  const readFilesFn = deps.readFilesImpl ?? readFiles;
@@ -14,22 +15,33 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
14
15
  const systemPrompt = runOptions.system?.trim() || '';
15
16
  const sections = createFileSections(files, cwd);
16
17
  const markdown = buildPromptMarkdown(systemPrompt, userPrompt, sections);
17
- const inlineFiles = Boolean(runOptions.browserInlineFiles);
18
- const composerSections = [];
18
+ const attachmentsPolicy = runOptions.browserInlineFiles
19
+ ? 'never'
20
+ : runOptions.browserAttachments ?? 'auto';
21
+ const bundleRequested = Boolean(runOptions.browserBundleFiles);
22
+ const inlinePlan = buildAttachmentPlan(sections, { inlineFiles: true, bundleRequested });
23
+ const uploadPlan = buildAttachmentPlan(sections, { inlineFiles: false, bundleRequested });
24
+ const baseComposerSections = [];
19
25
  if (systemPrompt)
20
- composerSections.push(systemPrompt);
26
+ baseComposerSections.push(systemPrompt);
21
27
  if (userPrompt)
22
- composerSections.push(userPrompt);
23
- const attachmentPlan = buildAttachmentPlan(sections, {
24
- inlineFiles,
25
- bundleRequested: Boolean(runOptions.browserBundleFiles),
26
- });
27
- if (attachmentPlan.inlineBlock) {
28
- composerSections.push(attachmentPlan.inlineBlock);
29
- }
30
- const composerText = composerSections.join('\n\n').trim();
31
- const attachments = attachmentPlan.attachments.slice();
32
- const shouldBundle = attachmentPlan.shouldBundle;
28
+ baseComposerSections.push(userPrompt);
29
+ const inlineComposerText = [...baseComposerSections, inlinePlan.inlineBlock].filter(Boolean).join('\n\n').trim();
30
+ const selectedPlan = attachmentsPolicy === 'always'
31
+ ? uploadPlan
32
+ : attachmentsPolicy === 'never'
33
+ ? inlinePlan
34
+ : inlineComposerText.length <= DEFAULT_BROWSER_INLINE_CHAR_BUDGET || sections.length === 0
35
+ ? inlinePlan
36
+ : uploadPlan;
37
+ const composerText = (selectedPlan.inlineBlock
38
+ ? [...baseComposerSections, selectedPlan.inlineBlock]
39
+ : baseComposerSections)
40
+ .filter(Boolean)
41
+ .join('\n\n')
42
+ .trim();
43
+ const attachments = selectedPlan.attachments.slice();
44
+ const shouldBundle = selectedPlan.shouldBundle;
33
45
  let bundleText = null;
34
46
  if (shouldBundle) {
35
47
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
@@ -48,11 +60,11 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
48
60
  sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
49
61
  });
50
62
  }
51
- const inlineFileCount = attachmentPlan.inlineFileCount;
63
+ const inlineFileCount = selectedPlan.inlineFileCount;
52
64
  const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
53
- const tokenizer = modelConfig.tokenizer;
54
- const tokenizerUserContent = inlineFileCount > 0 && attachmentPlan.inlineBlock
55
- ? [userPrompt, attachmentPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
65
+ const tokenizer = deps.tokenizeImpl ?? modelConfig.tokenizer;
66
+ const tokenizerUserContent = inlineFileCount > 0 && selectedPlan.inlineBlock
67
+ ? [userPrompt, selectedPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
56
68
  : userPrompt;
57
69
  const tokenizerMessages = [
58
70
  systemPrompt ? { role: 'system', content: systemPrompt } : null,
@@ -61,7 +73,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
61
73
  let estimatedInputTokens = tokenizer(tokenizerMessages.length > 0
62
74
  ? tokenizerMessages
63
75
  : [{ role: 'user', content: '' }], TOKENIZER_OPTIONS);
64
- const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(attachmentPlan.inlineBlock);
76
+ const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(selectedPlan.inlineBlock);
65
77
  if (!tokenEstimateIncludesInlineFiles && sections.length > 0) {
66
78
  const attachmentText = bundleText ??
67
79
  sections
@@ -70,6 +82,35 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
70
82
  const attachmentTokens = tokenizer([{ role: 'user', content: attachmentText }], TOKENIZER_OPTIONS);
71
83
  estimatedInputTokens += attachmentTokens;
72
84
  }
85
+ let fallback = null;
86
+ if (attachmentsPolicy === 'auto' && selectedPlan.mode === 'inline' && sections.length > 0) {
87
+ const fallbackComposerText = baseComposerSections.join('\n\n').trim();
88
+ const fallbackAttachments = uploadPlan.attachments.slice();
89
+ let fallbackBundled = null;
90
+ if (uploadPlan.shouldBundle) {
91
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
92
+ const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
93
+ const bundleLines = [];
94
+ sections.forEach((section) => {
95
+ bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
96
+ bundleLines.push('');
97
+ });
98
+ const fallbackBundleText = `${bundleLines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
99
+ await fs.writeFile(bundlePath, fallbackBundleText, 'utf8');
100
+ fallbackAttachments.length = 0;
101
+ fallbackAttachments.push({
102
+ path: bundlePath,
103
+ displayPath: bundlePath,
104
+ sizeBytes: Buffer.byteLength(fallbackBundleText, 'utf8'),
105
+ });
106
+ fallbackBundled = { originalCount: sections.length, bundlePath };
107
+ }
108
+ fallback = {
109
+ composerText: fallbackComposerText,
110
+ attachments: fallbackAttachments,
111
+ bundled: fallbackBundled,
112
+ };
113
+ }
73
114
  return {
74
115
  markdown,
75
116
  composerText,
@@ -77,6 +118,9 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
77
118
  attachments,
78
119
  inlineFileCount,
79
120
  tokenEstimateIncludesInlineFiles,
121
+ attachmentsPolicy,
122
+ attachmentMode: selectedPlan.mode,
123
+ fallback,
80
124
  bundled: shouldBundle && attachments.length === 1 && attachments[0]?.displayPath
81
125
  ? { originalCount: sections.length, bundlePath: attachments[0].displayPath }
82
126
  : null,
@@ -25,8 +25,8 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
25
25
  log(chalk.yellow(`[browser] Bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}.`));
26
26
  }
27
27
  }
28
- else if (runOptions.file && runOptions.file.length > 0 && runOptions.browserInlineFiles) {
29
- log(chalk.dim('[verbose] Browser inline file fallback enabled (pasting file contents).'));
28
+ else if (runOptions.file && runOptions.file.length > 0 && promptArtifacts.attachmentMode === 'inline') {
29
+ log(chalk.dim('[verbose] Browser will paste file contents inline (no uploads).'));
30
30
  }
31
31
  }
32
32
  if (promptArtifacts.bundled) {
@@ -34,11 +34,12 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
34
34
  }
35
35
  const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
36
36
  const automationLogger = ((message) => {
37
- if (!runOptions.verbose)
37
+ if (typeof message !== 'string')
38
38
  return;
39
- if (typeof message === 'string') {
40
- log(message);
41
- }
39
+ const shouldAlwaysPrint = message.startsWith('[browser] ') && /fallback|retry/i.test(message);
40
+ if (!runOptions.verbose && !shouldAlwaysPrint)
41
+ return;
42
+ log(message);
42
43
  });
43
44
  automationLogger.verbose = Boolean(runOptions.verbose);
44
45
  automationLogger.sessionLog = runOptions.verbose ? log : (() => { });
@@ -53,6 +54,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
53
54
  browserResult = await executeBrowser({
54
55
  prompt: promptArtifacts.composerText,
55
56
  attachments: promptArtifacts.attachments,
57
+ fallbackSubmission: promptArtifacts.fallback
58
+ ? { prompt: promptArtifacts.fallback.composerText, attachments: promptArtifacts.fallback.attachments }
59
+ : undefined,
56
60
  config: browserConfig,
57
61
  log: automationLogger,
58
62
  heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import os from 'node:os';
4
3
  import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
4
+ 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';
@@ -9,6 +9,9 @@ const BROWSER_MODEL_LABELS = {
9
9
  'gpt-5-pro': 'GPT-5 Pro',
10
10
  'gpt-5.1-pro': 'GPT-5.1 Pro',
11
11
  'gpt-5.1': 'GPT-5.1',
12
+ 'gpt-5.2': 'GPT-5.2 Thinking',
13
+ 'gpt-5.2-instant': 'GPT-5.2 Instant',
14
+ 'gpt-5.2-pro': 'GPT-5.2 Pro',
12
15
  'gemini-3-pro': 'Gemini 3 Pro',
13
16
  };
14
17
  export async function buildBrowserConfig(options) {
@@ -53,6 +56,7 @@ export async function buildBrowserConfig(options) {
53
56
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
54
57
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
55
58
  remoteChrome,
59
+ extendedThinking: options.browserExtendedThinking ? true : undefined,
56
60
  };
57
61
  }
58
62
  function selectBrowserPort(options) {
@@ -155,7 +159,7 @@ async function resolveInlineCookies({ inlineArg, inlineFileArg, envPayload, envF
155
159
  return { cookies: parsed, source };
156
160
  }
157
161
  // fallback: ~/.oracle/cookies.{json,base64}
158
- const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
162
+ const oracleHome = getOracleHomeDir();
159
163
  const candidates = ['cookies.json', 'cookies.base64'];
160
164
  for (const file of candidates) {
161
165
  const fullPath = path.join(oracleHome, file);
@@ -19,7 +19,6 @@ import { formatElapsed } from '../oracle/format.js';
19
19
  import { sanitizeOscProgress } from './oscUtils.js';
20
20
  import { readFiles } from '../oracle/files.js';
21
21
  import { formatUSD } from '../oracle/format.js';
22
- import { SESSIONS_DIR } from '../sessionManager.js';
23
22
  import { cwd as getCwd } from 'node:process';
24
23
  const isTty = process.stdout.isTTY;
25
24
  const dim = (text) => (isTty ? kleur.dim(text) : text);
@@ -386,7 +385,7 @@ async function writeAssistantOutput(targetPath, content, log) {
386
385
  return;
387
386
  }
388
387
  const normalizedTarget = path.resolve(targetPath);
389
- const normalizedSessionsDir = path.resolve(SESSIONS_DIR);
388
+ const normalizedSessionsDir = path.resolve(sessionStore.sessionsDir());
390
389
  if (normalizedTarget === normalizedSessionsDir ||
391
390
  normalizedTarget.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
392
391
  log(dim(`write-output skipped: refusing to write inside session storage (${normalizedSessionsDir}).`));
@@ -442,7 +441,7 @@ function buildFallbackPath(original) {
442
441
  const dir = getCwd();
443
442
  const candidate = ext ? `${stem}.fallback${ext}` : `${stem}.fallback`;
444
443
  const fallback = path.join(dir, candidate);
445
- const normalizedSessionsDir = path.resolve(SESSIONS_DIR);
444
+ const normalizedSessionsDir = path.resolve(sessionStore.sessionsDir());
446
445
  const normalizedFallback = path.resolve(fallback);
447
446
  if (normalizedFallback === normalizedSessionsDir ||
448
447
  normalizedFallback.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
@@ -388,6 +388,7 @@ async function askOracleFlow(version, userConfig) {
388
388
  sessionId: undefined,
389
389
  verbose: false,
390
390
  heartbeatIntervalMs: undefined,
391
+ browserAttachments: 'auto',
391
392
  browserInlineFiles: false,
392
393
  browserBundleFiles: false,
393
394
  background: undefined,
@@ -1,10 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import JSON5 from 'json5';
4
+ import { getOracleHomeDir } from './oracleHome.js';
5
5
  function resolveConfigPath() {
6
- const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
7
- return path.join(oracleHome, 'config.json');
6
+ return path.join(getOracleHomeDir(), 'config.json');
8
7
  }
9
8
  export async function loadUserConfig() {
10
9
  const CONFIG_PATH = resolveConfigPath();
@@ -3,17 +3,18 @@ import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro
3
3
  import { countTokens as countTokensAnthropicRaw } from '@anthropic-ai/tokenizer';
4
4
  import { stringifyTokenizerInput } from './tokenStringifier.js';
5
5
  export const DEFAULT_MODEL = 'gpt-5.1-pro';
6
- export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'claude-4.5-sonnet', 'claude-4.1-opus']);
6
+ export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'gpt-5.2-pro', 'claude-4.5-sonnet', 'claude-4.1-opus']);
7
7
  const countTokensAnthropic = (input) => countTokensAnthropicRaw(stringifyTokenizerInput(input));
8
8
  export const MODEL_CONFIGS = {
9
9
  'gpt-5.1-pro': {
10
10
  model: 'gpt-5.1-pro',
11
+ apiModel: 'gpt-5.2-pro',
11
12
  provider: 'openai',
12
13
  tokenizer: countTokensGpt5Pro,
13
14
  inputLimit: 196000,
14
15
  pricing: {
15
- inputPerToken: 15 / 1_000_000,
16
- outputPerToken: 120 / 1_000_000,
16
+ inputPerToken: 21 / 1_000_000,
17
+ outputPerToken: 168 / 1_000_000,
17
18
  },
18
19
  reasoning: null,
19
20
  },
@@ -50,6 +51,40 @@ export const MODEL_CONFIGS = {
50
51
  },
51
52
  reasoning: { effort: 'high' },
52
53
  },
54
+ 'gpt-5.2': {
55
+ model: 'gpt-5.2',
56
+ provider: 'openai',
57
+ tokenizer: countTokensGpt5,
58
+ inputLimit: 196000,
59
+ pricing: {
60
+ inputPerToken: 1.75 / 1_000_000,
61
+ outputPerToken: 14 / 1_000_000,
62
+ },
63
+ reasoning: { effort: 'xhigh' },
64
+ },
65
+ 'gpt-5.2-instant': {
66
+ model: 'gpt-5.2-instant',
67
+ apiModel: 'gpt-5.2-chat-latest',
68
+ provider: 'openai',
69
+ tokenizer: countTokensGpt5,
70
+ inputLimit: 196000,
71
+ pricing: {
72
+ inputPerToken: 1.75 / 1_000_000,
73
+ outputPerToken: 14 / 1_000_000,
74
+ },
75
+ reasoning: null,
76
+ },
77
+ 'gpt-5.2-pro': {
78
+ model: 'gpt-5.2-pro',
79
+ provider: 'openai',
80
+ tokenizer: countTokensGpt5Pro,
81
+ inputLimit: 196000,
82
+ pricing: {
83
+ inputPerToken: 21 / 1_000_000,
84
+ outputPerToken: 168 / 1_000_000,
85
+ },
86
+ reasoning: { effort: 'xhigh' },
87
+ },
53
88
  'gemini-3-pro': {
54
89
  model: 'gemini-3-pro',
55
90
  provider: 'google',
@@ -95,13 +95,13 @@ export function toTransportError(error, model) {
95
95
  const apiMessage = apiError.error?.message ||
96
96
  apiError.message ||
97
97
  (apiError.status ? `${apiError.status} OpenAI API error` : 'OpenAI API error');
98
- // TODO: Remove once gpt-5.1-pro is available via the Responses API.
99
- if (model === 'gpt-5.1-pro' &&
98
+ // Friendly guidance when a pro-tier model isn't available on this base URL / API key.
99
+ if (model === 'gpt-5.2-pro' &&
100
100
  (code === 'model_not_found' ||
101
101
  messageText.includes('does not exist') ||
102
102
  messageText.includes('unknown model') ||
103
103
  messageText.includes('model_not_found'))) {
104
- return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Using gpt-5-pro until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
104
+ return new OracleTransportError('model-unavailable', 'gpt-5.2-pro is not available on this API base/key. Try gpt-5-pro or gpt-5.2, or switch to the browser engine.', apiError);
105
105
  }
106
106
  if (apiError.status === 404 || apiError.status === 405) {
107
107
  return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', apiError);
@@ -1,10 +1,13 @@
1
1
  import { GoogleGenAI, HarmCategory, HarmBlockThreshold } from '@google/genai';
2
2
  const MODEL_ID_MAP = {
3
3
  'gemini-3-pro': 'gemini-3-pro-preview',
4
- 'gpt-5.1-pro': 'gpt-5.1-pro', // unused, normalize TS map
4
+ 'gpt-5.1-pro': 'gpt-5.1-pro',
5
5
  'gpt-5-pro': 'gpt-5-pro',
6
6
  'gpt-5.1': 'gpt-5.1',
7
7
  'gpt-5.1-codex': 'gpt-5.1-codex',
8
+ 'gpt-5.2': 'gpt-5.2',
9
+ 'gpt-5.2-instant': 'gpt-5.2-instant',
10
+ 'gpt-5.2-pro': 'gpt-5.2-pro',
8
11
  'claude-4.5-sonnet': 'claude-4.5-sonnet',
9
12
  'claude-4.1-opus': 'claude-4.1-opus',
10
13
  'grok-4.1': 'grok-4.1',
@@ -186,17 +186,14 @@ export async function runOracle(options, deps = {}) {
186
186
  : DEFAULT_TIMEOUT_NON_PRO_MS / 1000
187
187
  : options.timeoutSeconds;
188
188
  const timeoutMs = timeoutSeconds * 1000;
189
- const apiModelFromConfig = modelConfig.apiModel ?? modelConfig.model;
190
- const modelDowngraded = apiModelFromConfig === 'gpt-5.1-pro';
191
- const resolvedApiModelId = modelDowngraded ? 'gpt-5-pro' : apiModelFromConfig;
192
189
  // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
193
190
  const effectiveModelId = options.effectiveModelId ??
194
191
  (options.model.startsWith('gemini')
195
192
  ? resolveGeminiModelId(options.model)
196
- : resolvedApiModelId);
193
+ : (modelConfig.apiModel ?? modelConfig.model));
197
194
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
198
195
  const requestBody = buildRequestBody({
199
- modelConfig: { ...modelConfig, apiModel: resolvedApiModelId },
196
+ modelConfig,
200
197
  systemPrompt,
201
198
  userPrompt: promptWithFiles,
202
199
  searchEnabled,
@@ -222,8 +219,8 @@ export async function runOracle(options, deps = {}) {
222
219
  if (baseUrl) {
223
220
  log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
224
221
  }
225
- if (modelDowngraded) {
226
- log(dim('gpt-5.1-pro is not yet available via API; sending request with gpt-5-pro instead.'));
222
+ if (effectiveModelId !== modelConfig.model) {
223
+ log(dim(`Resolved model: ${modelConfig.model} ${effectiveModelId}`));
227
224
  }
228
225
  if (options.background && !supportsBackground) {
229
226
  log(dim('Background runs are not supported for this model; streaming in foreground instead.'));
@@ -0,0 +1,13 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ let oracleHomeDirOverride = null;
4
+ /**
5
+ * Test-only hook: avoid mutating process.env (shared across Vitest worker threads).
6
+ * This override is scoped to the current Node worker.
7
+ */
8
+ export function setOracleHomeDirOverrideForTest(dir) {
9
+ oracleHomeDirOverride = dir;
10
+ }
11
+ export function getOracleHomeDir() {
12
+ return oracleHomeDirOverride ?? process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
13
+ }
@@ -1,11 +1,12 @@
1
- import os from 'node:os';
2
1
  import path from 'node:path';
3
2
  import fs from 'node:fs/promises';
4
3
  import { createWriteStream } from 'node:fs';
5
4
  import { DEFAULT_MODEL } from './oracle.js';
6
5
  import { safeModelSlug } from './oracle/modelResolver.js';
7
- const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
8
- const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
6
+ import { getOracleHomeDir } from './oracleHome.js';
7
+ export function getSessionsDir() {
8
+ return path.join(getOracleHomeDir(), 'sessions');
9
+ }
9
10
  const METADATA_FILENAME = 'meta.json';
10
11
  const LEGACY_SESSION_FILENAME = 'session.json';
11
12
  const LEGACY_REQUEST_FILENAME = 'request.json';
@@ -22,7 +23,7 @@ async function ensureDir(dirPath) {
22
23
  await fs.mkdir(dirPath, { recursive: true });
23
24
  }
24
25
  export async function ensureSessionStorage() {
25
- await ensureDir(SESSIONS_DIR);
26
+ await ensureDir(getSessionsDir());
26
27
  }
27
28
  function slugify(text, maxWords = MAX_SLUG_WORDS) {
28
29
  const normalized = text?.toLowerCase() ?? '';
@@ -50,7 +51,7 @@ export function createSessionId(prompt, customSlug) {
50
51
  return slugify(prompt);
51
52
  }
52
53
  function sessionDir(id) {
53
- return path.join(SESSIONS_DIR, id);
54
+ return path.join(getSessionsDir(), id);
54
55
  }
55
56
  function metaPath(id) {
56
57
  return path.join(sessionDir(id), METADATA_FILENAME);
@@ -191,6 +192,7 @@ export async function initializeSession(options, cwd, notifications) {
191
192
  browserConfig,
192
193
  verbose: options.verbose,
193
194
  heartbeatIntervalMs: options.heartbeatIntervalMs,
195
+ browserAttachments: options.browserAttachments,
194
196
  browserInlineFiles: options.browserInlineFiles,
195
197
  browserBundleFiles: options.browserBundleFiles,
196
198
  background: options.background,
@@ -287,7 +289,7 @@ export function createSessionLogWriter(sessionId, model) {
287
289
  }
288
290
  export async function listSessionsMetadata() {
289
291
  await ensureSessionStorage();
290
- const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
292
+ const entries = await fs.readdir(getSessionsDir()).catch(() => []);
291
293
  const metas = [];
292
294
  for (const entry of entries) {
293
295
  let meta = await readSessionMetadata(entry);
@@ -379,7 +381,7 @@ export async function readSessionRequest(sessionId) {
379
381
  }
380
382
  export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
381
383
  await ensureSessionStorage();
382
- const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
384
+ const entries = await fs.readdir(getSessionsDir()).catch(() => []);
383
385
  if (!entries.length) {
384
386
  return { deleted: 0, remaining: 0 };
385
387
  }
@@ -415,7 +417,7 @@ export async function deleteSessionsOlderThan({ hours = 24, includeAll = false,
415
417
  export async function wait(ms) {
416
418
  return new Promise((resolve) => setTimeout(resolve, ms));
417
419
  }
418
- export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
420
+ export { MAX_STATUS_LIMIT };
419
421
  export { ZOMBIE_MAX_AGE_MS };
420
422
  export async function getSessionPaths(sessionId) {
421
423
  const dir = sessionDir(sessionId);
@@ -1,4 +1,4 @@
1
- import { ensureSessionStorage, initializeSession, readSessionMetadata, updateSessionMetadata, createSessionLogWriter, readSessionLog, readModelLog, readSessionRequest, listSessionsMetadata, filterSessionsByRange, deleteSessionsOlderThan, updateModelRunMetadata, getSessionPaths, SESSIONS_DIR, } from './sessionManager.js';
1
+ import { ensureSessionStorage, initializeSession, readSessionMetadata, updateSessionMetadata, createSessionLogWriter, readSessionLog, readModelLog, readSessionRequest, listSessionsMetadata, filterSessionsByRange, deleteSessionsOlderThan, updateModelRunMetadata, getSessionPaths, getSessionsDir, } from './sessionManager.js';
2
2
  class FileSessionStore {
3
3
  ensureStorage() {
4
4
  return ensureSessionStorage();
@@ -40,7 +40,7 @@ class FileSessionStore {
40
40
  return getSessionPaths(sessionId);
41
41
  }
42
42
  sessionsDir() {
43
- return SESSIONS_DIR;
43
+ return getSessionsDir();
44
44
  }
45
45
  }
46
46
  export const sessionStore = new FileSessionStore();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.5.4",
4
- "description": "CLI wrapper around OpenAI Responses API with GPT-5.1 Pro, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
3
+ "version": "0.6.0",
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",
7
7
  "bin": {
@@ -30,11 +30,7 @@
30
30
  "name": "node",
31
31
  "version": ">=20"
32
32
  }
33
- ],
34
- "packageManager": {
35
- "name": "pnpm",
36
- "version": ">=8"
37
- }
33
+ ]
38
34
  },
39
35
  "keywords": [],
40
36
  "author": "",
package/dist/.DS_Store DELETED
Binary file