@steipete/oracle 0.14.0 → 0.14.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 (69) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/oracle-cli.js +8 -8
  3. package/dist/src/browser/actions/assistantResponse.js +2 -1
  4. package/dist/src/browser/actions/modelSelection.js +344 -29
  5. package/dist/src/browser/actions/thinkingTime.js +239 -46
  6. package/dist/src/browser/chatgptImages.js +126 -24
  7. package/dist/src/browser/index.js +63 -8
  8. package/dist/src/browser/projectSourcesRunner.js +2 -1
  9. package/dist/src/browser/prompt.js +151 -22
  10. package/dist/src/cli/browserConfig.js +2 -1
  11. package/dist/src/cli/browserDefaults.js +2 -1
  12. package/dist/src/cli/options.js +8 -0
  13. package/dist/src/mcp/tools/chatgptImage.js +8 -3
  14. package/dist/src/mcp/tools/consult.js +9 -8
  15. package/dist/src/mcp/types.js +11 -2
  16. package/dist/src/oracle/thinkingTime.js +40 -0
  17. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  18. package/package.json +32 -34
  19. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  20. package/dist/bin/oracle.js +0 -569
  21. package/dist/docs-site/.nojekyll +0 -0
  22. package/dist/docs-site/CNAME +0 -1
  23. package/dist/docs-site/RELEASING.html +0 -410
  24. package/dist/docs-site/agents.html +0 -374
  25. package/dist/docs-site/anthropic.html +0 -368
  26. package/dist/docs-site/bridge.html +0 -400
  27. package/dist/docs-site/browser-mode.html +0 -593
  28. package/dist/docs-site/chromium-forks.html +0 -347
  29. package/dist/docs-site/cli-reference.html +0 -346
  30. package/dist/docs-site/configuration.html +0 -452
  31. package/dist/docs-site/favicon.svg +0 -14
  32. package/dist/docs-site/followup.html +0 -375
  33. package/dist/docs-site/gemini.html +0 -383
  34. package/dist/docs-site/grok.html +0 -325
  35. package/dist/docs-site/index.html +0 -360
  36. package/dist/docs-site/install.html +0 -335
  37. package/dist/docs-site/linux.html +0 -321
  38. package/dist/docs-site/llms.txt +0 -43
  39. package/dist/docs-site/manual-tests.html +0 -596
  40. package/dist/docs-site/mcp.html +0 -391
  41. package/dist/docs-site/multimodel.html +0 -364
  42. package/dist/docs-site/mythical-pro-agents.html +0 -360
  43. package/dist/docs-site/notifier.html +0 -338
  44. package/dist/docs-site/openai-endpoints.html +0 -387
  45. package/dist/docs-site/openrouter.html +0 -344
  46. package/dist/docs-site/quickstart.html +0 -369
  47. package/dist/docs-site/refactor/ux.html +0 -532
  48. package/dist/docs-site/sessions.html +0 -388
  49. package/dist/docs-site/social-card.png +0 -0
  50. package/dist/docs-site/social-card.svg +0 -79
  51. package/dist/docs-site/spec.html +0 -363
  52. package/dist/docs-site/testing.html +0 -320
  53. package/dist/docs-site/tui-debug.html +0 -326
  54. package/dist/docs-site/windows-work.html +0 -323
  55. package/dist/docs-site/windows.html +0 -320
  56. package/dist/src/browser/chromeCookies.js +0 -312
  57. package/dist/src/browser/keytarShim.js +0 -56
  58. package/dist/src/browser/windowsCookies.js +0 -219
  59. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  60. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  61. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  62. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  63. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  68. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  69. package/vendor/oracle-notifier/README.md +0 -26
@@ -9,6 +9,33 @@ import { resolveSessionArtifactsDir } from "./artifacts.js";
9
9
  import { saveAssistantDownloadButtonArtifacts } from "./chatgptFiles.js";
10
10
  const GENERATED_IMAGE_WAIT_MIN_MS = 15_000;
11
11
  const GENERATED_IMAGE_WAIT_MAX_MS = 15 * 60_000;
