@steipete/oracle 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/dist/bin/oracle-cli.js +440 -98
- package/dist/src/browser/actions/modelSelection.js +53 -15
- package/dist/src/browser/actions/navigation.js +5 -3
- package/dist/src/browser/actions/promptComposer.js +75 -18
- package/dist/src/browser/actions/thinkingTime.js +23 -8
- package/dist/src/browser/constants.js +1 -1
- package/dist/src/browser/index.js +41 -7
- package/dist/src/browser/manualLoginProfile.js +54 -0
- package/dist/src/browser/projectSourcesRunner.js +16 -5
- package/dist/src/browser/prompt.js +56 -37
- package/dist/src/browser/sessionRunner.js +72 -1
- package/dist/src/browser/utils.js +1 -47
- package/dist/src/browser/zipBundle.js +152 -0
- package/dist/src/cli/browserConfig.js +13 -11
- package/dist/src/cli/browserDefaults.js +2 -1
- package/dist/src/cli/docsCheck.js +186 -0
- package/dist/src/cli/engine.js +11 -4
- package/dist/src/cli/options.js +12 -6
- package/dist/src/cli/perfTrace.js +242 -0
- package/dist/src/cli/promptRequirement.js +2 -0
- package/dist/src/cli/providerDoctor.js +85 -0
- package/dist/src/cli/runOptions.js +46 -16
- package/dist/src/cli/sessionDisplay.js +39 -4
- package/dist/src/cli/sessionLifecycle.js +38 -0
- package/dist/src/cli/sessionRunner.js +228 -3
- package/dist/src/cli/sessionTable.js +2 -1
- package/dist/src/duration.js +47 -0
- package/dist/src/mcp/tools/consult.js +19 -3
- package/dist/src/mcp/types.js +1 -0
- package/dist/src/mcp/utils.js +4 -1
- package/dist/src/oracle/baseUrl.js +17 -0
- package/dist/src/oracle/client.js +1 -22
- package/dist/src/oracle/config.js +17 -4
- package/dist/src/oracle/gemini.js +2 -22
- package/dist/src/oracle/geminiModels.js +21 -0
- package/dist/src/oracle/modelResolver.js +7 -1
- package/dist/src/oracle/multiModelRunner.js +20 -2
- package/dist/src/oracle/providerFailures.js +204 -0
- package/dist/src/oracle/providerRoutePlan.js +281 -0
- package/dist/src/oracle/providerRouting.js +92 -0
- package/dist/src/oracle/run.js +157 -54
- package/dist/src/oracle.js +1 -0
- package/dist/src/remote/client.js +8 -0
- package/dist/src/remote/server.js +26 -0
- package/dist/src/sessionManager.js +5 -1
- package/package.json +3 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { COMPOSER_MODEL_SIGNAL_SELECTOR, MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
|
|
2
2
|
import { logDomFailure } from "../domDebug.js";
|
|
3
3
|
import { buildClickDispatcher } from "./domEvents.js";
|
|
4
|
+
const LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
|
|
5
|
+
const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
|
|
4
6
|
export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
|
|
5
7
|
const outcome = await Runtime.evaluate({
|
|
6
8
|
expression: buildModelSelectionExpression(desiredModel, strategy),
|
|
@@ -17,7 +19,15 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
|
|
|
17
19
|
assertResolvedModelSelection(desiredModel, label);
|
|
18
20
|
}
|
|
19
21
|
logger(`Model picker: ${label}`);
|
|
20
|
-
return
|
|
22
|
+
return {
|
|
23
|
+
requestedModel: desiredModel,
|
|
24
|
+
resolvedLabel: label,
|
|
25
|
+
strategy,
|
|
26
|
+
status: result.status,
|
|
27
|
+
verified: strategy !== "current",
|
|
28
|
+
source: "chatgpt-model-picker",
|
|
29
|
+
capturedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
21
31
|
}
|
|
22
32
|
case "option-not-found": {
|
|
23
33
|
await logDomFailure(Runtime, logger, "model-switcher-option");
|
|
@@ -38,22 +48,39 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
|
|
|
38
48
|
function assertResolvedModelSelection(desiredModel, resolvedLabel) {
|
|
39
49
|
const desired = desiredModel.toLowerCase();
|
|
40
50
|
const resolved = resolvedLabel.toLowerCase();
|
|
41
|
-
const wantsGpt55Pro = desired === "
|
|
51
|
+
const wantsGpt55Pro = desired === "pro" ||
|
|
52
|
+
desired === "chatgpt pro" ||
|
|
53
|
+
desired === "gpt-5.5-pro" ||
|
|
42
54
|
desired.includes("5.5 pro") ||
|
|
43
55
|
desired.includes("5-5 pro") ||
|
|
44
56
|
(desired.includes("pro") && desired.includes("extended"));
|
|
45
57
|
if (!wantsGpt55Pro || !resolved) {
|
|
46
58
|
return;
|
|
47
59
|
}
|
|
48
|
-
|
|
60
|
+
if (!hasCurrentProSignal(resolved) ||
|
|
61
|
+
hasLegacyProVersionLabel(resolved) ||
|
|
62
|
+
(resolved.includes("thinking") && !resolved.includes("pro"))) {
|
|
63
|
+
throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function normalizeResolvedModelLabel(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
69
|
+
.replace(/\s+/g, " ")
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
function hasCurrentProSignal(resolved) {
|
|
73
|
+
return (resolved.includes(" pro") ||
|
|
49
74
|
resolved.endsWith("pro") ||
|
|
50
75
|
resolved.includes("pro ") ||
|
|
51
76
|
resolved.includes("extended") ||
|
|
52
77
|
resolved.includes("gpt-5.5-pro") ||
|
|
53
|
-
resolved.includes("gpt 5 5 pro");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
resolved.includes("gpt 5 5 pro"));
|
|
79
|
+
}
|
|
80
|
+
function hasLegacyProVersionLabel(resolved) {
|
|
81
|
+
const normalized = normalizeResolvedModelLabel(resolved);
|
|
82
|
+
return (LEGACY_PRO_VERSION_WORD_TOKENS.some((token) => normalized.includes(token)) ||
|
|
83
|
+
LEGACY_PRO_VERSION_COMPACT_TOKENS.some((token) => resolved.includes(token)));
|
|
57
84
|
}
|
|
58
85
|
export function assertResolvedModelSelectionForTest(desiredModel, resolvedLabel) {
|
|
59
86
|
assertResolvedModelSelection(desiredModel, resolvedLabel);
|
|
@@ -121,8 +148,16 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
121
148
|
const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
|
|
122
149
|
const wantsInstant = normalizedTarget.includes('instant');
|
|
123
150
|
const wantsThinking = normalizedTarget.includes('thinking');
|
|
151
|
+
const targetUsesCurrentGpt55Alias =
|
|
152
|
+
desiredVersion === '5-5' || normalizedTarget === 'pro' || normalizedTarget === 'chatgpt pro';
|
|
153
|
+
const labelHasProWord = (label) => label === 'pro' || label.startsWith('pro ') || label.includes(' pro ') || label.endsWith(' pro');
|
|
154
|
+
const legacyProVersionTokens = ['5 4', '5 2', '5 1', '5 0', 'gpt54', 'gpt52', 'gpt51', 'gpt50', 'gpt 5 pro'];
|
|
155
|
+
const labelHasLegacyProVersion = (value) => {
|
|
156
|
+
const label = normalizeText(value);
|
|
157
|
+
return legacyProVersionTokens.some((token) => label.includes(token));
|
|
158
|
+
};
|
|
124
159
|
const isTargetGpt55VisibleAlias = (value) => {
|
|
125
|
-
if (
|
|
160
|
+
if (!targetUsesCurrentGpt55Alias) return false;
|
|
126
161
|
const label = normalizeText(value);
|
|
127
162
|
if (wantsPro) {
|
|
128
163
|
// ChatGPT UI as of 2026-05: the picker shows just "Pro" (no longer "Pro Extended").
|
|
@@ -200,7 +235,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
200
235
|
if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
|
|
201
236
|
if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
|
|
202
237
|
}
|
|
203
|
-
if (wantsPro &&
|
|
238
|
+
if (wantsPro && labelHasLegacyProVersion(normalizedLabel)) return false;
|
|
239
|
+
if (wantsPro && !labelHasProWord(normalizedLabel)) return false;
|
|
204
240
|
if (wantsInstant && !normalizedLabel.includes('instant')) return false;
|
|
205
241
|
if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
|
|
206
242
|
// Also reject if button has variants we DON'T want
|
|
@@ -218,6 +254,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
218
254
|
if (!signal) {
|
|
219
255
|
return COMPOSER_SIGNAL_ALLOW_BLANK;
|
|
220
256
|
}
|
|
257
|
+
if (wantsPro && labelHasLegacyProVersion(signal)) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
221
260
|
if (COMPOSER_SIGNAL_EXCLUDES.some((token) => token && signal.includes(token))) {
|
|
222
261
|
return false;
|
|
223
262
|
}
|
|
@@ -356,15 +395,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
356
395
|
const candidateGpt55VisibleAlias = isTargetGpt55VisibleAlias(normalizedText);
|
|
357
396
|
const candidateHasThinking =
|
|
358
397
|
normalizedText.includes('thinking') || normalizedTestId.includes('thinking');
|
|
398
|
+
const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
|
|
359
399
|
const candidateHasPro =
|
|
360
400
|
candidateGpt55VisibleAlias ||
|
|
361
|
-
normalizedText
|
|
362
|
-
normalizedText.startsWith('pro ') ||
|
|
363
|
-
normalizedText.includes(' pro ') ||
|
|
364
|
-
normalizedText.endsWith(' pro') ||
|
|
401
|
+
labelHasProWord(normalizedText) ||
|
|
365
402
|
normalizedText.includes('proresearch') ||
|
|
366
403
|
normalizedTestId.includes('pro');
|
|
367
404
|
if (wantsPro && candidateHasThinking) return 0;
|
|
405
|
+
if (wantsPro && candidateHasLegacyProVersion) return 0;
|
|
368
406
|
if (wantsPro && !candidateHasPro) return 0;
|
|
369
407
|
if (wantsThinking && candidateHasPro) return 0;
|
|
370
408
|
if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
|
|
@@ -407,10 +445,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
407
445
|
}
|
|
408
446
|
// If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
|
|
409
447
|
if (wantsPro) {
|
|
410
|
-
if (!normalizedText
|
|
448
|
+
if (!labelHasProWord(normalizedText)) {
|
|
411
449
|
score -= 80;
|
|
412
450
|
}
|
|
413
|
-
} else if (normalizedText
|
|
451
|
+
} else if (labelHasProWord(normalizedText)) {
|
|
414
452
|
score -= 40;
|
|
415
453
|
}
|
|
416
454
|
// Similarly for Thinking variant
|
|
@@ -388,9 +388,8 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
388
388
|
if (!text) return false;
|
|
389
389
|
const normalized = text.toLowerCase().trim();
|
|
390
390
|
return (
|
|
391
|
-
['log in', 'login', 'sign in', 'signin', '
|
|
392
|
-
|
|
393
|
-
) ||
|
|
391
|
+
['log in', 'login', 'sign in', 'signin', 'sign up for free'].includes(normalized) ||
|
|
392
|
+
normalized.startsWith('continue with') ||
|
|
394
393
|
normalized.includes('get responses tailored to you') ||
|
|
395
394
|
normalized.includes('log in to get answers')
|
|
396
395
|
);
|
|
@@ -490,3 +489,6 @@ function normalizeLoginProbe(raw) {
|
|
|
490
489
|
onAuthPage: Boolean(value.onAuthPage),
|
|
491
490
|
};
|
|
492
491
|
}
|
|
492
|
+
export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
|
|
493
|
+
return buildLoginProbeExpression(timeoutMs);
|
|
494
|
+
}
|
|
@@ -290,17 +290,35 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
290
290
|
document.querySelector('form') ||
|
|
291
291
|
document.body ||
|
|
292
292
|
document;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
293
|
+
// Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
|
|
294
|
+
// ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
|
|
295
|
+
// only the node's own textContent/aria/title misses the match.
|
|
296
|
+
const collectLabelHaystack = (node) => {
|
|
297
|
+
if (!node) return '';
|
|
298
|
+
const pieces = [];
|
|
299
|
+
const pushAttrs = (el) => {
|
|
300
|
+
if (!el || typeof el.getAttribute !== 'function') return;
|
|
301
|
+
for (const attr of ['aria-label', 'title', 'data-testid', 'data-tooltip', 'data-tooltip-content']) {
|
|
302
|
+
const v = el.getAttribute(attr);
|
|
303
|
+
if (v) pieces.push(v);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const pushText = (el) => {
|
|
307
|
+
if (!el) return;
|
|
308
|
+
const text = (el.innerText ?? el.textContent ?? '').trim();
|
|
309
|
+
if (text) pieces.push(text);
|
|
310
|
+
};
|
|
311
|
+
pushAttrs(node);
|
|
312
|
+
pushText(node);
|
|
313
|
+
const parent = node.parentElement;
|
|
314
|
+
pushAttrs(parent);
|
|
315
|
+
pushText(parent);
|
|
316
|
+
const grandparent = parent?.parentElement;
|
|
317
|
+
pushAttrs(grandparent);
|
|
318
|
+
pushText(grandparent);
|
|
319
|
+
return pieces.join(' ').toLowerCase();
|
|
320
|
+
};
|
|
321
|
+
const match = (node, name) => collectLabelHaystack(node).includes(name);
|
|
304
322
|
|
|
305
323
|
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
306
324
|
const attachmentSelectors = [
|
|
@@ -312,13 +330,32 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
312
330
|
'button[aria-label*="Remove file"]',
|
|
313
331
|
'[aria-label*="remove file"]',
|
|
314
332
|
'button[aria-label*="remove file"]',
|
|
333
|
+
'[aria-label*="Remove attachment"]',
|
|
334
|
+
'button[aria-label*="Remove attachment"]',
|
|
335
|
+
'[aria-label*="remove attachment"]',
|
|
336
|
+
'button[aria-label*="remove attachment"]',
|
|
315
337
|
];
|
|
316
|
-
const attachmentRoots = Array.from(new Set([composer
|
|
338
|
+
const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
|
|
339
|
+
const collectChipNodes = () => {
|
|
340
|
+
const seen = new Set();
|
|
341
|
+
const collected = [];
|
|
342
|
+
for (const root of attachmentRoots) {
|
|
343
|
+
for (const node of Array.from(root.querySelectorAll(attachmentSelectors.join(',')))) {
|
|
344
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
345
|
+
// Skip elements clearly inside the editable input (composer textarea may contain
|
|
346
|
+
// filename text in the user's prompt — avoid mistaking that for a chip).
|
|
347
|
+
if (node.closest('textarea,[contenteditable="true"]')) continue;
|
|
348
|
+
if (seen.has(node)) continue;
|
|
349
|
+
seen.add(node);
|
|
350
|
+
collected.push(node);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return collected;
|
|
354
|
+
};
|
|
355
|
+
const chipNodes = collectChipNodes();
|
|
317
356
|
|
|
318
357
|
const chipsReady = names.every((name) =>
|
|
319
|
-
|
|
320
|
-
Array.from(root.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
|
|
321
|
-
),
|
|
358
|
+
chipNodes.some((node) => match(node, name)),
|
|
322
359
|
);
|
|
323
360
|
const inputsReady = names.every((name) =>
|
|
324
361
|
attachmentRoots.some((root) =>
|
|
@@ -329,14 +366,28 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
329
366
|
),
|
|
330
367
|
),
|
|
331
368
|
);
|
|
369
|
+
// Count-based fallback: if we cannot match names individually (ChatGPT may strip
|
|
370
|
+
// the filename out of attribute-readable text into a deeply nested span), but we
|
|
371
|
+
// do see at least as many distinct chip-shaped nodes as attachments we uploaded,
|
|
372
|
+
// and a sibling "Remove" affordance exists per chip, trust the upload.
|
|
373
|
+
const removeAffordanceCount = chipNodes.filter((node) => {
|
|
374
|
+
const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
|
|
375
|
+
if (aria.includes('remove')) return true;
|
|
376
|
+
const removeSibling = node.querySelector?.(
|
|
377
|
+
'[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
|
|
378
|
+
);
|
|
379
|
+
return Boolean(removeSibling);
|
|
380
|
+
}).length;
|
|
381
|
+
const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
|
|
332
382
|
|
|
333
|
-
return chipsReady || inputsReady;
|
|
383
|
+
return chipsReady || inputsReady || countReady;
|
|
334
384
|
})()`;
|
|
335
385
|
}
|
|
336
386
|
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
337
387
|
return buildAttachmentReadyExpression(attachmentNames);
|
|
338
388
|
}
|
|
339
389
|
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
390
|
+
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
340
391
|
const script = `(() => {
|
|
341
392
|
${buildClickDispatcher()}
|
|
342
393
|
const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
@@ -369,9 +420,11 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
369
420
|
dispatchClickSequence(button);
|
|
370
421
|
return 'clicked';
|
|
371
422
|
})()`;
|
|
372
|
-
|
|
423
|
+
// Give attachment-bearing submissions more headroom. ChatGPT's chip render can
|
|
424
|
+
// settle slowly for multi-file uploads, but plain text sends should keep the
|
|
425
|
+
// shorter historical deadline.
|
|
426
|
+
const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
|
|
373
427
|
while (Date.now() < deadline) {
|
|
374
|
-
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
375
428
|
if (needAttachment) {
|
|
376
429
|
const ready = await Runtime.evaluate({
|
|
377
430
|
expression: buildAttachmentReadyExpression(attachmentNames),
|
|
@@ -400,6 +453,9 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
400
453
|
}
|
|
401
454
|
return false;
|
|
402
455
|
}
|
|
456
|
+
function sendButtonTimeoutMs(attachmentNames) {
|
|
457
|
+
return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
|
|
458
|
+
}
|
|
403
459
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
|
|
404
460
|
const deadline = Date.now() + timeoutMs;
|
|
405
461
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
@@ -542,5 +598,6 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
542
598
|
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
543
599
|
export const __test__ = {
|
|
544
600
|
attemptSendButton,
|
|
601
|
+
sendButtonTimeoutMs,
|
|
545
602
|
verifyPromptCommitted,
|
|
546
603
|
};
|
|
@@ -207,21 +207,36 @@ function buildThinkingTimeExpression(level) {
|
|
|
207
207
|
|
|
208
208
|
const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
|
|
209
209
|
const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
|
|
210
|
+
const findEffortRow = (node) => {
|
|
211
|
+
let current = node instanceof HTMLElement ? node.parentElement : null;
|
|
212
|
+
while (current && current !== document.body) {
|
|
213
|
+
if (current.getAttribute?.('data-model-picker-thinking-effort-row') === 'true') {
|
|
214
|
+
return current;
|
|
215
|
+
}
|
|
216
|
+
current = current.parentElement;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
};
|
|
220
|
+
const rowIsSelected = (row) => {
|
|
221
|
+
if (!(row instanceof HTMLElement)) return false;
|
|
222
|
+
const modelItem = row.querySelector('[data-model-picker-thinking-effort-menu-item="true"], [role="menuitemradio"]');
|
|
223
|
+
if (optionIsSelected(modelItem)) return true;
|
|
224
|
+
return Boolean(
|
|
225
|
+
row.querySelector(
|
|
226
|
+
'[aria-checked="true"], [aria-selected="true"], [aria-current="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
};
|
|
210
230
|
const pickTrailingForCurrentModel = () => {
|
|
211
231
|
const trailings = findTrailingButtons();
|
|
212
232
|
if (trailings.length === 0) return null;
|
|
213
233
|
if (trailings.length === 1) return trailings[0];
|
|
214
234
|
// Prefer the trailing button whose model row is currently selected.
|
|
215
235
|
for (const t of trailings) {
|
|
216
|
-
const row = t
|
|
217
|
-
if (
|
|
218
|
-
}
|
|
219
|
-
// Fallback: first one with non-zero box.
|
|
220
|
-
for (const t of trailings) {
|
|
221
|
-
const r = t.getBoundingClientRect?.();
|
|
222
|
-
if (r && r.width > 0 && r.height > 0) return t;
|
|
236
|
+
const row = findEffortRow(t);
|
|
237
|
+
if (rowIsSelected(row)) return t;
|
|
223
238
|
}
|
|
224
|
-
return
|
|
239
|
+
return null;
|
|
225
240
|
};
|
|
226
241
|
|
|
227
242
|
const modelBtn = findModelButton();
|
|
@@ -26,6 +26,7 @@ import { resolveAttachRunningConnection } from "./attachRunning.js";
|
|
|
26
26
|
import { connectToExistingChatGptTab } from "./liveTabs.js";
|
|
27
27
|
import { captureBrowserDiagnostics } from "./domDebug.js";
|
|
28
28
|
import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
|
|
29
|
+
import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
|
|
29
30
|
import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
|
|
30
31
|
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
|
|
31
32
|
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
|
|
@@ -290,6 +291,17 @@ async function closeRemoteConnectionAfterRun(options) {
|
|
|
290
291
|
function shouldCloseOwnedRunTargetAfterRun(options) {
|
|
291
292
|
return options.runStatus === "complete" && options.ownsTarget && !options.keepBrowser;
|
|
292
293
|
}
|
|
294
|
+
function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
|
|
295
|
+
return {
|
|
296
|
+
requestedModel: desiredModel ?? null,
|
|
297
|
+
resolvedLabel: null,
|
|
298
|
+
strategy,
|
|
299
|
+
status: "skipped",
|
|
300
|
+
verified: false,
|
|
301
|
+
source: "config",
|
|
302
|
+
capturedAt: new Date().toISOString(),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
293
305
|
export async function runBrowserMode(options) {
|
|
294
306
|
const promptText = options.prompt?.trim();
|
|
295
307
|
if (!promptText) {
|
|
@@ -383,14 +395,19 @@ export async function runBrowserMode(options) {
|
|
|
383
395
|
const manualLogin = Boolean(config.manualLogin);
|
|
384
396
|
const manualProfileDir = config.manualLoginProfileDir
|
|
385
397
|
? path.resolve(config.manualLoginProfileDir)
|
|
386
|
-
:
|
|
398
|
+
: defaultManualLoginProfileDir();
|
|
387
399
|
const userDataDir = manualLogin
|
|
388
400
|
? manualProfileDir
|
|
389
401
|
: await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-"));
|
|
402
|
+
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
390
403
|
if (manualLogin) {
|
|
391
404
|
// Learned: manual login reuses a persistent profile so cookies/SSO survive.
|
|
392
405
|
await mkdir(userDataDir, { recursive: true });
|
|
393
406
|
logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
|
|
407
|
+
await assertManualLoginProfileReadyForRun({
|
|
408
|
+
userDataDir,
|
|
409
|
+
keepBrowser: effectiveKeepBrowser,
|
|
410
|
+
});
|
|
394
411
|
}
|
|
395
412
|
else {
|
|
396
413
|
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
@@ -403,7 +420,6 @@ export async function runBrowserMode(options) {
|
|
|
403
420
|
sessionId: options.sessionId,
|
|
404
421
|
});
|
|
405
422
|
}
|
|
406
|
-
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
407
423
|
let acquiredChrome;
|
|
408
424
|
try {
|
|
409
425
|
acquiredChrome = manualLogin
|
|
@@ -451,6 +467,7 @@ export async function runBrowserMode(options) {
|
|
|
451
467
|
let answerMarkdown = "";
|
|
452
468
|
let answerHtml = "";
|
|
453
469
|
let runStatus = "attempted";
|
|
470
|
+
let modelSelectionEvidence;
|
|
454
471
|
let connectionClosedUnexpectedly = false;
|
|
455
472
|
let stopThinkingMonitor = null;
|
|
456
473
|
let removeDialogHandler = null;
|
|
@@ -587,6 +604,8 @@ export async function runBrowserMode(options) {
|
|
|
587
604
|
appliedCookies,
|
|
588
605
|
manualLogin,
|
|
589
606
|
timeoutMs: config.timeoutMs,
|
|
607
|
+
profileDir: userDataDir,
|
|
608
|
+
keepBrowser: effectiveKeepBrowser,
|
|
590
609
|
}));
|
|
591
610
|
if (config.url !== baseUrl) {
|
|
592
611
|
await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
|
|
@@ -680,7 +699,7 @@ export async function runBrowserMode(options) {
|
|
|
680
699
|
await captureRuntimeSnapshot();
|
|
681
700
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
682
701
|
if (config.desiredModel && modelStrategy !== "ignore") {
|
|
683
|
-
await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
702
|
+
modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
684
703
|
retries: 2,
|
|
685
704
|
delayMs: 300,
|
|
686
705
|
onRetry: (attempt, error) => {
|
|
@@ -699,6 +718,7 @@ export async function runBrowserMode(options) {
|
|
|
699
718
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
700
719
|
}
|
|
701
720
|
else if (modelStrategy === "ignore") {
|
|
721
|
+
modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
|
|
702
722
|
logger("Model picker: skipped (strategy=ignore)");
|
|
703
723
|
}
|
|
704
724
|
const deepResearch = config.researchMode === "deep";
|
|
@@ -881,6 +901,7 @@ export async function runBrowserMode(options) {
|
|
|
881
901
|
answerHtml: researchResult.html,
|
|
882
902
|
artifacts: savedArtifacts,
|
|
883
903
|
archive,
|
|
904
|
+
modelSelection: modelSelectionEvidence,
|
|
884
905
|
tookMs: durationMs,
|
|
885
906
|
answerTokens: tokens,
|
|
886
907
|
answerChars: researchResult.text.length,
|
|
@@ -1266,6 +1287,7 @@ export async function runBrowserMode(options) {
|
|
|
1266
1287
|
generatedImages: imageArtifacts.generatedImages,
|
|
1267
1288
|
savedImages: imageArtifacts.savedImages,
|
|
1268
1289
|
archive,
|
|
1290
|
+
modelSelection: modelSelectionEvidence,
|
|
1269
1291
|
tookMs: durationMs,
|
|
1270
1292
|
answerTokens,
|
|
1271
1293
|
answerChars,
|
|
@@ -1491,12 +1513,13 @@ async function findEphemeralPort() {
|
|
|
1491
1513
|
});
|
|
1492
1514
|
});
|
|
1493
1515
|
}
|
|
1494
|
-
async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
|
|
1516
|
+
async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, profileDir, keepBrowser, }) {
|
|
1495
1517
|
if (!manualLogin) {
|
|
1496
1518
|
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
1497
1519
|
return;
|
|
1498
1520
|
}
|
|
1499
|
-
const
|
|
1521
|
+
const waitMs = resolveManualLoginWaitMs(timeoutMs, Boolean(keepBrowser));
|
|
1522
|
+
const deadline = Date.now() + waitMs;
|
|
1500
1523
|
let lastNotice = 0;
|
|
1501
1524
|
while (Date.now() < deadline) {
|
|
1502
1525
|
try {
|
|
@@ -1518,7 +1541,10 @@ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, time
|
|
|
1518
1541
|
await delay(1000);
|
|
1519
1542
|
}
|
|
1520
1543
|
}
|
|
1521
|
-
|
|
1544
|
+
const setupCommand = formatManualLoginSetupCommand(profileDir ?? defaultManualLoginProfileDir());
|
|
1545
|
+
throw new Error("Manual login mode timed out waiting for ChatGPT session. " +
|
|
1546
|
+
`Browser mode is using Oracle's private Chrome profile at ${profileDir ?? "(default profile)"}, not your normal Chrome profile. ` +
|
|
1547
|
+
`Run first-time setup, sign in there, then retry: ${setupCommand}`);
|
|
1522
1548
|
}
|
|
1523
1549
|
async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) {
|
|
1524
1550
|
// Learned: long streaming responses can still be rendering after initial capture.
|
|
@@ -1712,6 +1738,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1712
1738
|
let answerHtml = "";
|
|
1713
1739
|
let connectionClosedUnexpectedly = false;
|
|
1714
1740
|
let runStatus = "attempted";
|
|
1741
|
+
let modelSelectionEvidence;
|
|
1715
1742
|
let stopThinkingMonitor = null;
|
|
1716
1743
|
let removeDialogHandler = null;
|
|
1717
1744
|
let connection = null;
|
|
@@ -1801,7 +1828,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1801
1828
|
}
|
|
1802
1829
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
1803
1830
|
if (config.desiredModel && modelStrategy !== "ignore") {
|
|
1804
|
-
await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
1831
|
+
modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
1805
1832
|
retries: 2,
|
|
1806
1833
|
delayMs: 300,
|
|
1807
1834
|
onRetry: (attempt, error) => {
|
|
@@ -1814,6 +1841,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1814
1841
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1815
1842
|
}
|
|
1816
1843
|
else if (modelStrategy === "ignore") {
|
|
1844
|
+
modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
|
|
1817
1845
|
logger("Model picker: skipped (strategy=ignore)");
|
|
1818
1846
|
}
|
|
1819
1847
|
const deepResearch = config.researchMode === "deep";
|
|
@@ -1948,6 +1976,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1948
1976
|
answerHtml: researchResult.html,
|
|
1949
1977
|
artifacts: savedArtifacts,
|
|
1950
1978
|
archive,
|
|
1979
|
+
modelSelection: modelSelectionEvidence,
|
|
1951
1980
|
tookMs: durationMs,
|
|
1952
1981
|
answerTokens: tokens,
|
|
1953
1982
|
answerChars: researchResult.text.length,
|
|
@@ -2311,6 +2340,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
2311
2340
|
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
2312
2341
|
artifacts: savedArtifacts,
|
|
2313
2342
|
archive,
|
|
2343
|
+
modelSelection: modelSelectionEvidence,
|
|
2314
2344
|
controllerPid: process.pid,
|
|
2315
2345
|
};
|
|
2316
2346
|
}
|
|
@@ -2372,10 +2402,14 @@ export { estimateTokenCount } from "./utils.js";
|
|
|
2372
2402
|
export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
|
|
2373
2403
|
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
2374
2404
|
export const __test__ = {
|
|
2405
|
+
assertManualLoginProfileReadyForRun,
|
|
2375
2406
|
closeRemoteConnectionAfterRun,
|
|
2376
2407
|
detachKeptChromeProcess,
|
|
2408
|
+
formatManualLoginSetupCommand,
|
|
2409
|
+
isManualLoginProfileInitialized,
|
|
2377
2410
|
isImageOnlyUiChromeText,
|
|
2378
2411
|
listIgnoredRemoteChromeFlags,
|
|
2412
|
+
resolveManualLoginWaitMs,
|
|
2379
2413
|
shouldCloseOwnedRunTargetAfterRun,
|
|
2380
2414
|
};
|
|
2381
2415
|
export { syncCookies } from "./cookies.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { BrowserAutomationError } from "../oracle/errors.js";
|
|
5
|
+
export function resolveManualLoginWaitMs(timeoutMs, keepBrowser) {
|
|
6
|
+
const configured = Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
|
|
7
|
+
if (keepBrowser) {
|
|
8
|
+
return configured;
|
|
9
|
+
}
|
|
10
|
+
return Math.min(configured, 30_000);
|
|
11
|
+
}
|
|
12
|
+
export async function assertManualLoginProfileReadyForRun({ userDataDir, keepBrowser, }) {
|
|
13
|
+
if (keepBrowser) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (await isManualLoginProfileInitialized(userDataDir)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const setupCommand = formatManualLoginSetupCommand(userDataDir);
|
|
20
|
+
throw new BrowserAutomationError("ChatGPT browser manual-login profile is not initialized. " +
|
|
21
|
+
`Browser mode is using Oracle's private Chrome profile at ${userDataDir}, separate from your normal Chrome profile. ` +
|
|
22
|
+
`Run first-time setup, sign in there, then retry: ${setupCommand}. ` +
|
|
23
|
+
"If you want to reuse an already signed-in Chrome instead, use --browser-attach-running.", {
|
|
24
|
+
stage: "browser-login-setup",
|
|
25
|
+
details: {
|
|
26
|
+
profileDir: userDataDir,
|
|
27
|
+
setupCommand,
|
|
28
|
+
sessionStatus: "needs_login",
|
|
29
|
+
},
|
|
30
|
+
reuseProfileHint: setupCommand,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function isManualLoginProfileInitialized(profileDir) {
|
|
34
|
+
const entries = await readdir(profileDir, { withFileTypes: true }).catch(() => []);
|
|
35
|
+
return entries.some((entry) => {
|
|
36
|
+
if (!entry.name)
|
|
37
|
+
return false;
|
|
38
|
+
if (entry.name === "Default" || entry.name === "Local State")
|
|
39
|
+
return true;
|
|
40
|
+
if (entry.name.startsWith("Profile "))
|
|
41
|
+
return true;
|
|
42
|
+
return false;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function formatManualLoginSetupCommand(profileDir) {
|
|
46
|
+
return [
|
|
47
|
+
"oracle --engine browser --browser-manual-login --browser-keep-browser",
|
|
48
|
+
`--browser-manual-login-profile-dir ${JSON.stringify(profileDir)}`,
|
|
49
|
+
'-p "HI"',
|
|
50
|
+
].join(" ");
|
|
51
|
+
}
|
|
52
|
+
export function defaultManualLoginProfileDir() {
|
|
53
|
+
return path.join(os.homedir(), ".oracle", "browser-profile");
|
|
54
|
+
}
|