@steipete/oracle 0.7.0 → 0.7.2

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.
@@ -18,7 +18,13 @@ export async function ensureModelSelection(Runtime, desiredModel, logger) {
18
18
  }
19
19
  case 'option-not-found': {
20
20
  await logDomFailure(Runtime, logger, 'model-switcher-option');
21
- throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.`);
21
+ const isTemporary = result.hint?.temporaryChat ?? false;
22
+ const available = (result.hint?.availableOptions ?? []).filter(Boolean);
23
+ const availableHint = available.length > 0 ? ` Available: ${available.join(', ')}.` : '';
24
+ const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
25
+ ? ' You are in Temporary Chat mode; Pro models are not available there. Remove "temporary-chat=true" from --chatgpt-url or use a non-Pro model (e.g. gpt-5.2).'
26
+ : '';
27
+ throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.${availableHint}${tempHint}`);
22
28
  }
23
29
  default: {
24
30
  await logDomFailure(Runtime, logger, 'model-switcher-button');
@@ -63,12 +69,41 @@ function buildModelSelectionExpression(targetModel) {
63
69
  .map((token) => normalizeText(token))
64
70
  .filter(Boolean);
65
71
  const targetWords = normalizedTarget.split(' ').filter(Boolean);
72
+ const desiredVersion = normalizedTarget.includes('5 2')
73
+ ? '5-2'
74
+ : normalizedTarget.includes('5 1')
75
+ ? '5-1'
76
+ : normalizedTarget.includes('5 0')
77
+ ? '5-0'
78
+ : null;
79
+ const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
80
+ const wantsInstant = normalizedTarget.includes('instant');
81
+ const wantsThinking = normalizedTarget.includes('thinking');
66
82
 
67
83
  const button = document.querySelector(BUTTON_SELECTOR);
68
84
  if (!button) {
69
85
  return { status: 'button-missing' };
70
86
  }
71
87
 
88
+ const getButtonLabel = () => (button.textContent ?? '').trim();
89
+ const buttonMatchesTarget = () => {
90
+ const normalizedLabel = normalizeText(getButtonLabel());
91
+ if (!normalizedLabel) return false;
92
+ if (desiredVersion) {
93
+ if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
94
+ if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
95
+ if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
96
+ }
97
+ if (wantsPro && !normalizedLabel.includes(' pro')) return false;
98
+ if (wantsInstant && !normalizedLabel.includes('instant')) return false;
99
+ if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
100
+ return true;
101
+ };
102
+
103
+ if (buttonMatchesTarget()) {
104
+ return { status: 'already-selected', label: getButtonLabel() };
105
+ }
106
+
72
107
  let lastPointerClick = 0;
73
108
  const pointerClick = () => {
74
109
  if (dispatchClickSequence(button)) {
@@ -106,8 +141,46 @@ function buildModelSelectionExpression(targetModel) {
106
141
  }
107
142
  let score = 0;
108
143
  const normalizedTestId = (testid ?? '').toLowerCase();
109
- if (normalizedTestId && TEST_IDS.some((id) => normalizedTestId.includes(id))) {
110
- score += 1000;
144
+ if (normalizedTestId) {
145
+ if (desiredVersion) {
146
+ // data-testid strings have been observed with both dotted and dashed versions (e.g. gpt-5.2-pro vs gpt-5-2-pro).
147
+ const has52 =
148
+ normalizedTestId.includes('5-2') ||
149
+ normalizedTestId.includes('5.2') ||
150
+ normalizedTestId.includes('gpt-5-2') ||
151
+ normalizedTestId.includes('gpt-5.2') ||
152
+ normalizedTestId.includes('gpt52');
153
+ const has51 =
154
+ normalizedTestId.includes('5-1') ||
155
+ normalizedTestId.includes('5.1') ||
156
+ normalizedTestId.includes('gpt-5-1') ||
157
+ normalizedTestId.includes('gpt-5.1') ||
158
+ normalizedTestId.includes('gpt51');
159
+ const has50 =
160
+ normalizedTestId.includes('5-0') ||
161
+ normalizedTestId.includes('5.0') ||
162
+ normalizedTestId.includes('gpt-5-0') ||
163
+ normalizedTestId.includes('gpt-5.0') ||
164
+ normalizedTestId.includes('gpt50');
165
+ const candidateVersion = has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null;
166
+ // If a candidate advertises a different version, ignore it entirely.
167
+ if (candidateVersion && candidateVersion !== desiredVersion) {
168
+ return 0;
169
+ }
170
+ // When targeting an explicit version, avoid selecting submenu wrappers that can contain legacy models.
171
+ if (normalizedTestId.includes('submenu') && candidateVersion === null) {
172
+ return 0;
173
+ }
174
+ }
175
+ const matches = TEST_IDS.filter((id) => id && normalizedTestId.includes(id));
176
+ if (matches.length > 0) {
177
+ // Prefer the most specific match (longest token) instead of treating any hit as equal.
178
+ // This prevents generic tokens (e.g. "pro") from outweighing version-specific targets.
179
+ const best = matches.reduce((acc, token) => (token.length > acc.length ? token : acc), '');
180
+ score += 200 + Math.min(900, best.length * 25);
181
+ if (best.startsWith('model-switcher-')) score += 120;
182
+ if (best.includes('gpt-')) score += 60;
183
+ }
111
184
  }
112
185
  if (normalizedText && normalizedTarget) {
113
186
  if (normalizedText === normalizedTarget) {
@@ -134,6 +207,14 @@ function buildModelSelectionExpression(targetModel) {
134
207
  }
135
208
  score -= missing * 12;
136
209
  }
210
+ // If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
211
+ if (wantsPro) {
212
+ if (!normalizedText.includes(' pro')) {
213
+ score -= 80;
214
+ }
215
+ } else if (normalizedText.includes(' pro')) {
216
+ score -= 40;
217
+ }
137
218
  return Math.max(score, 0);
138
219
  };
139
220
 
@@ -153,7 +234,7 @@ function buildModelSelectionExpression(targetModel) {
153
234
  }
154
235
  const label = getOptionLabel(option);
155
236
  if (!bestMatch || score > bestMatch.score) {
156
- bestMatch = { node: option, label, score };
237
+ bestMatch = { node: option, label, score, testid, normalizedText };
157
238
  }
158
239
  }
159
240
  }
@@ -162,6 +243,28 @@ function buildModelSelectionExpression(targetModel) {
162
243
 
163
244
  return new Promise((resolve) => {
164
245
  const start = performance.now();
246
+ const detectTemporaryChat = () => {
247
+ try {
248
+ const url = new URL(window.location.href);
249
+ const flag = (url.searchParams.get('temporary-chat') ?? '').toLowerCase();
250
+ if (flag === 'true' || flag === '1' || flag === 'yes') return true;
251
+ } catch {}
252
+ const title = (document.title || '').toLowerCase();
253
+ if (title.includes('temporary chat')) return true;
254
+ const body = (document.body?.innerText || '').toLowerCase();
255
+ return body.includes('temporary chat');
256
+ };
257
+ const collectAvailableOptions = () => {
258
+ const menuRoots = Array.from(document.querySelectorAll(${menuContainerLiteral}));
259
+ const nodes = menuRoots.length > 0
260
+ ? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(${menuItemLiteral})))
261
+ : Array.from(document.querySelectorAll(${menuItemLiteral}));
262
+ const labels = nodes
263
+ .map((node) => (node?.textContent ?? '').trim())
264
+ .filter(Boolean)
265
+ .filter((label, index, arr) => arr.indexOf(label) === index);
266
+ return labels.slice(0, 12);
267
+ };
165
268
  const ensureMenuOpen = () => {
166
269
  const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
167
270
  if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
@@ -182,15 +285,32 @@ function buildModelSelectionExpression(targetModel) {
182
285
  const match = findBestOption();
183
286
  if (match) {
184
287
  if (optionIsSelected(match.node)) {
185
- resolve({ status: 'already-selected', label: match.label });
288
+ resolve({ status: 'already-selected', label: getButtonLabel() || match.label });
186
289
  return;
187
290
  }
188
291
  dispatchClickSequence(match.node);
189
- resolve({ status: 'switched', label: match.label });
292
+ // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
293
+ // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
294
+ const isSubmenu = (match.testid ?? '').toLowerCase().includes('submenu');
295
+ if (isSubmenu) {
296
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
297
+ return;
298
+ }
299
+ // Wait for the top bar label to reflect the requested model; otherwise keep scanning.
300
+ setTimeout(() => {
301
+ if (buttonMatchesTarget()) {
302
+ resolve({ status: 'switched', label: getButtonLabel() || match.label });
303
+ return;
304
+ }
305
+ attempt();
306
+ }, Math.max(120, INITIAL_WAIT_MS));
190
307
  return;
191
308
  }
192
309
  if (performance.now() - start > MAX_WAIT_MS) {
193
- resolve({ status: 'option-not-found' });
310
+ resolve({
311
+ status: 'option-not-found',
312
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
313
+ });
194
314
  return;
195
315
  }
196
316
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
@@ -135,7 +135,6 @@ export async function submitPrompt(deps, prompt, logger) {
135
135
  logger('Clicked send button');
136
136
  }
137
137
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
138
- await clickAnswerNowIfPresent(runtime, logger);
139
138
  }
140
139
  export async function clearPromptComposer(Runtime, logger) {
141
140
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
@@ -186,6 +185,43 @@ async function waitForDomReady(Runtime, logger) {
186
185
  }
187
186
  logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
188
187
  }
188
+ function buildAttachmentReadyExpression(attachmentNames) {
189
+ const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
190
+ return `(() => {
191
+ const names = ${namesLiteral};
192
+ const composer =
193
+ document.querySelector('[data-testid*="composer"]') ||
194
+ document.querySelector('form') ||
195
+ document.body ||
196
+ document;
197
+ const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
198
+
199
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
200
+ const attachmentSelectors = [
201
+ '[data-testid*="chip"]',
202
+ '[data-testid*="attachment"]',
203
+ '[data-testid*="upload"]',
204
+ '[aria-label="Remove file"]',
205
+ 'button[aria-label="Remove file"]',
206
+ ];
207
+
208
+ const chipsReady = names.every((name) =>
209
+ Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
210
+ );
211
+ const inputsReady = names.every((name) =>
212
+ Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
213
+ Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
214
+ file?.name?.toLowerCase?.().includes(name),
215
+ ),
216
+ ),
217
+ );
218
+
219
+ return chipsReady || inputsReady;
220
+ })()`;
221
+ }
222
+ export function buildAttachmentReadyExpressionForTest(attachmentNames) {
223
+ return buildAttachmentReadyExpression(attachmentNames);
224
+ }
189
225
  async function attemptSendButton(Runtime, _logger, attachmentNames) {
190
226
  const script = `(() => {
191
227
  ${buildClickDispatcher()}
@@ -215,19 +251,7 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
215
251
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
216
252
  if (needAttachment) {
217
253
  const ready = await Runtime.evaluate({
218
- expression: `(() => {
219
- const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
220
- const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
221
- const chipsReady = names.every((name) =>
222
- Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
223
- );
224
- const inputsReady = names.every((name) =>
225
- Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
226
- Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
227
- ),
228
- );
229
- return chipsReady || inputsReady;
230
- })()`,
254
+ expression: buildAttachmentReadyExpression(attachmentNames),
231
255
  returnByValue: true,
232
256
  });
233
257
  if (!ready?.result?.value) {
@@ -246,57 +270,34 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
246
270
  }
247
271
  return false;
248
272
  }
249
- async function clickAnswerNowIfPresent(Runtime, logger) {
250
- const script = `(() => {
251
- ${buildClickDispatcher()}
252
- const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
253
- const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
254
- if (!candidate) return 'missing';
255
- const button = candidate.closest('button') ?? candidate;
256
- const style = window.getComputedStyle(button);
257
- const disabled =
258
- button.hasAttribute('disabled') ||
259
- button.getAttribute('aria-disabled') === 'true' ||
260
- style.pointerEvents === 'none' ||
261
- style.display === 'none';
262
- if (disabled) return 'disabled';
263
- dispatchClickSequence(button);
264
- return 'clicked';
265
- })()`;
266
- const deadline = Date.now() + 3_000;
267
- while (Date.now() < deadline) {
268
- const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
269
- const status = result.value;
270
- if (status === 'clicked') {
271
- logger?.('Clicked "Answer now" gate');
272
- await delay(500);
273
- return;
274
- }
275
- if (status === 'missing')
276
- return;
277
- await delay(100);
278
- }
279
- }
280
273
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
281
274
  const deadline = Date.now() + timeoutMs;
282
275
  const encodedPrompt = JSON.stringify(prompt.trim());
283
276
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
284
277
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
285
278
  const script = `(() => {
286
- const editor = document.querySelector(${primarySelectorLiteral});
287
- const fallback = document.querySelector(${fallbackSelectorLiteral});
288
- const normalize = (value) => value?.toLowerCase?.().replace(/\\s+/g, ' ').trim() ?? '';
289
- const normalizedPrompt = normalize(${encodedPrompt});
290
- const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
291
- const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
292
- const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
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] ?? '';
299
- return {
279
+ const editor = document.querySelector(${primarySelectorLiteral});
280
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
281
+ const normalize = (value) => {
282
+ let text = value?.toLowerCase?.() ?? '';
283
+ // Strip markdown *markers* but keep content (ChatGPT renders fence markers differently).
284
+ text = text.replace(/\`\`\`[^\\n]*\\n([\\s\\S]*?)\`\`\`/g, ' $1 ');
285
+ text = text.replace(/\`\`\`/g, ' ');
286
+ text = text.replace(/\`([^\`]*)\`/g, '$1');
287
+ return text.replace(/\\s+/g, ' ').trim();
288
+ };
289
+ const normalizedPrompt = normalize(${encodedPrompt});
290
+ const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
291
+ const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
292
+ const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
293
+ const normalizedTurns = articles.map((node) => normalize(node?.innerText));
294
+ const userMatched =
295
+ normalizedPrompt.length > 0 && normalizedTurns.some((text) => text.includes(normalizedPrompt));
296
+ const prefixMatched =
297
+ normalizedPromptPrefix.length > 30 &&
298
+ normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
299
+ const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
300
+ return {
300
301
  userMatched,
301
302
  prefixMatched,
302
303
  fallbackValue: fallback?.value ?? '',
@@ -1,5 +1,5 @@
1
1
  import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
2
- import { normalizeChatgptUrl } from './utils.js';
2
+ import { isTemporaryChatUrl, normalizeChatgptUrl } from './utils.js';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  export const DEFAULT_BROWSER_CONFIG = {
@@ -32,6 +32,11 @@ export function resolveBrowserConfig(config) {
32
32
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? '').trim() === '1';
33
33
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
34
34
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
35
+ const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
36
+ if (isTemporaryChatUrl(normalizedUrl) && /\bpro\b/i.test(desiredModel)) {
37
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
38
+ 'Remove "temporary-chat=true" from your browser URL, or use a non-Pro model label (e.g. "GPT-5.2").');
39
+ }
35
40
  const isWindows = process.platform === 'win32';
36
41
  const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
37
42
  const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
@@ -53,7 +58,7 @@ export function resolveBrowserConfig(config) {
53
58
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
54
59
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
55
60
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
56
- desiredModel: config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel,
61
+ desiredModel,
57
62
  chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
58
63
  chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
59
64
  chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = 'https://chatgpt.com/';
2
- export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.2';
2
+ export const DEFAULT_MODEL_TARGET = 'GPT-5.2 Pro';
3
3
  export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com', 'https://atlas.openai.com'];
4
4
  export const INPUT_SELECTORS = [
5
5
  'textarea[data-id="prompt-textarea"]',
@@ -13,13 +13,17 @@ export const INPUT_SELECTORS = [
13
13
  ];
14
14
  export const ANSWER_SELECTORS = [
15
15
  'article[data-testid^="conversation-turn"][data-message-author-role="assistant"]',
16
+ 'article[data-testid^="conversation-turn"][data-turn="assistant"]',
16
17
  'article[data-testid^="conversation-turn"] [data-message-author-role="assistant"]',
18
+ 'article[data-testid^="conversation-turn"] [data-turn="assistant"]',
17
19
  'article[data-testid^="conversation-turn"] .markdown',
18
20
  '[data-message-author-role="assistant"] .markdown',
21
+ '[data-turn="assistant"] .markdown',
19
22
  '[data-message-author-role="assistant"]',
23
+ '[data-turn="assistant"]',
20
24
  ];
21
25
  export const CONVERSATION_TURN_SELECTOR = 'article[data-testid^="conversation-turn"]';
22
- export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"]';
26
+ export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"], [data-turn="assistant"]';
23
27
  export const CLOUDFLARE_SCRIPT_SELECTOR = 'script[src*="/challenge-platform/"]';
24
28
  export const CLOUDFLARE_TITLE = 'just a moment';
25
29
  export const PROMPT_PRIMARY_SELECTOR = '#prompt-textarea';
@@ -5,7 +5,7 @@ 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, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
10
  import { ensureExtendedThinking } from './actions/thinkingTime.js';
11
11
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -14,7 +14,7 @@ import { CHATGPT_URL } from './constants.js';
14
14
  import { BrowserAutomationError } from '../oracle/errors.js';
15
15
  import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
16
16
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
17
- export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
17
+ export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
18
18
  export async function runBrowserMode(options) {
19
19
  const promptText = options.prompt?.trim();
20
20
  if (!promptText) {
@@ -285,11 +285,18 @@ export async function runBrowserMode(options) {
285
285
  logger(`Uploading attachment: ${attachment.displayPath}`);
286
286
  await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
287
287
  }
288
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
288
+ // Scale timeout based on number of files: base 30s + 15s per additional file
289
+ const baseTimeout = config.inputTimeoutMs ?? 30_000;
290
+ const perFileTimeout = 15_000;
291
+ const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
289
292
  await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
290
293
  logger('All attachments uploaded');
291
294
  }
292
295
  await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
296
+ if (attachmentNames.length > 0) {
297
+ await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
298
+ logger('Verified attachments present on sent user message');
299
+ }
293
300
  };
294
301
  try {
295
302
  await raceWithDisconnect(submitOnce(promptText, attachments));
@@ -327,10 +334,13 @@ export async function runBrowserMode(options) {
327
334
  },
328
335
  })).catch(() => null);
329
336
  answerMarkdown = copiedMarkdown ?? answerText;
337
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
338
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
330
339
  // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
331
340
  const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
332
341
  const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
333
- if (finalText &&
342
+ if (!copiedMarkdown &&
343
+ finalText &&
334
344
  finalText !== answerMarkdown.trim() &&
335
345
  finalText !== promptText.trim() &&
336
346
  finalText.length >= answerMarkdown.trim().length) {
@@ -338,14 +348,26 @@ export async function runBrowserMode(options) {
338
348
  answerText = finalText;
339
349
  answerMarkdown = finalText;
340
350
  }
341
- if (answerMarkdown.trim() === promptText.trim()) {
351
+ // Detect prompt echo using normalized comparison (whitespace-insensitive)
352
+ const normalizedAnswer = normalizeForComparison(answerMarkdown);
353
+ const normalizedPrompt = normalizeForComparison(promptText);
354
+ const promptPrefix = normalizedPrompt.length >= 80
355
+ ? normalizedPrompt.slice(0, Math.min(200, normalizedPrompt.length))
356
+ : '';
357
+ const isPromptEcho = normalizedAnswer === normalizedPrompt || (promptPrefix.length > 0 && normalizedAnswer.startsWith(promptPrefix));
358
+ if (isPromptEcho) {
359
+ logger('Detected prompt echo in response; waiting for actual assistant response...');
342
360
  const deadline = Date.now() + 8_000;
343
361
  let bestText = null;
344
362
  let stableCount = 0;
345
363
  while (Date.now() < deadline) {
346
364
  const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
347
365
  const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
348
- if (text && text !== promptText.trim()) {
366
+ const normalizedText = normalizeForComparison(text);
367
+ const isStillEcho = !text ||
368
+ normalizedText === normalizedPrompt ||
369
+ (promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
370
+ if (!isStillEcho) {
349
371
  if (!bestText || text.length > bestText.length) {
350
372
  bestText = text;
351
373
  stableCount = 0;
@@ -661,7 +683,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
661
683
  logger(`Uploading attachment: ${attachment.displayPath}`);
662
684
  await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
663
685
  }
664
- const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
686
+ // Scale timeout based on number of files: base 30s + 15s per additional file
687
+ const baseTimeout = config.inputTimeoutMs ?? 30_000;
688
+ const perFileTimeout = 15_000;
689
+ const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
665
690
  await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
666
691
  logger('All attachments uploaded');
667
692
  }
@@ -703,6 +728,58 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
703
728
  },
704
729
  }).catch(() => null);
705
730
  answerMarkdown = copiedMarkdown ?? answerText;
731
+ // Helper to normalize text for echo detection (collapse whitespace, lowercase)
732
+ const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
733
+ // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
734
+ const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
735
+ const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
736
+ if (finalText &&
737
+ finalText !== answerMarkdown.trim() &&
738
+ finalText !== promptText.trim() &&
739
+ finalText.length >= answerMarkdown.trim().length) {
740
+ logger('Refreshed assistant response via final DOM snapshot');
741
+ answerText = finalText;
742
+ answerMarkdown = finalText;
743
+ }
744
+ // Detect prompt echo using normalized comparison (whitespace-insensitive)
745
+ const normalizedAnswer = normalizeForComparison(answerMarkdown);
746
+ const normalizedPrompt = normalizeForComparison(promptText);
747
+ const promptPrefix = normalizedPrompt.length >= 80
748
+ ? normalizedPrompt.slice(0, Math.min(200, normalizedPrompt.length))
749
+ : '';
750
+ const isPromptEcho = normalizedAnswer === normalizedPrompt || (promptPrefix.length > 0 && normalizedAnswer.startsWith(promptPrefix));
751
+ if (isPromptEcho) {
752
+ logger('Detected prompt echo in response; waiting for actual assistant response...');
753
+ const deadline = Date.now() + 8_000;
754
+ let bestText = null;
755
+ let stableCount = 0;
756
+ while (Date.now() < deadline) {
757
+ const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
758
+ const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
759
+ const normalizedText = normalizeForComparison(text);
760
+ const isStillEcho = !text ||
761
+ normalizedText === normalizedPrompt ||
762
+ (promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
763
+ if (!isStillEcho) {
764
+ if (!bestText || text.length > bestText.length) {
765
+ bestText = text;
766
+ stableCount = 0;
767
+ }
768
+ else if (text === bestText) {
769
+ stableCount += 1;
770
+ }
771
+ if (stableCount >= 2) {
772
+ break;
773
+ }
774
+ }
775
+ await new Promise((resolve) => setTimeout(resolve, 300));
776
+ }
777
+ if (bestText) {
778
+ logger('Recovered assistant response after detecting prompt echo');
779
+ answerText = bestText;
780
+ answerMarkdown = bestText;
781
+ }
782
+ }
706
783
  stopThinkingMonitor?.();
707
784
  const durationMs = Date.now() - startedAt;
708
785
  const answerChars = answerText.length;
@@ -1,5 +1,5 @@
1
1
  export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
2
2
  export { ensureModelSelection } from './actions/modelSelection.js';
3
3
  export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
- export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
4
+ export { uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments } from './actions/attachments.js';
5
5
  export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -110,3 +110,13 @@ export function normalizeChatgptUrl(raw, fallback) {
110
110
  // Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
111
111
  return parsed.toString();
112
112
  }
113
+ export function isTemporaryChatUrl(url) {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
117
+ return value === 'true' || value === '1' || value === 'yes';
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
@@ -1 +1 @@
1
- export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, } from './browser/index.js';
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';