@steipete/oracle 0.14.0 → 0.15.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.
Files changed (31) hide show
  1. package/README.md +2 -2
  2. package/dist/bin/oracle-cli.js +10 -8
  3. package/dist/docs-site/browser-mode.html +3 -2
  4. package/dist/docs-site/cli-reference.html +1 -1
  5. package/dist/docs-site/mcp.html +1 -1
  6. package/dist/src/browser/actions/assistantResponse.js +2 -1
  7. package/dist/src/browser/actions/deepResearch.js +132 -61
  8. package/dist/src/browser/actions/modelSelection.js +388 -30
  9. package/dist/src/browser/actions/thinkingTime.js +303 -65
  10. package/dist/src/browser/artifacts.js +2 -8
  11. package/dist/src/browser/chatgptFiles.js +198 -49
  12. package/dist/src/browser/chatgptImages.js +126 -24
  13. package/dist/src/browser/chromeLifecycle.js +35 -4
  14. package/dist/src/browser/deepResearchResult.js +23 -0
  15. package/dist/src/browser/index.js +145 -19
  16. package/dist/src/browser/profileCopy.js +93 -0
  17. package/dist/src/browser/projectSourcesRunner.js +2 -1
  18. package/dist/src/browser/prompt.js +151 -22
  19. package/dist/src/cli/browserConfig.js +19 -2
  20. package/dist/src/cli/browserDefaults.js +2 -1
  21. package/dist/src/cli/options.js +8 -0
  22. package/dist/src/cli/sessionRunner.js +13 -7
  23. package/dist/src/mcp/tools/chatgptImage.js +8 -3
  24. package/dist/src/mcp/tools/consult.js +9 -8
  25. package/dist/src/mcp/types.js +11 -2
  26. package/dist/src/oracle/thinkingTime.js +40 -0
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/package.json +6 -6
  30. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -458,7 +458,7 @@ async function configureBrowserDownloadPath(params) {
458
458
  }
459
459
  return false;
460
460
  }
