@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.
- package/dist/bin/oracle-cli.js +4 -4
- package/dist/src/browser/actions/assistantResponse.js +125 -73
- package/dist/src/browser/actions/attachments.js +307 -130
- package/dist/src/browser/actions/modelSelection.js +127 -7
- package/dist/src/browser/actions/promptComposer.js +60 -59
- package/dist/src/browser/config.js +7 -2
- package/dist/src/browser/constants.js +6 -2
- package/dist/src/browser/index.js +84 -7
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/utils.js +10 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +40 -9
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/options.js +20 -8
- package/dist/src/cli/runOptions.js +4 -1
- package/dist/src/gemini-web/client.js +17 -11
- package/dist/src/gemini-web/executor.js +82 -62
- package/dist/src/mcp/tools/consult.js +4 -1
- package/dist/src/oracle/config.js +1 -1
- package/dist/src/oracle/run.js +15 -4
- package/package.json +16 -16
|
@@ -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
|
-
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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 = '
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/browserMode.js
CHANGED
|
@@ -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';
|