12
+ const CHATGPT_GENERATED_IMAGE_BASE_URL = "https://chatgpt.com/";
13
+ function isAllowedChatGptHost(hostname) {
14
+ const value = hostname.toLowerCase();
15
+ return value === "chatgpt.com" || value === "chat.openai.com";
16
+ }
17
+ function normalizeGeneratedImageUrl(value) {
18
+ const raw = String(value ?? "").trim();
19
+ if (!raw)
20
+ return undefined;
21
+ let url;
22
+ try {
23
+ url = new URL(raw, CHATGPT_GENERATED_IMAGE_BASE_URL);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (url.protocol !== "https:" || url.port || !isAllowedChatGptHost(url.hostname)) {
29
+ return undefined;
30
+ }
31
+ if (url.pathname !== "/backend-api/estuary/content") {
32
+ return undefined;
33
+ }
34
+ if (!(url.searchParams.get("id") ?? "").startsWith("file_")) {
35
+ return undefined;
36
+ }
37
+ return url.href;
38
+ }
12
39
  function extractFileId(url) {
13
40
  try {
14
41
  return new URL(url).searchParams.get("id") ?? undefined;
@@ -41,8 +68,12 @@ function buildAssistantImageExpression(minTurnIndex) {
41
68
  const CONVERSATION_SELECTOR = ${conversationLiteral};
42
69
  const ASSISTANT_SELECTOR = ${assistantLiteral};
43
70
  const isGeneratedImage = (img) => {
44
- const url = img?.src || '';
45
- if (!url.includes('/backend-api/estuary/content?id=file_')) return false;
71
+ const url = new URL(img?.src || '', location.origin || 'https://chatgpt.com');
72
+ const host = url.hostname.toLowerCase();
73
+ if (url.protocol !== 'https:' || url.port) return false;
74
+ if (host !== 'chatgpt.com' && host !== 'chat.openai.com') return false;
75
+ if (url.pathname !== '/backend-api/estuary/content') return false;
76
+ if (!String(url.searchParams.get('id') || '').startsWith('file_')) return false;
46
77
  const alt = String(img.alt || '').toLowerCase();
47
78
  if (alt.includes('generated image')) return true;
48
79
  let node = img;
@@ -104,13 +135,16 @@ export async function readAssistantGeneratedImages(Runtime, minTurnIndex) {
104
135
  });
105
136
  const raw = Array.isArray(result?.value) ? result.value : [];
106
137
  const normalized = raw
107
- .map((item) => ({
108
- url: typeof item?.url === "string" ? item.url : "",
109
- alt: typeof item?.alt === "string" ? item.alt : undefined,
110
- width: typeof item?.width === "number" ? item.width : undefined,
111
- height: typeof item?.height === "number" ? item.height : undefined,
112
- fileId: typeof item?.url === "string" ? extractFileId(item.url) : undefined,
113
- }))
138
+ .map((item) => {
139
+ const url = normalizeGeneratedImageUrl(typeof item?.url === "string" ? item.url : "");
140
+ return {
141
+ url: url ?? "",
142
+ alt: typeof item?.alt === "string" ? item.alt : undefined,
143
+ width: typeof item?.width === "number" ? item.width : undefined,
144
+ height: typeof item?.height === "number" ? item.height : undefined,
145
+ fileId: url ? extractFileId(url) : undefined,
146
+ };
147
+ })
114
148
  .filter((item) => item.url.length > 0);
115
149
  return dedupeImages(normalized);
116
150
  }
@@ -209,8 +243,51 @@ async function buildCookieHeader(Network) {
209
243
  .map((cookie) => `${cookie.name}=${cookie.value}`)
210
244
  .join("; ");
211
245
  }
246
+ async function fetchGeneratedImageInBrowserContext(Runtime, url) {
247
+ const expression = `
248
+ (async () => {
249
+ const url = ${JSON.stringify(url)};
250
+ const response = await fetch(url, { credentials: 'include', redirect: 'follow' });
251
+ const contentType = response.headers.get('content-type') || '';
252
+ const buffer = await response.arrayBuffer();
253
+ const bytes = new Uint8Array(buffer);
254
+ let binary = '';
255
+ for (let index = 0; index < bytes.length; index += 0x8000) {
256
+ binary += String.fromCharCode(...bytes.slice(index, index + 0x8000));
257
+ }
258
+ return {
259
+ ok: response.ok,
260
+ status: response.status,
261
+ statusText: response.statusText,
262
+ contentType,
263
+ finalUrl: response.url,
264
+ b64: btoa(binary),
265
+ };
266
+ })()
267
+ `;
268
+ const { result, exceptionDetails } = await Runtime.evaluate({
269
+ expression,
270
+ awaitPromise: true,
271
+ returnByValue: true,
272
+ timeout: 120_000,
273
+ });
274
+ if (exceptionDetails) {
275
+ throw new Error("browser-context fetch threw an exception");
276
+ }
277
+ const value = result?.value;
278
+ if (!value?.ok || typeof value.b64 !== "string") {
279
+ const status = typeof value?.status === "number" ? value.status : "unknown";
280
+ const statusText = typeof value?.statusText === "string" ? value.statusText : "";
281
+ throw new Error(`browser-context fetch failed: ${status} ${statusText}`.trim());
282
+ }
283
+ return {
284
+ buffer: Buffer.from(value.b64, "base64"),
285
+ contentType: typeof value.contentType === "string" ? value.contentType : null,
286
+ finalUrl: typeof value.finalUrl === "string" && value.finalUrl ? value.finalUrl : url,
287
+ };
288
+ }
212
289
  export async function saveChatGptGeneratedImages(params) {
213
- const { Network, images, outputPath, logger } = params;
290
+ const { Network, Runtime, images, outputPath, logger } = params;
214
291
  if (!images.length)
215
292
  return { saved: false, imageCount: 0, savedImages: [], errors: [] };
216
293
  const cookieHeader = await buildCookieHeader(Network);
@@ -228,20 +305,41 @@ export async function saveChatGptGeneratedImages(params) {
228
305
  for (let index = 0; index < images.length; index += 1) {
229
306
  const image = images[index];
230
307
  try {
231
- const response = await fetch(image.url, {
232
- headers: {
233
- cookie: cookieHeader,
234
- "user-agent": "Mozilla/5.0",
235
- },
236
- redirect: "follow",
237
- });
238
- if (!response.ok) {
239
- throw new Error(`download failed: ${response.status} ${response.statusText}`);
308
+ const imageUrl = normalizeGeneratedImageUrl(image.url);
309
+ if (!imageUrl) {
310
+ throw new Error("rejected non-ChatGPT generated image URL");
311
+ }
312
+ let contentType = null;
313
+ let finalUrl = imageUrl;
314
+ let buffer;
315
+ try {
316
+ const response = await fetch(imageUrl, {
317
+ headers: {
318
+ cookie: cookieHeader,
319
+ "user-agent": "Mozilla/5.0",
320
+ },
321
+ redirect: "follow",
322
+ });
323
+ if (!response.ok) {
324
+ throw new Error(`download failed: ${response.status} ${response.statusText}`);
325
+ }
326
+ contentType = response.headers.get("content-type");
327
+ finalUrl = response.url;
328
+ buffer = Buffer.from(await response.arrayBuffer());
329
+ }
330
+ catch (downloadError) {
331
+ if (!Runtime) {
332
+ throw downloadError;
333
+ }
334
+ const message = downloadError instanceof Error ? downloadError.message : String(downloadError);
335
+ logger?.(`[browser] ChatGPT generated image download failed via Node fetch; retrying in browser context (${image.fileId ?? imageUrl}: ${message}).`);
336
+ const browserFetch = await fetchGeneratedImageInBrowserContext(Runtime, imageUrl);
337
+ contentType = browserFetch.contentType;
338
+ finalUrl = browserFetch.finalUrl;
339
+ buffer = browserFetch.buffer;
240
340
  }
241
- const contentType = response.headers.get("content-type");
242
341
  const extension = contentTypeToExtension(contentType);
243
342
  const targetPath = resolveSiblingImagePath(path.resolve(outputPath), index, extension);
244
- const buffer = Buffer.from(await response.arrayBuffer());
245
343
  await fs.writeFile(targetPath, buffer);
246
344
  savedImages.push({
247
345
  kind: "image",
@@ -249,9 +347,9 @@ export async function saveChatGptGeneratedImages(params) {
249
347
  label: index === 0 ? "Generated image" : `Generated image ${index + 1}`,
250
348
  mimeType: contentType ?? undefined,
251
349
  sizeBytes: buffer.length,
252
- sourceUrl: image.url,
253
- url: image.url,
254
- finalUrl: response.url,
350
+ sourceUrl: imageUrl,
351
+ url: imageUrl,
352
+ finalUrl,
255
353
  alt: image.alt,
256
354
  width: image.width,
257
355
  height: image.height,
@@ -336,6 +434,7 @@ export async function collectGeneratedImageArtifacts(params) {
336
434
  let generatedImages = await readAssistantGeneratedImagesWithFallback(params.Runtime, params.minTurnIndex ?? undefined);
337
435
  let latestAnswerText = params.answerText;
338
436
  if (explicitTargetPath && generatedImages.length === 0) {
437
+ await params.checkBlockingUiWarning?.();
339
438
  const targetPath = path.resolve(explicitTargetPath);
340
439
  const buttonImages = await saveGeneratedImageButtonArtifacts({
341
440
  Browser: params.Browser,
@@ -352,6 +451,7 @@ export async function collectGeneratedImageArtifacts(params) {
352
451
  const deadline = Date.now() + resolveGeneratedImageWaitTimeoutMs(params.waitTimeoutMs);
353
452
  while (Date.now() < deadline) {
354
453
  await delay(1500);
454
+ await params.checkBlockingUiWarning?.();
355
455
  generatedImages = await readAssistantGeneratedImagesWithFallback(params.Runtime, params.minTurnIndex ?? undefined);
356
456
  if (generatedImages.length > 0) {
357
457
  break;
@@ -363,6 +463,7 @@ export async function collectGeneratedImageArtifacts(params) {
363
463
  }
364
464
  }
365
465
  if (generatedImages.length === 0) {
466
+ await params.checkBlockingUiWarning?.();
366
467
  const delayedButtonImages = await saveGeneratedImageButtonArtifacts({
367
468
  Browser: params.Browser,
368
469
  Client: params.Client,
@@ -396,6 +497,7 @@ export async function collectGeneratedImageArtifacts(params) {
396
497
  }
397
498
  const saved = await saveChatGptGeneratedImages({
398
499
  Network: params.Network,
500
+ Runtime: params.Runtime,
399
501
  images: generatedImages,
400
502
  outputPath: targetPath,
401
503
  logger: params.logger,
@@ -240,20 +240,39 @@ function formatChatGptUiWarningType(type) {
240
240
  return "authentication/challenge";
241
241
  }
242
242
  }
243
- async function createAssistantTimeoutError(params) {
243
+ async function createChatGptUiWarningError(params) {
244
244
  const [uiWarning] = await collectChatGptUiWarnings(params.Runtime);
245
- if (!uiWarning) {
246
- return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause);
247
- }
245
+ if (!uiWarning)
246
+ return null;
248
247
  params.logger(`[browser] ChatGPT UI warning detected (${uiWarning.type}): ${uiWarning.message}`);
249
- return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for the assistant: ${uiWarning.message}`, {
250
- stage: "assistant-timeout",
248
+ return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for ${params.waitTarget}: ${uiWarning.message}`, {
249
+ stage: params.stage,
251
250
  code: "chatgpt-ui-warning",
252
251
  uiWarning,
253
252
  runtime: params.runtime,
254
253
  diagnostics: params.diagnostics,
255
254
  }, params.cause);
256
255
  }
256
+ async function throwChatGptUiWarningIfPresent(params) {
257
+ const error = await createChatGptUiWarningError(params);
258
+ if (error)
259
+ throw error;
260
+ }
261
+ async function createAssistantTimeoutError(params) {
262
+ const warningError = await createChatGptUiWarningError({
263
+ Runtime: params.Runtime,
264
+ logger: params.logger,
265
+ runtime: params.runtime,
266
+ stage: "assistant-timeout",
267
+ waitTarget: "the assistant",
268
+ diagnostics: params.diagnostics,
269
+ cause: params.cause,
270
+ });
271
+ if (!warningError) {
272
+ return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause);
273
+ }
274
+ return warningError;
275
+ }
257
276
  function listIgnoredRemoteChromeFlags(config) {
258
277
  return [
259
278
  config.headless ? "--browser-headless" : null,
@@ -334,7 +353,8 @@ function isImageOnlyUiChromeText(text) {
334
353
  return (normalized.length === 0 ||
335
354
  normalized === "edit" ||
336
355
  normalized === "stopped thinking" ||
337
- normalized === "stopped thinking edit");
356
+ normalized === "stopped thinking edit" ||
357
+ /^thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
338
358
  }
339
359
  function normalizeBrowserFollowUpPrompts(values) {
340
360
  return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
@@ -690,9 +710,10 @@ export async function runBrowserMode(options) {
690
710
  }
691
711
  else {
692
712
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
713
+ const devtoolsRetries = manualLogin ? 6 : 0;
693
714
  const connection = await connectWithNewTab(chrome.port, logger, config.url, chromeHost, {
694
715
  fallbackToDefault: !strictTabIsolation,
695
- retries: strictTabIsolation ? 3 : 0,
716
+ retries: devtoolsRetries,
696
717
  retryDelayMs: 500,
697
718
  });
698
719
  client = connection.client;
@@ -1504,6 +1525,23 @@ export async function runBrowserMode(options) {
1504
1525
  outputPath: options.outputPath,
1505
1526
  answerText,
1506
1527
  waitTimeoutMs: options.config?.timeoutMs,
1528
+ checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({
1529
+ Runtime,
1530
+ logger,
1531
+ stage: "image-artifact-wait",
1532
+ waitTarget: "generated image artifacts",
1533
+ runtime: {
1534
+ chromePid: chrome.pid,
1535
+ chromePort: chrome.port,
1536
+ chromeHost,
1537
+ userDataDir,
1538
+ chromeTargetId: lastTargetId,
1539
+ tabUrl: lastUrl,
1540
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1541
+ promptSubmitted,
1542
+ controllerPid: process.pid,
1543
+ },
1544
+ }),
1507
1545
  });
1508
1546
  answerText = imageArtifacts.answerText || answerText;
1509
1547
  if (imageArtifacts.markdownSuffix) {
@@ -2616,6 +2654,23 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2616
2654
  outputPath: options.outputPath,
2617
2655
  answerText,
2618
2656
  waitTimeoutMs: options.config?.timeoutMs,
2657
+ checkBlockingUiWarning: () => throwChatGptUiWarningIfPresent({
2658
+ Runtime,
2659
+ logger,
2660
+ stage: "image-artifact-wait",
2661
+ waitTarget: "generated image artifacts",
2662
+ runtime: {
2663
+ chromePort: port,
2664
+ chromeHost: host,
2665
+ chromeBrowserWSEndpoint: browserWSEndpoint,
2666
+ chromeProfileRoot,
2667
+ chromeTargetId: remoteTargetId ?? undefined,
2668
+ tabUrl: lastUrl,
2669
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2670
+ promptSubmitted,
2671
+ controllerPid: process.pid,
2672
+ },
2673
+ }),
2619
2674
  });
2620
2675
  answerText = imageArtifacts.answerText || answerText;
2621
2676
  if (imageArtifacts.markdownSuffix) {
@@ -97,9 +97,10 @@ export async function runBrowserProjectSources(request) {
97
97
  preserveUserDataDir: manualLogin,
98
98
  });
99
99
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
100
+ const devtoolsRetries = manualLogin ? 6 : 0;
100
101
  const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
101
102
  fallbackToDefault: !strictTabIsolation,
102
- retries: strictTabIsolation ? 3 : 0,
103
+ retries: devtoolsRetries,
103
104
  retryDelayMs: 500,
104
105
  });
105
106
  client = connection.client;
@@ -1,12 +1,14 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSections, } from "../oracle.js";
4
+ import { readFiles, createFileSections, FileValidationError, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSections, } from "../oracle.js";
5
5
  import { isKnownModel } from "../oracle/modelResolver.js";
6
6
  import { buildPromptMarkdown } from "../oracle/promptAssembly.js";
7
7
  import { buildAttachmentPlan } from "./policies.js";
8
8
  import { createStoredZip } from "./zipBundle.js";
9
9
  const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
10
+ const MAX_BROWSER_ATTACHMENTS = 10;
11
+ const MAX_BROWSER_ZIP_BUNDLE_BYTES = 128 * 1024 * 1024;
10
12
  const MEDIA_EXTENSIONS = new Set([
11
13
  ".mp4",
12
14
  ".mov",
@@ -31,27 +33,95 @@ const MEDIA_EXTENSIONS = new Set([
31
33
  ".heif",
32
34
  ".pdf",
33
35
  ]);
36
+ const ARCHIVE_EXTENSIONS = new Set([
37
+ ".7z",
38
+ ".aab",
39
+ ".apk",
40
+ ".br",
41
+ ".bz2",
42
+ ".cab",
43
+ ".crx",
44
+ ".deb",
45
+ ".dmg",
46
+ ".doc",
47
+ ".docx",
48
+ ".ear",
49
+ ".epub",
50
+ ".gz",
51
+ ".ipa",
52
+ ".iso",
53
+ ".jar",
54
+ ".lz",
55
+ ".lz4",
56
+ ".msi",
57
+ ".odp",
58
+ ".ods",
59
+ ".odt",
60
+ ".pkg",
61
+ ".ppt",
62
+ ".pptx",
63
+ ".rar",
64
+ ".rpm",
65
+ ".tar",
66
+ ".tgz",
67
+ ".war",
68
+ ".whl",
69
+ ".xls",
70
+ ".xlsx",
71
+ ".xz",
72
+ ".xpi",
73
+ ".zip",
74
+ ".zipx",
75
+ ".zst",
76
+ ]);
34
77
  export function isMediaFile(filePath) {
35
78
  const ext = path.extname(filePath).toLowerCase();
36
79
  return MEDIA_EXTENSIONS.has(ext);
37
80
  }
81
+ export function isRawUploadFile(filePath) {
82
+ const ext = path.extname(filePath).toLowerCase();
83
+ return MEDIA_EXTENSIONS.has(ext) || ARCHIVE_EXTENSIONS.has(ext);
84
+ }
38
85
  function formatSectionsForBundle(sections, options = {}) {
39
86
  return formatFileSections(sections, {
40
87
  lineNumbers: options.lineNumbers ?? true,
41
88
  trailingNewline: true,
42
89
  });
43
90
  }
44
- async function writeBrowserBundle(sections, format) {
91
+ function resolveBrowserBundleFormat(format, sources) {
92
+ if (format !== "auto") {
93
+ return format;
94
+ }
95
+ return sources.hasRawUploadFiles ? "zip" : "text";
96
+ }
97
+ function shouldWriteBrowserBundle(format, { attachmentCount, bundleRequested, textSourceCount, textPlanShouldBundle, }) {
98
+ if (format === "zip") {
99
+ return (textPlanShouldBundle ||
100
+ (bundleRequested && attachmentCount > 0) ||
101
+ attachmentCount > MAX_BROWSER_ATTACHMENTS);
102
+ }
103
+ return textSourceCount > 0 && (textPlanShouldBundle || attachmentCount > MAX_BROWSER_ATTACHMENTS);
104
+ }
105
+ function assertAttachmentCount(attachments, format) {
106
+ if (attachments.length <= MAX_BROWSER_ATTACHMENTS)
107
+ return;
108
+ throw new Error(`Browser upload has ${attachments.length} attachments after applying bundle format "${format}". Use --browser-bundle-format auto or zip to stay within the ${MAX_BROWSER_ATTACHMENTS}-attachment limit.`);
109
+ }
110
+ async function writeBrowserBundle(sections, sources, format) {
45
111
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "oracle-browser-bundle-"));
46
112
  const tokenEstimateText = formatSectionsForBundle(sections, {
47
113
  lineNumbers: format === "text",
48
114
  });
49
115
  if (format === "zip") {
116
+ const totalSourceBytes = sources.reduce((total, source) => total + source.sizeBytes, 0);
117
+ if (totalSourceBytes > MAX_BROWSER_ZIP_BUNDLE_BYTES) {
118
+ throw new Error(`Browser ZIP bundle inputs exceed the ${MAX_BROWSER_ZIP_BUNDLE_BYTES}-byte in-memory limit.`);
119
+ }
50
120
  const bundlePath = path.join(bundleDir, "attachments-bundle.zip");
51
- const buffer = createStoredZip(sections.map((section) => ({
52
- path: section.displayPath,
53
- content: section.content,
54
- })));
121
+ const buffer = createStoredZip(await Promise.all(sources.map(async (source) => ({
122
+ path: source.displayPath,
123
+ content: await fs.readFile(source.absolutePath),
124
+ }))));
55
125
  await fs.writeFile(bundlePath, buffer);
56
126
  return {
57
127
  attachment: {
@@ -60,7 +130,7 @@ async function writeBrowserBundle(sections, format) {
60
130
  sizeBytes: buffer.length,
61
131
  generatedBundle: true,
62
132
  },
63
- metadata: { originalCount: sections.length, bundlePath, format },
133
+ metadata: { originalCount: sources.length, bundlePath, format },
64
134
  tokenEstimateText,
65
135
  };
66
136
  }
@@ -81,11 +151,27 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
81
151
  const cwd = deps.cwd ?? process.cwd();
82
152
  const readFilesFn = deps.readFilesImpl ?? readFiles;
83
153
  const allFilePaths = runOptions.file ?? [];
84
- const textFilePaths = allFilePaths.filter((f) => !isMediaFile(f));
85
- const mediaFilePaths = allFilePaths.filter((f) => isMediaFile(f));
86
- const mediaAttachments = await Promise.all(mediaFilePaths.map(async (filePath) => {
154
+ const discoveredFiles = allFilePaths.length > 0
155
+ ? await readFilesFn(allFilePaths, {
156
+ cwd,
157
+ maxFileSizeBytes: 0,
158
+ readContents: false,
159
+ })
160
+ : [];
161
+ const textFilePaths = discoveredFiles
162
+ .filter((file) => !isRawUploadFile(file.path))
163
+ .map((file) => file.path);
164
+ const rawUploadFiles = discoveredFiles.filter((file) => isRawUploadFile(file.path));
165
+ const maxFileSizeBytes = runOptions.maxFileSizeBytes;
166
+ const rawUploadAttachments = await Promise.all(rawUploadFiles.map(async ({ path: filePath }) => {
87
167
  const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
88
168
  const stats = await fs.stat(resolvedPath);
169
+ if (maxFileSizeBytes && stats.size > maxFileSizeBytes) {
170
+ throw new FileValidationError(`The following file exceeds the ${maxFileSizeBytes}-byte limit:\n- ${path.relative(cwd, resolvedPath) || resolvedPath} (${stats.size} bytes)`, {
171
+ files: [resolvedPath],
172
+ limitBytes: maxFileSizeBytes,
173
+ });
174
+ }
89
175
  return {
90
176
  path: resolvedPath,
91
177
  displayPath: path.relative(cwd, resolvedPath) || path.basename(resolvedPath),
@@ -105,7 +191,10 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
105
191
  ? "never"
106
192
  : (runOptions.browserAttachments ?? "auto");
107
193
  const bundleRequested = Boolean(runOptions.browserBundleFiles);
108
- const bundleFormat = runOptions.browserBundleFormat ?? "text";
194
+ const bundleFormat = runOptions.browserBundleFormat ?? "auto";
195
+ if (attachmentsPolicy === "never" && rawUploadAttachments.length > 0) {
196
+ throw new FileValidationError("Raw or binary files cannot be pasted inline when browser attachments are disabled. Use --browser-attachments auto or always.", { files: rawUploadAttachments.map((attachment) => attachment.displayPath) });
197
+ }
109
198
  const inlinePlan = buildAttachmentPlan(sections, { inlineFiles: true, bundleRequested });
110
199
  const uploadPlan = buildAttachmentPlan(sections, { inlineFiles: false, bundleRequested });
111
200
  const baseComposerSections = [];
@@ -124,25 +213,47 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
124
213
  : inlineComposerText.length <= DEFAULT_BROWSER_INLINE_CHAR_BUDGET || sections.length === 0
125
214
  ? inlinePlan
126
215
  : uploadPlan;
127
- const composerText = (selectedPlan.inlineBlock
216
+ const textBundleSources = sections.map((section) => ({
217
+ absolutePath: section.absolutePath,
218
+ displayPath: section.displayPath,
219
+ sizeBytes: Buffer.byteLength(section.content, "utf8"),
220
+ }));
221
+ const rawUploadBundleSources = rawUploadAttachments.map((attachment) => ({
222
+ absolutePath: attachment.path,
223
+ displayPath: attachment.displayPath,
224
+ sizeBytes: attachment.sizeBytes ?? 0,
225
+ }));
226
+ const allBundleSources = [...textBundleSources, ...rawUploadBundleSources];
227
+ const attachments = [...selectedPlan.attachments, ...rawUploadAttachments];
228
+ const resolvedBundleFormat = resolveBrowserBundleFormat(bundleFormat, {
229
+ hasRawUploadFiles: rawUploadAttachments.length > 0,
230
+ });
231
+ const shouldBundle = shouldWriteBrowserBundle(resolvedBundleFormat, {
232
+ attachmentCount: attachments.length,
233
+ bundleRequested,
234
+ textSourceCount: textBundleSources.length,
235
+ textPlanShouldBundle: selectedPlan.shouldBundle,
236
+ });
237
+ const composerText = (!shouldBundle && selectedPlan.inlineBlock
128
238
  ? [...baseComposerSections, selectedPlan.inlineBlock]
129
239
  : baseComposerSections)
130
240
  .filter(Boolean)
131
241
  .join("\n\n")
132
242
  .trim();
133
- const attachments = [...selectedPlan.attachments, ...mediaAttachments];
134
- const shouldBundle = selectedPlan.shouldBundle;
135
243
  let bundleText = null;
136
244
  let bundled = null;
137
245
  if (shouldBundle) {
138
- const writtenBundle = await writeBrowserBundle(sections, bundleFormat);
246
+ const writtenBundle = await writeBrowserBundle(sections, resolvedBundleFormat === "zip" ? allBundleSources : textBundleSources, resolvedBundleFormat);
139
247
  bundleText = writtenBundle.tokenEstimateText;
140
248
  attachments.length = 0;
141
249
  attachments.push(writtenBundle.attachment);
142
- attachments.push(...mediaAttachments);
250
+ if (resolvedBundleFormat === "text") {
251
+ attachments.push(...rawUploadAttachments);
252
+ }
143
253
  bundled = writtenBundle.metadata;
144
254
  }
145
- const inlineFileCount = selectedPlan.inlineFileCount;
255
+ assertAttachmentCount(attachments, resolvedBundleFormat);
256
+ const inlineFileCount = shouldBundle ? 0 : selectedPlan.inlineFileCount;
146
257
  const modelConfig = isKnownModel(runOptions.model)
147
258
  ? MODEL_CONFIGS[runOptions.model]
148
259
  : MODEL_CONFIGS["gpt-5.1"];
@@ -167,15 +278,27 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
167
278
  let fallback = null;
168
279
  if (attachmentsPolicy === "auto" && selectedPlan.mode === "inline" && sections.length > 0) {
169
280
  const fallbackComposerText = baseComposerSections.join("\n\n").trim();
170
- const fallbackAttachments = [...uploadPlan.attachments, ...mediaAttachments];
281
+ const fallbackAttachments = [...uploadPlan.attachments, ...rawUploadAttachments];
171
282
  let fallbackBundled = null;
172
- if (uploadPlan.shouldBundle) {
173
- const writtenBundle = await writeBrowserBundle(sections, bundleFormat);
283
+ const fallbackBundleFormat = resolveBrowserBundleFormat(bundleFormat, {
284
+ hasRawUploadFiles: rawUploadAttachments.length > 0,
285
+ });
286
+ const fallbackShouldBundle = shouldWriteBrowserBundle(fallbackBundleFormat, {
287
+ attachmentCount: fallbackAttachments.length,
288
+ bundleRequested,
289
+ textSourceCount: textBundleSources.length,
290
+ textPlanShouldBundle: uploadPlan.shouldBundle,
291
+ });
292
+ if (fallbackShouldBundle) {
293
+ const writtenBundle = await writeBrowserBundle(sections, fallbackBundleFormat === "zip" ? allBundleSources : textBundleSources, fallbackBundleFormat);
174
294
  fallbackAttachments.length = 0;
175
295
  fallbackAttachments.push(writtenBundle.attachment);
176
- fallbackAttachments.push(...mediaAttachments);
296
+ if (fallbackBundleFormat === "text") {
297
+ fallbackAttachments.push(...rawUploadAttachments);
298
+ }
177
299
  fallbackBundled = writtenBundle.metadata;
178
300
  }
301
+ assertAttachmentCount(fallbackAttachments, fallbackBundleFormat);
179
302
  fallback = {
180
303
  composerText: fallbackComposerText,
181
304
  attachments: fallbackAttachments,
@@ -190,7 +313,13 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
190
313
  inlineFileCount,
191
314
  tokenEstimateIncludesInlineFiles,
192
315
  attachmentsPolicy,
193
- attachmentMode: selectedPlan.mode,
316
+ attachmentMode: shouldBundle
317
+ ? "bundle"
318
+ : attachments.length > 0
319
+ ? "upload"
320
+ : selectedPlan.mode === "bundle"
321
+ ? "inline"
322
+ : selectedPlan.mode,
194
323
  fallback,
195
324
  bundled,
196
325
  };
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
3
4
  import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "../browser/constants.js";
4
5
  import { normalizeChatgptUrl } from "../browser/utils.js";
5
6
  import { parseDuration } from "../duration.js";
@@ -153,7 +154,7 @@ export async function buildBrowserConfig(options) {
153
154
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
154
155
  remoteChrome,
155
156
  browserTabRef: options.browserTab ?? undefined,
156
- thinkingTime: options.browserThinkingTime,
157
+ thinkingTime: normalizeThinkingTimeLevel(options.browserThinkingTime) ?? undefined,
157
158
  researchMode: options.browserResearch === "deep" ? "deep" : "off",
158
159
  archiveConversations: options.browserArchive,
159
160
  };
@@ -1,5 +1,6 @@
1
1
  import { CHATGPT_URL } from "../browser/constants.js";
2
2
  import { normalizeChatgptUrl } from "../browser/utils.js";
3
+ import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
3
4
  export function applyBrowserDefaultsFromConfig(options, config, getSource) {
4
5
  const browser = config.browser;
5
6
  if (!browser)
@@ -89,7 +90,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
89
90
  options.browserModelStrategy = browser.modelStrategy;
90
91
  }
91
92
  if (isUnset("browserThinkingTime") && browser.thinkingTime !== undefined) {
92
- options.browserThinkingTime = browser.thinkingTime;
93
+ options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;
93
94
  }
94
95
  if (isUnset("browserResearch") && browser.researchMode !== undefined) {
95
96
  options.browserResearch = browser.researchMode;