461
- function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true) {
461
+ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true, options = {}) {
462
462
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
463
463
  ? Math.floor(minTurnIndex)
464
464
  : -1;
@@ -466,12 +466,22 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
466
466
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
467
467
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
468
468
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
469
+ const markClickedLiteral = JSON.stringify(options.markClicked === true);
470
+ const maxClicksLiteral = typeof options.maxClicks === "number" &&
471
+ Number.isFinite(options.maxClicks) &&
472
+ options.maxClicks > 0
473
+ ? Math.floor(options.maxClicks)
474
+ : 0;
469
475
  return `(() => {
470
476
  const MIN_TURN_INDEX = ${minTurnLiteral};
471
477
  const CONVERSATION_SELECTOR = ${conversationLiteral};
472
478
  const ASSISTANT_SELECTOR = ${assistantLiteral};
473
479
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
474
480
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
481
+ const MARK_CLICKED = ${markClickedLiteral};
482
+ const MAX_CLICKS = ${maxClicksLiteral};
483
+ const HAS_EXPECTED_LABELS = EXPECTED_LABELS.length > 0;
484
+ const CLICKED_ATTRIBUTE = 'data-oracle-download-clicked';
475
485
  const isAssistantTurn = (node) => {
476
486
  if (!(node instanceof HTMLElement)) return false;
477
487
  const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -482,33 +492,67 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
482
492
  if (testId.includes('assistant')) return true;
483
493
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
484
494
  };
495
+ const expectedFileButton = (button) => {
496
+ const text = (button.textContent || '').trim().toLowerCase();
497
+ return EXPECTED_LABELS.some((label) => {
498
+ const downloadLabel = 'download ' + label;
499
+ return text === label ||
500
+ text.startsWith(label + ' ') ||
501
+ text === downloadLabel ||
502
+ text.startsWith(downloadLabel + ' ');
503
+ });
504
+ };
505
+ const genericBehaviorButton = (button) => {
506
+ const text = (button.textContent || '').trim().toLowerCase();
507
+ return ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text);
508
+ };
509
+ const genericFallbackButton = (button) => {
510
+ if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
511
+ const text = (button.textContent || '').trim().toLowerCase();
512
+ const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
513
+ const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
514
+ return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
515
+ };
485
516
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
486
- const selected = new Set();
517
+ const expectedMatches = new Set();
518
+ const genericBehaviorMatches = new Set();
519
+ const genericFallbackMatches = new Set();
520
+ const genericAllMatches = new Set();
487
521
  for (let index = turns.length - 1; index >= 0; index -= 1) {
488
522
  const turn = turns[index];
489
523
  if (!isAssistantTurn(turn)) continue;
490
524
  if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
491
525
  const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) || turn;
492
- const buttons = Array.from(messageRoot.querySelectorAll('button'));
493
- const primary = buttons.filter((button) => {
494
- const text = (button.textContent || '').trim().toLowerCase();
495
- const expectedFile = EXPECTED_LABELS.some(
496
- (label) => text === label || text.startsWith(label + ' ')
497
- );
498
- return String(button.className || '').includes('behavior-btn') &&
499
- (expectedFile || (ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text)));
500
- });
501
- const fallback = primary.length > 0 ? [] : buttons.filter((button) => {
502
- if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
503
- const text = (button.textContent || '').trim().toLowerCase();
504
- const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
505
- const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
506
- return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
526
+ const buttons = Array.from(messageRoot.querySelectorAll('button'))
527
+ .filter((button) => !(MARK_CLICKED && button.getAttribute(CLICKED_ATTRIBUTE) === 'true'));
528
+ const behaviorButtons = buttons.filter((button) =>
529
+ String(button.className || '').includes('behavior-btn')
530
+ );
531
+ behaviorButtons.filter(expectedFileButton).forEach((button) => expectedMatches.add(button));
532
+ const genericBehavior = behaviorButtons.filter(genericBehaviorButton);
533
+ genericBehavior.forEach((button) => {
534
+ genericBehaviorMatches.add(button);
535
+ genericAllMatches.add(button);
507
536
  });
508
- [...primary, ...fallback].forEach((button) => selected.add(button));
537
+ if (genericBehavior.length === 0) {
538
+ buttons.filter(genericFallbackButton).forEach((button) => {
539
+ genericFallbackMatches.add(button);
540
+ genericAllMatches.add(button);
541
+ });
542
+ }
509
543
  }
510
- const selectedButtons = Array.from(selected);
511
- selectedButtons.forEach((button) => button.click());
544
+ const selected = expectedMatches.size > 0
545
+ ? expectedMatches
546
+ : HAS_EXPECTED_LABELS
547
+ ? genericBehaviorMatches.size > 0
548
+ ? genericBehaviorMatches
549
+ : genericFallbackMatches
550
+ : genericAllMatches;
551
+ const selectedButtons = Array.from(selected).slice(0, MAX_CLICKS > 0 ? MAX_CLICKS : undefined);
552
+ selectedButtons.forEach((button) => {
553
+ if (MARK_CLICKED) button.setAttribute(CLICKED_ATTRIBUTE, 'true');
554
+ button.click();
555
+ });
512
556
  return selectedButtons.map((button) => ({
513
557
  text: (button.textContent || '').trim(),
514
558
  ariaLabel: button.getAttribute('aria-label') || '',
@@ -516,6 +560,80 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
516
560
  }));
517
561
  })()`;
518
562
  }
