@steipete/oracle 0.7.6 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/bin/oracle-cli.js +4 -0
- package/dist/src/browser/actions/assistantResponse.js +437 -84
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1300 -132
- package/dist/src/browser/actions/modelSelection.js +9 -4
- package/dist/src/browser/actions/navigation.js +160 -5
- package/dist/src/browser/actions/promptComposer.js +54 -11
- package/dist/src/browser/actions/remoteFileTransfer.js +5 -156
- package/dist/src/browser/actions/thinkingTime.js +5 -0
- package/dist/src/browser/chromeLifecycle.js +9 -1
- package/dist/src/browser/config.js +11 -3
- package/dist/src/browser/constants.js +4 -1
- package/dist/src/browser/cookies.js +55 -21
- package/dist/src/browser/index.js +342 -69
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/profileState.js +16 -0
- package/dist/src/browser/reattach.js +27 -179
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +12 -5
- package/dist/src/cli/browserDefaults.js +12 -0
- package/dist/src/cli/sessionDisplay.js +7 -0
- package/dist/src/gemini-web/executor.js +107 -46
- package/dist/src/oracle/oscProgress.js +7 -0
- package/dist/src/oracle/run.js +23 -32
- package/dist/src/remote/server.js +30 -15
- package/package.json +8 -17
|
@@ -5,15 +5,16 @@ 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, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
8
|
+
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
9
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
10
10
|
import { ensureThinkingTime } from './actions/thinkingTime.js';
|
|
11
11
|
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
12
12
|
import { formatElapsed } from '../oracle/format.js';
|
|
13
|
-
import { CHATGPT_URL } from './constants.js';
|
|
13
|
+
import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
|
|
14
14
|
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
|
|
16
|
+
import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
17
|
+
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
17
18
|
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
|
|
18
19
|
export async function runBrowserMode(options) {
|
|
19
20
|
const promptText = options.prompt?.trim();
|
|
@@ -87,13 +88,14 @@ export async function runBrowserMode(options) {
|
|
|
87
88
|
? manualProfileDir
|
|
88
89
|
: await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
|
|
89
90
|
if (manualLogin) {
|
|
91
|
+
// Learned: manual login reuses a persistent profile so cookies/SSO survive.
|
|
90
92
|
await mkdir(userDataDir, { recursive: true });
|
|
91
93
|
logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
|
|
92
94
|
}
|
|
93
95
|
else {
|
|
94
96
|
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
95
97
|
}
|
|
96
|
-
const effectiveKeepBrowser = config.keepBrowser
|
|
98
|
+
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
97
99
|
const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
|
|
98
100
|
const chrome = reusedChrome ??
|
|
99
101
|
(await launchChrome({
|
|
@@ -113,6 +115,7 @@ export async function runBrowserMode(options) {
|
|
|
113
115
|
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
|
|
114
116
|
isInFlight: () => runStatus !== 'complete',
|
|
115
117
|
emitRuntimeHint,
|
|
118
|
+
preserveUserDataDir: manualLogin,
|
|
116
119
|
});
|
|
117
120
|
}
|
|
118
121
|
catch {
|
|
@@ -158,14 +161,19 @@ export async function runBrowserMode(options) {
|
|
|
158
161
|
if (!manualLogin) {
|
|
159
162
|
await Network.clearBrowserCookies();
|
|
160
163
|
}
|
|
161
|
-
const
|
|
164
|
+
const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
|
|
165
|
+
const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
|
|
162
166
|
if (cookieSyncEnabled) {
|
|
167
|
+
if (manualLoginCookieSync) {
|
|
168
|
+
logger('Manual login mode: seeding persistent profile with cookies from your Chrome profile.');
|
|
169
|
+
}
|
|
163
170
|
if (!config.inlineCookies) {
|
|
164
171
|
logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
|
|
165
172
|
}
|
|
166
173
|
else {
|
|
167
174
|
logger('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
|
|
168
175
|
}
|
|
176
|
+
// Learned: always sync cookies before the first navigation so /backend-api/me succeeds.
|
|
169
177
|
const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
|
|
170
178
|
allowErrors: config.allowCookieErrors ?? false,
|
|
171
179
|
filterNames: config.cookieNames ?? undefined,
|
|
@@ -190,13 +198,15 @@ export async function runBrowserMode(options) {
|
|
|
190
198
|
: 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
|
|
191
199
|
}
|
|
192
200
|
if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
|
|
201
|
+
// Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
|
|
202
|
+
// Fail early so the user knows to sign in.
|
|
193
203
|
throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
|
|
194
|
-
'Make sure ChatGPT is signed in in the selected profile or
|
|
204
|
+
'Make sure ChatGPT is signed in in the selected profile, or use --browser-manual-login / inline cookies.', {
|
|
195
205
|
stage: 'execute-browser',
|
|
196
206
|
details: {
|
|
197
207
|
profile: config.chromeProfile ?? 'Default',
|
|
198
208
|
cookiePath: config.chromeCookiePath ?? null,
|
|
199
|
-
hint: '
|
|
209
|
+
hint: 'If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.',
|
|
200
210
|
},
|
|
201
211
|
});
|
|
202
212
|
}
|
|
@@ -205,6 +215,7 @@ export async function runBrowserMode(options) {
|
|
|
205
215
|
// then hop to the requested URL if it differs.
|
|
206
216
|
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
|
|
207
217
|
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
218
|
+
// Learned: login checks must happen on the base domain before jumping into project URLs.
|
|
208
219
|
await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
|
|
209
220
|
if (config.url !== baseUrl) {
|
|
210
221
|
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
|
|
@@ -235,6 +246,9 @@ export async function runBrowserMode(options) {
|
|
|
235
246
|
catch {
|
|
236
247
|
// ignore
|
|
237
248
|
}
|
|
249
|
+
if (lastUrl) {
|
|
250
|
+
logger(`[browser] url = ${lastUrl}`);
|
|
251
|
+
}
|
|
238
252
|
if (chrome?.port) {
|
|
239
253
|
const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
|
|
240
254
|
if (lastUrl) {
|
|
@@ -246,9 +260,45 @@ export async function runBrowserMode(options) {
|
|
|
246
260
|
await emitRuntimeHint();
|
|
247
261
|
}
|
|
248
262
|
};
|
|
263
|
+
let conversationHintInFlight = null;
|
|
264
|
+
const updateConversationHint = async (label, timeoutMs = 10_000) => {
|
|
265
|
+
if (!chrome?.port) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
const start = Date.now();
|
|
269
|
+
while (Date.now() - start < timeoutMs) {
|
|
270
|
+
try {
|
|
271
|
+
const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
272
|
+
if (typeof result?.value === 'string' && result.value.includes('/c/')) {
|
|
273
|
+
lastUrl = result.value;
|
|
274
|
+
logger(`[browser] conversation url (${label}) = ${lastUrl}`);
|
|
275
|
+
await emitRuntimeHint();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// ignore; keep polling until timeout
|
|
281
|
+
}
|
|
282
|
+
await delay(250);
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
};
|
|
286
|
+
const scheduleConversationHint = (label, timeoutMs) => {
|
|
287
|
+
if (conversationHintInFlight) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// Learned: the /c/ URL can update after the answer; emit hints in the background.
|
|
291
|
+
// Run in the background so prompt submission/streaming isn't blocked by slow URL updates.
|
|
292
|
+
conversationHintInFlight = updateConversationHint(label, timeoutMs)
|
|
293
|
+
.catch(() => false)
|
|
294
|
+
.finally(() => {
|
|
295
|
+
conversationHintInFlight = null;
|
|
296
|
+
});
|
|
297
|
+
};
|
|
249
298
|
await captureRuntimeSnapshot();
|
|
250
|
-
|
|
251
|
-
|
|
299
|
+
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
300
|
+
if (config.desiredModel && modelStrategy !== 'ignore') {
|
|
301
|
+
await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
252
302
|
retries: 2,
|
|
253
303
|
delayMs: 300,
|
|
254
304
|
onRetry: (attempt, error) => {
|
|
@@ -266,6 +316,9 @@ export async function runBrowserMode(options) {
|
|
|
266
316
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
267
317
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
268
318
|
}
|
|
319
|
+
else if (modelStrategy === 'ignore') {
|
|
320
|
+
logger('Model picker: skipped (strategy=ignore)');
|
|
321
|
+
}
|
|
269
322
|
// Handle thinking time selection if specified
|
|
270
323
|
const thinkingTime = config.thinkingTime;
|
|
271
324
|
if (thinkingTime) {
|
|
@@ -280,14 +333,22 @@ export async function runBrowserMode(options) {
|
|
|
280
333
|
}));
|
|
281
334
|
}
|
|
282
335
|
const submitOnce = async (prompt, submissionAttachments) => {
|
|
336
|
+
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
337
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
283
338
|
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
339
|
+
let inputOnlyAttachments = false;
|
|
284
340
|
if (submissionAttachments.length > 0) {
|
|
285
341
|
if (!DOM) {
|
|
286
342
|
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
287
343
|
}
|
|
288
|
-
|
|
344
|
+
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
345
|
+
for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
|
|
346
|
+
const attachment = submissionAttachments[attachmentIndex];
|
|
289
347
|
logger(`Uploading attachment: ${attachment.displayPath}`);
|
|
290
|
-
await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
|
|
348
|
+
const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger, { expectedCount: attachmentIndex + 1 });
|
|
349
|
+
if (!uiConfirmed) {
|
|
350
|
+
inputOnlyAttachments = true;
|
|
351
|
+
}
|
|
291
352
|
await delay(500);
|
|
292
353
|
}
|
|
293
354
|
// Scale timeout based on number of files: base 30s + 15s per additional file
|
|
@@ -297,30 +358,103 @@ export async function runBrowserMode(options) {
|
|
|
297
358
|
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
298
359
|
logger('All attachments uploaded');
|
|
299
360
|
}
|
|
300
|
-
await
|
|
361
|
+
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
362
|
+
// Learned: return baselineTurns so assistant polling can ignore earlier content.
|
|
363
|
+
const committedTurns = await submitPrompt({
|
|
364
|
+
runtime: Runtime,
|
|
365
|
+
input: Input,
|
|
366
|
+
attachmentNames,
|
|
367
|
+
baselineTurns: baselineTurns ?? undefined,
|
|
368
|
+
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
369
|
+
}, prompt, logger);
|
|
370
|
+
if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
|
|
371
|
+
if (baselineTurns === null || committedTurns > baselineTurns) {
|
|
372
|
+
baselineTurns = Math.max(0, committedTurns - 1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
301
375
|
if (attachmentNames.length > 0) {
|
|
302
|
-
|
|
303
|
-
|
|
376
|
+
if (inputOnlyAttachments) {
|
|
377
|
+
logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
|
|
381
|
+
if (verified) {
|
|
382
|
+
logger('Verified attachments present on sent user message');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
304
385
|
}
|
|
386
|
+
// Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
|
|
387
|
+
scheduleConversationHint('post-submit', config.timeoutMs ?? 120_000);
|
|
388
|
+
return { baselineTurns, baselineAssistantText };
|
|
305
389
|
};
|
|
390
|
+
let baselineTurns = null;
|
|
391
|
+
let baselineAssistantText = null;
|
|
306
392
|
try {
|
|
307
|
-
await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
393
|
+
const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
394
|
+
baselineTurns = submission.baselineTurns;
|
|
395
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
308
396
|
}
|
|
309
397
|
catch (error) {
|
|
310
398
|
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
311
399
|
error.details?.code === 'prompt-too-large';
|
|
312
400
|
if (fallbackSubmission && isPromptTooLarge) {
|
|
401
|
+
// Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
|
|
313
402
|
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
314
403
|
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
315
404
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
316
|
-
await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
|
|
405
|
+
const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
|
|
406
|
+
baselineTurns = submission.baselineTurns;
|
|
407
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
317
408
|
}
|
|
318
409
|
else {
|
|
319
410
|
throw error;
|
|
320
411
|
}
|
|
321
412
|
}
|
|
322
413
|
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
323
|
-
|
|
414
|
+
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
415
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
416
|
+
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
417
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
418
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
419
|
+
: '';
|
|
420
|
+
const deadline = Date.now() + timeoutMs;
|
|
421
|
+
while (Date.now() < deadline) {
|
|
422
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
423
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
424
|
+
if (text) {
|
|
425
|
+
const normalized = normalizeForComparison(text);
|
|
426
|
+
const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
427
|
+
if (!isBaseline) {
|
|
428
|
+
return {
|
|
429
|
+
text,
|
|
430
|
+
html: snapshot?.html ?? undefined,
|
|
431
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
await delay(350);
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
};
|
|
439
|
+
let answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
|
|
440
|
+
// Ensure we store the final conversation URL even if the UI updated late.
|
|
441
|
+
await updateConversationHint('post-response', 15_000);
|
|
442
|
+
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
443
|
+
if (baselineNormalized) {
|
|
444
|
+
const normalizedAnswer = normalizeForComparison(answer.text ?? '');
|
|
445
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
446
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
447
|
+
: '';
|
|
448
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
449
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
450
|
+
if (isBaseline) {
|
|
451
|
+
logger('Detected stale assistant response; waiting for new response...');
|
|
452
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
453
|
+
if (refreshed) {
|
|
454
|
+
answer = refreshed;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
324
458
|
answerText = answer.text;
|
|
325
459
|
answerHtml = answer.html ?? '';
|
|
326
460
|
const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
|
|
@@ -339,39 +473,41 @@ export async function runBrowserMode(options) {
|
|
|
339
473
|
},
|
|
340
474
|
})).catch(() => null);
|
|
341
475
|
answerMarkdown = copiedMarkdown ?? answerText;
|
|
342
|
-
|
|
343
|
-
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
476
|
+
const promptEchoMatcher = buildPromptEchoMatcher(promptText);
|
|
344
477
|
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
345
|
-
const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
478
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
346
479
|
const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
finalText
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
480
|
+
if (finalText && finalText !== promptText.trim()) {
|
|
481
|
+
const trimmedMarkdown = answerMarkdown.trim();
|
|
482
|
+
const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
|
|
483
|
+
const lengthDelta = finalText.length - trimmedMarkdown.length;
|
|
484
|
+
const missingCopy = !copiedMarkdown && lengthDelta >= 0;
|
|
485
|
+
const likelyTruncatedCopy = copiedMarkdown &&
|
|
486
|
+
trimmedMarkdown.length > 0 &&
|
|
487
|
+
lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
|
|
488
|
+
if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
|
|
489
|
+
logger('Refreshed assistant response via final DOM snapshot');
|
|
490
|
+
answerText = finalText;
|
|
491
|
+
answerMarkdown = finalText;
|
|
492
|
+
}
|
|
355
493
|
}
|
|
356
|
-
// Detect prompt echo using normalized comparison (whitespace-insensitive)
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
494
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
495
|
+
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
496
|
+
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
497
|
+
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
498
|
+
});
|
|
499
|
+
answerText = alignedEcho.answerText;
|
|
500
|
+
answerMarkdown = alignedEcho.answerMarkdown;
|
|
501
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
363
502
|
if (isPromptEcho) {
|
|
364
503
|
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
365
|
-
const deadline = Date.now() +
|
|
504
|
+
const deadline = Date.now() + 15_000;
|
|
366
505
|
let bestText = null;
|
|
367
506
|
let stableCount = 0;
|
|
368
507
|
while (Date.now() < deadline) {
|
|
369
|
-
const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
508
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
370
509
|
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
371
|
-
const
|
|
372
|
-
const isStillEcho = !text ||
|
|
373
|
-
normalizedText === normalizedPrompt ||
|
|
374
|
-
(promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
|
|
510
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
375
511
|
if (!isStillEcho) {
|
|
376
512
|
if (!bestText || text.length > bestText.length) {
|
|
377
513
|
bestText = text;
|
|
@@ -392,6 +528,36 @@ export async function runBrowserMode(options) {
|
|
|
392
528
|
answerMarkdown = bestText;
|
|
393
529
|
}
|
|
394
530
|
}
|
|
531
|
+
const minAnswerChars = 16;
|
|
532
|
+
if (answerText.trim().length > 0 && answerText.trim().length < minAnswerChars) {
|
|
533
|
+
const deadline = Date.now() + 12_000;
|
|
534
|
+
let bestText = answerText.trim();
|
|
535
|
+
let stableCycles = 0;
|
|
536
|
+
while (Date.now() < deadline) {
|
|
537
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
538
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
539
|
+
if (text && text.length > bestText.length) {
|
|
540
|
+
bestText = text;
|
|
541
|
+
stableCycles = 0;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
stableCycles += 1;
|
|
545
|
+
}
|
|
546
|
+
if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
await delay(400);
|
|
550
|
+
}
|
|
551
|
+
if (bestText.length > answerText.trim().length) {
|
|
552
|
+
logger('Refreshed short assistant response from latest DOM snapshot');
|
|
553
|
+
answerText = bestText;
|
|
554
|
+
answerMarkdown = bestText;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (connectionClosedUnexpectedly) {
|
|
558
|
+
// Bail out on mid-run disconnects so the session stays reattachable.
|
|
559
|
+
throw new Error('Chrome disconnected before completion');
|
|
560
|
+
}
|
|
395
561
|
stopThinkingMonitor?.();
|
|
396
562
|
runStatus = 'complete';
|
|
397
563
|
const durationMs = Date.now() - startedAt;
|
|
@@ -462,7 +628,19 @@ export async function runBrowserMode(options) {
|
|
|
462
628
|
// ignore kill failures
|
|
463
629
|
}
|
|
464
630
|
}
|
|
465
|
-
|
|
631
|
+
if (manualLogin) {
|
|
632
|
+
const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
|
|
633
|
+
connectionClosedUnexpectedly,
|
|
634
|
+
host: chromeHost,
|
|
635
|
+
});
|
|
636
|
+
if (shouldCleanup) {
|
|
637
|
+
// Preserve the persistent manual-login profile, but clear stale reattach hints.
|
|
638
|
+
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
643
|
+
}
|
|
466
644
|
if (!connectionClosedUnexpectedly) {
|
|
467
645
|
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
468
646
|
logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
|
|
@@ -653,8 +831,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
653
831
|
catch {
|
|
654
832
|
// ignore
|
|
655
833
|
}
|
|
656
|
-
|
|
657
|
-
|
|
834
|
+
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
835
|
+
if (config.desiredModel && modelStrategy !== 'ignore') {
|
|
836
|
+
await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
658
837
|
retries: 2,
|
|
659
838
|
delayMs: 300,
|
|
660
839
|
onRetry: (attempt, error) => {
|
|
@@ -666,6 +845,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
666
845
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
667
846
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
668
847
|
}
|
|
848
|
+
else if (modelStrategy === 'ignore') {
|
|
849
|
+
logger('Model picker: skipped (strategy=ignore)');
|
|
850
|
+
}
|
|
669
851
|
// Handle thinking time selection if specified
|
|
670
852
|
const thinkingTime = config.thinkingTime;
|
|
671
853
|
if (thinkingTime) {
|
|
@@ -680,11 +862,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
680
862
|
});
|
|
681
863
|
}
|
|
682
864
|
const submitOnce = async (prompt, submissionAttachments) => {
|
|
865
|
+
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
866
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
683
867
|
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
684
868
|
if (submissionAttachments.length > 0) {
|
|
685
869
|
if (!DOM) {
|
|
686
870
|
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
687
871
|
}
|
|
872
|
+
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
688
873
|
// Use remote file transfer for remote Chrome (reads local files and injects via CDP)
|
|
689
874
|
for (const attachment of submissionAttachments) {
|
|
690
875
|
logger(`Uploading attachment: ${attachment.displayPath}`);
|
|
@@ -698,10 +883,27 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
698
883
|
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
699
884
|
logger('All attachments uploaded');
|
|
700
885
|
}
|
|
701
|
-
await
|
|
886
|
+
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
887
|
+
const committedTurns = await submitPrompt({
|
|
888
|
+
runtime: Runtime,
|
|
889
|
+
input: Input,
|
|
890
|
+
attachmentNames,
|
|
891
|
+
baselineTurns: baselineTurns ?? undefined,
|
|
892
|
+
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
893
|
+
}, prompt, logger);
|
|
894
|
+
if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
|
|
895
|
+
if (baselineTurns === null || committedTurns > baselineTurns) {
|
|
896
|
+
baselineTurns = Math.max(0, committedTurns - 1);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return { baselineTurns, baselineAssistantText };
|
|
702
900
|
};
|
|
901
|
+
let baselineTurns = null;
|
|
902
|
+
let baselineAssistantText = null;
|
|
703
903
|
try {
|
|
704
|
-
await submitOnce(promptText, attachments);
|
|
904
|
+
const submission = await submitOnce(promptText, attachments);
|
|
905
|
+
baselineTurns = submission.baselineTurns;
|
|
906
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
705
907
|
}
|
|
706
908
|
catch (error) {
|
|
707
909
|
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
@@ -710,14 +912,57 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
710
912
|
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
711
913
|
await clearPromptComposer(Runtime, logger);
|
|
712
914
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
713
|
-
await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
|
|
915
|
+
const submission = await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
|
|
916
|
+
baselineTurns = submission.baselineTurns;
|
|
917
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
714
918
|
}
|
|
715
919
|
else {
|
|
716
920
|
throw error;
|
|
717
921
|
}
|
|
718
922
|
}
|
|
719
923
|
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
720
|
-
|
|
924
|
+
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
925
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
926
|
+
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
927
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
928
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
929
|
+
: '';
|
|
930
|
+
const deadline = Date.now() + timeoutMs;
|
|
931
|
+
while (Date.now() < deadline) {
|
|
932
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
933
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
934
|
+
if (text) {
|
|
935
|
+
const normalized = normalizeForComparison(text);
|
|
936
|
+
const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
937
|
+
if (!isBaseline) {
|
|
938
|
+
return {
|
|
939
|
+
text,
|
|
940
|
+
html: snapshot?.html ?? undefined,
|
|
941
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
await delay(350);
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
};
|
|
949
|
+
let answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
|
|
950
|
+
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
951
|
+
if (baselineNormalized) {
|
|
952
|
+
const normalizedAnswer = normalizeForComparison(answer.text ?? '');
|
|
953
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
954
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
955
|
+
: '';
|
|
956
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
957
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
958
|
+
if (isBaseline) {
|
|
959
|
+
logger('Detected stale assistant response; waiting for new response...');
|
|
960
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
961
|
+
if (refreshed) {
|
|
962
|
+
answer = refreshed;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
721
966
|
answerText = answer.text;
|
|
722
967
|
answerHtml = answer.html ?? '';
|
|
723
968
|
const copiedMarkdown = await withRetries(async () => {
|
|
@@ -736,10 +981,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
736
981
|
},
|
|
737
982
|
}).catch(() => null);
|
|
738
983
|
answerMarkdown = copiedMarkdown ?? answerText;
|
|
739
|
-
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
740
|
-
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
741
984
|
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
742
|
-
const finalSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
985
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
743
986
|
const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
|
|
744
987
|
if (finalText &&
|
|
745
988
|
finalText !== answerMarkdown.trim() &&
|
|
@@ -749,25 +992,24 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
749
992
|
answerText = finalText;
|
|
750
993
|
answerMarkdown = finalText;
|
|
751
994
|
}
|
|
752
|
-
// Detect prompt echo using normalized comparison (whitespace-insensitive)
|
|
753
|
-
const
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
995
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
996
|
+
const promptEchoMatcher = buildPromptEchoMatcher(promptText);
|
|
997
|
+
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
998
|
+
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
999
|
+
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
1000
|
+
});
|
|
1001
|
+
answerText = alignedEcho.answerText;
|
|
1002
|
+
answerMarkdown = alignedEcho.answerMarkdown;
|
|
1003
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
759
1004
|
if (isPromptEcho) {
|
|
760
1005
|
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
761
|
-
const deadline = Date.now() +
|
|
1006
|
+
const deadline = Date.now() + 15_000;
|
|
762
1007
|
let bestText = null;
|
|
763
1008
|
let stableCount = 0;
|
|
764
1009
|
while (Date.now() < deadline) {
|
|
765
|
-
const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
1010
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
766
1011
|
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
767
|
-
const
|
|
768
|
-
const isStillEcho = !text ||
|
|
769
|
-
normalizedText === normalizedPrompt ||
|
|
770
|
-
(promptPrefix.length > 0 && normalizedText.startsWith(promptPrefix));
|
|
1012
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
771
1013
|
if (!isStillEcho) {
|
|
772
1014
|
if (!bestText || text.length > bestText.length) {
|
|
773
1015
|
bestText = text;
|
|
@@ -867,9 +1109,9 @@ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
|
|
|
867
1109
|
const statusLabel = message ? ` — ${message}` : '';
|
|
868
1110
|
return `${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
|
|
869
1111
|
}
|
|
870
|
-
async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger) {
|
|
1112
|
+
async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex) {
|
|
871
1113
|
try {
|
|
872
|
-
return await waitForAssistantResponse(Runtime, timeoutMs, logger);
|
|
1114
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
873
1115
|
}
|
|
874
1116
|
catch (error) {
|
|
875
1117
|
if (!shouldReloadAfterAssistantError(error)) {
|
|
@@ -882,14 +1124,17 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg
|
|
|
882
1124
|
logger('Assistant response stalled; reloading conversation and retrying once');
|
|
883
1125
|
await Page.navigate({ url: conversationUrl });
|
|
884
1126
|
await delay(1000);
|
|
885
|
-
return await waitForAssistantResponse(Runtime, timeoutMs, logger);
|
|
1127
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
886
1128
|
}
|
|
887
1129
|
}
|
|
888
1130
|
function shouldReloadAfterAssistantError(error) {
|
|
889
1131
|
if (!(error instanceof Error))
|
|
890
1132
|
return false;
|
|
891
1133
|
const message = error.message.toLowerCase();
|
|
892
|
-
return message.includes('assistant-response') ||
|
|
1134
|
+
return (message.includes('assistant-response') ||
|
|
1135
|
+
message.includes('watchdog') ||
|
|
1136
|
+
message.includes('timeout') ||
|
|
1137
|
+
message.includes('capture assistant response'));
|
|
893
1138
|
}
|
|
894
1139
|
async function readConversationUrl(Runtime) {
|
|
895
1140
|
try {
|
|
@@ -900,6 +1145,34 @@ async function readConversationUrl(Runtime) {
|
|
|
900
1145
|
return null;
|
|
901
1146
|
}
|
|
902
1147
|
}
|
|
1148
|
+
async function readConversationTurnCount(Runtime, logger) {
|
|
1149
|
+
const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
1150
|
+
const attempts = 4;
|
|
1151
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1152
|
+
try {
|
|
1153
|
+
const { result } = await Runtime.evaluate({
|
|
1154
|
+
expression: `document.querySelectorAll(${selectorLiteral}).length`,
|
|
1155
|
+
returnByValue: true,
|
|
1156
|
+
});
|
|
1157
|
+
const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
|
|
1158
|
+
if (!Number.isFinite(raw)) {
|
|
1159
|
+
throw new Error('Turn count not numeric');
|
|
1160
|
+
}
|
|
1161
|
+
return Math.max(0, Math.floor(raw));
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
if (attempt < attempts - 1) {
|
|
1165
|
+
await delay(150);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (logger?.verbose) {
|
|
1169
|
+
logger(`Failed to read conversation turn count: ${error instanceof Error ? error.message : String(error)}`);
|
|
1170
|
+
}
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
903
1176
|
function isConversationUrl(url) {
|
|
904
1177
|
return /\/c\/[a-z0-9-]+/i.test(url);
|
|
905
1178
|
}
|