563
+ function describeDownloadableFile(file) {
564
+ return (file.filename ??
565
+ file.label ??
566
+ filenameFromUrl(file.sandboxUrl) ??
567
+ filenameFromUrl(file.downloadUrl) ??
568
+ filenameFromUrl(file.url) ??
569
+ file.sandboxUrl ??
570
+ file.downloadUrl ??
571
+ file.url);
572
+ }
573
+ function expectedDownloadedFilename(file) {
574
+ const filename = file.filename ??
575
+ filenameFromUrl(file.sandboxUrl) ??
576
+ filenameFromUrl(file.downloadUrl) ??
577
+ filenameFromUrl(file.url);
578
+ const basename = path.basename(String(filename ?? "").trim());
579
+ return basename && basename !== "." ? basename : undefined;
580
+ }
581
+ async function moveDownloadedFileToExpectedName(filePath, file) {
582
+ const filename = expectedDownloadedFilename(file);
583
+ if (!filename) {
584
+ return filePath;
585
+ }
586
+ const targetPath = path.join(path.dirname(filePath), filename);
587
+ if (path.resolve(targetPath) === path.resolve(filePath)) {
588
+ return filePath;
589
+ }
590
+ const expected = path.parse(filename);
591
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
592
+ const duplicatePattern = new RegExp(`^${escapeRegExp(expected.name)} ?\\(\\d+\\)${escapeRegExp(expected.ext)}$`);
593
+ if (!duplicatePattern.test(path.basename(filePath))) {
594
+ return filePath;
595
+ }
596
+ const targetExists = await fs
597
+ .stat(targetPath)
598
+ .then((stat) => stat.isFile())
599
+ .catch(() => false);
600
+ if (targetExists) {
601
+ return filePath;
602
+ }
603
+ await fs.rename(filePath, targetPath);
604
+ return targetPath;
605
+ }
606
+ async function clickAssistantDownloadButtons(params) {
607
+ const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks });
608
+ const deadline = Date.now() + (params.timeoutMs ?? DOWNLOAD_BUTTON_WAIT_MS);
609
+ while (Date.now() < deadline) {
610
+ const { result } = await params.Runtime.evaluate({
611
+ expression,
612
+ returnByValue: true,
613
+ });
614
+ const clicked = Array.isArray(result?.value) ? result.value : [];
615
+ if (clicked.length > 0) {
616
+ return clicked;
617
+ }
618
+ await new Promise((resolve) => setTimeout(resolve, 250));
619
+ }
620
+ return [];
621
+ }
622
+ async function savedBrowserFileFromPath(filePath) {
623
+ const filename = path.basename(filePath);
624
+ const stat = await fs.stat(filePath);
625
+ return {
626
+ kind: "file",
627
+ path: filePath,
628
+ label: filename,
629
+ mimeType: mimeTypeFromFilename(filename),
630
+ sizeBytes: stat.size,
631
+ sourceUrl: "browser-download",
632
+ url: "browser-download",
633
+ finalUrl: "browser-download",
634
+ filename,
635
+ };
636
+ }
519
637
  export async function saveAssistantDownloadButtonArtifacts(params) {
520
638
  if ((!params.sessionId && !params.downloadPath) ||
521
639
  (!params.Client && !params.Browser && !params.Page)) {
@@ -539,41 +657,72 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
539
657
  params.logger?.("[browser] Browser download path could not be configured; skipping button fallback.");
540
658
  return [];
541
659
  }
542
- let clicked = [];
543
- const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, resolveDownloadButtonLabels(params.files ?? []), params.allowGenericDownloadLabels);
544
- const deadline = Date.now() + DOWNLOAD_BUTTON_WAIT_MS;
545
- while (Date.now() < deadline) {
546
- const { result } = await params.Runtime.evaluate({
547
- expression,
548
- returnByValue: true,
660
+ const buttonWaitMs = params.buttonWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
661
+ const downloadWaitMs = params.downloadWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
662
+ const expectedFiles = params.files ?? [];
663
+ if (expectedFiles.length === 0) {
664
+ const clicked = await clickAssistantDownloadButtons({
665
+ Runtime: params.Runtime,
666
+ minTurnIndex: params.minTurnIndex,
667
+ expectedLabels: [],
668
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels,
669
+ timeoutMs: buttonWaitMs,
549
670
  });
550
- clicked = Array.isArray(result?.value) ? result.value : [];
551
- if (clicked.length > 0) {
671
+ if (clicked.length === 0) {
672
+ params.logger?.("[browser] No assistant download buttons found for button fallback.");
673
+ return [];
674
+ }
675
+ params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
676
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length, downloadWaitMs);
677
+ return Promise.all(downloaded.map(savedBrowserFileFromPath));
678
+ }
679
+ let clickedCount = 0;
680
+ let knownEntries = before;
681
+ const downloadedPaths = [];
682
+ const missingFiles = [];
683
+ const unattemptedFiles = [];
684
+ for (const [fileIndex, file] of expectedFiles.entries()) {
685
+ const expectedLabels = resolveDownloadButtonLabels([file]);
686
+ const clicked = await clickAssistantDownloadButtons({
687
+ Runtime: params.Runtime,
688
+ minTurnIndex: params.minTurnIndex,
689
+ expectedLabels,
690
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels === true,
691
+ markClicked: true,
692
+ maxClicks: 1,
693
+ timeoutMs: buttonWaitMs,
694
+ });
695
+ const displayName = describeDownloadableFile(file);
696
+ if (clicked.length === 0) {
697
+ missingFiles.push(displayName);
698
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
699
+ continue;
700
+ }
701
+ clickedCount += clicked.length;
702
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, knownEntries, 1, downloadWaitMs);
703
+ if (downloaded.length === 0) {
704
+ missingFiles.push(displayName);
705
+ unattemptedFiles.push(...expectedFiles.slice(fileIndex + 1).map(describeDownloadableFile));
706
+ missingFiles.push(...unattemptedFiles);
707
+ params.logger?.(`[browser] Download timed out for ${displayName}${unattemptedFiles.length > 0
708
+ ? `; skipped remaining expected file(s) to avoid misassigning a late completion: ${unattemptedFiles.join(", ")}`
709
+ : ""}`);
552
710
  break;
553
711
  }
554
- await new Promise((resolve) => setTimeout(resolve, 250));
712
+ const normalizedDownloads = await Promise.all(downloaded.map((filePath, index) => index === 0 ? moveDownloadedFileToExpectedName(filePath, file) : filePath));
713
+ downloadedPaths.push(...normalizedDownloads);
714
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
555
715
  }
556
- if (clicked.length === 0) {
716
+ if (clickedCount === 0) {
557
717
  params.logger?.("[browser] No assistant download buttons found for button fallback.");
558
- return [];
559
718
  }
560
- params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
561
- const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length);
562
- return Promise.all(downloaded.map(async (filePath) => {
563
- const filename = path.basename(filePath);
564
- const stat = await fs.stat(filePath);
565
- return {
566
- kind: "file",
567
- path: filePath,
568
- label: filename,
569
- mimeType: mimeTypeFromFilename(filename),
570
- sizeBytes: stat.size,
571
- sourceUrl: "browser-download",
572
- url: "browser-download",
573
- finalUrl: "browser-download",
574
- filename,
575
- };
576
- }));
719
+ else {
720
+ params.logger?.(`[browser] Clicked ${clickedCount} assistant download button(s).`);
721
+ }
722
+ if (missingFiles.length > 0) {
723
+ params.logger?.(`[browser] Download button fallback did not save expected file(s): ${missingFiles.join(", ")}`);
724
+ }
725
+ return Promise.all([...new Set(downloadedPaths)].map(savedBrowserFileFromPath));
577
726
  }
578
727
  async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
579
728
  let currentUrl = new URL(downloadUrl);
@@ -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,
@@ -15,20 +15,31 @@ export async function launchChrome(config, userDataDir, logger) {
15
15
  const debugPort = config.debugPort ?? parseDebugPortEnv();
16
16
  const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress);
17
17
  const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
18
+ // copy-profile reuses a copied signed-in profile whose cookies are
19
+ // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
20
+ // strip the keychain-mocking flags from both chrome-launcher's defaults and
21
+ // Oracle's set, and ignore the defaults so they aren't re-added.
22
+ const usingCopiedProfile = Boolean(config.copyProfileSource);
23
+ if (usingCopiedProfile && config.chromeProfile) {
24
+ chromeFlags.push(`--profile-directory=${config.chromeProfile}`);
25
+ }
26
+ const launchOptions = resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile);
18
27
  const launcher = usePatchedLauncher
19
28
  ? await launchWithCustomHost({
20
- chromeFlags,
29
+ chromeFlags: launchOptions.chromeFlags,
21
30
  chromePath: config.chromePath ?? undefined,
22
31
  userDataDir,
23
32
  host: connectHost ?? "127.0.0.1",
24
33
  requestedPort: debugPort ?? undefined,
34
+ ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
25
35
  })
26
36
  : await launch({
27
37
  chromePath: config.chromePath ?? undefined,
28
- chromeFlags,
38
+ chromeFlags: launchOptions.chromeFlags,
29
39
  userDataDir,
30
40
  handleSIGINT: false,
31
41
  port: debugPort ?? undefined,
42
+ ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
32
43
  });
33
44
  const pidLabel = typeof launcher.pid === "number" ? ` (pid ${launcher.pid})` : "";
34
45
  const hostLabel = connectHost ? ` on ${connectHost}` : "";
@@ -44,10 +55,14 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
44
55
  }
45
56
  handling = true;
46
57
  const inFlight = opts?.isInFlight?.() ?? false;
47
- const leaveRunning = keepBrowser || inFlight;
58
+ const forceCleanup = opts?.forceProfileCleanup ?? false;
59
+ const leaveRunning = (keepBrowser || inFlight) && !forceCleanup;
48
60
  if (leaveRunning) {
49
61
  logger(`Received ${signal}; leaving Chrome running${inFlight ? " (assistant response pending)" : ""}`);
50
62
  }
63
+ else if (forceCleanup && (keepBrowser || inFlight)) {
64
+ logger(`Received ${signal}; terminating Chrome and removing the copied profile (copy-profile is not retained)`);
65
+ }
51
66
  else {
52
67
  logger(`Received ${signal}; terminating Chrome process`);
53
68
  }
@@ -343,6 +358,9 @@ function createSessionBoundChromeClient(browser, sessionId) {
343
358
  // Raw `send` here is the browser-level send (not session-bound), so callers
344
359
  // that issue Target.* via `send` must pass this page session id explicitly to
345
360
  // stay scoped to this tab (e.g. Deep Research OOPIF auto-attach).
361
+ // chrome-remote-interface defines `send` on the client prototype, so object
362
+ // spread does not preserve it. Bind it explicitly for raw session commands.
363
+ send: typeof browser.send === "function" ? browser.send.bind(browser) : undefined,
346
364
  oraclePageSessionId: sessionId,
347
365
  Network: bindDomain("Network"),
348
366
  Page: bindDomain("Page"),
@@ -472,6 +490,18 @@ function buildChromeFlags(headless, debugBindAddress) {
472
490
  }
473
491
  return flags;
474
492
  }
493
+ function resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile) {
494
+ if (!usingCopiedProfile) {
495
+ return { chromeFlags, ignoreDefaultFlags: false };
496
+ }
497
+ return {
498
+ chromeFlags: [...Launcher.defaultFlags(), ...chromeFlags].filter((flag) => flag !== "--use-mock-keychain" && flag !== "--password-store=basic"),
499
+ ignoreDefaultFlags: true,
500
+ };
501
+ }
502
+ export function resolveChromeLaunchOptionsForTest(chromeFlags, usingCopiedProfile) {
503
+ return resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile);
504
+ }
475
505
  function parseDebugPortEnv() {
476
506
  const raw = process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT;
477
507
  if (!raw)
@@ -514,13 +544,14 @@ function isWsl() {
514
544
  const release = os.release();
515
545
  return release.toLowerCase().includes("microsoft");
516
546
  }
517
- async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, }) {
547
+ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, ignoreDefaultFlags, }) {
518
548
  const launcher = new Launcher({
519
549
  chromePath: chromePath ?? undefined,
520
550
  chromeFlags,
521
551
  userDataDir,
522
552
  handleSIGINT: false,
523
553
  port: requestedPort ?? undefined,
554
+ ignoreDefaultFlags,
524
555
  });
525
556
  if (host) {
526
557
  const patched = launcher;
@@ -0,0 +1,23 @@
1
+ export function isDeepResearchIncompleteText(text) {
2
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
3
+ const lines = text
4
+ .split(/\n+/)
5
+ .map((line) => line.trim())
6
+ .filter(Boolean);
7
+ const tailIsPlanningPanel = text.length <= 1_500 &&
8
+ lines.length >= 4 &&
9
+ lines.length <= 20 &&
10
+ /^update$/i.test(lines[1] ?? "") &&
11
+ /^stop research$/i.test(lines.at(-1) ?? "") &&
12
+ /^determining steps for creating a report(?:\.\.\.)?$/i.test(lines.at(-2) ?? "");
13
+ return (normalized === "called tool" ||
14
+ normalized === "used tool" ||
15
+ normalized === "użyto narzędzia" ||
16
+ normalized === "narzędzie wywołane" ||
17
+ normalized === "planning" ||
18
+ normalized === "researching" ||
19
+ normalized === "searching the web" ||
20
+ (text.trimStart().startsWith("<system-reminder>") &&
21
+ /<system-reminder>[\s\S]*#\s*plan mode\b/i.test(text)) ||
22
+ tailIsPlanningPanel);
23
+ }