@indykish/oracle 0.9.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/LICENSE +21 -0
- package/README.md +215 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1252 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1067 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1910 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +485 -0
- package/dist/src/browser/actions/navigation.js +445 -0
- package/dist/src/browser/actions/promptComposer.js +485 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +344 -0
- package/dist/src/browser/config.js +103 -0
- package/dist/src/browser/constants.js +71 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1741 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +43 -0
- package/dist/src/browser/profileState.js +280 -0
- package/dist/src/browser/prompt.js +152 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/reattach.js +186 -0
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +119 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/browserConfig.js +278 -0
- package/dist/src/cli/browserDefaults.js +81 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +11 -0
- package/dist/src/cli/dryRun.js +105 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +17 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +306 -0
- package/dist/src/cli/options.js +281 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +78 -0
- package/dist/src/cli/sessionCommand.js +111 -0
- package/dist/src/cli/sessionDisplay.js +567 -0
- package/dist/src/cli/sessionRunner.js +602 -0
- package/dist/src/cli/sessionTable.js +92 -0
- package/dist/src/cli/tagline.js +258 -0
- package/dist/src/cli/tui/index.js +486 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/client.js +328 -0
- package/dist/src/gemini-web/executor.js +285 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +290 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +105 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +37 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +101 -0
- package/dist/src/oracle/client.js +197 -0
- package/dist/src/oracle/config.js +227 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +378 -0
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +195 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +13 -0
- package/dist/src/oracle/request.js +50 -0
- package/dist/src/oracle/run.js +596 -0
- package/dist/src/oracle/runUtils.js +31 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +533 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +637 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +115 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,1741 @@
|
|
|
1
|
+
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { resolveBrowserConfig } from './config.js';
|
|
6
|
+
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, closeRemoteChromeTarget, connectWithNewTab, closeTab, } from './chromeLifecycle.js';
|
|
7
|
+
import { syncCookies } from './cookies.js';
|
|
8
|
+
import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
|
+
import { INPUT_SELECTORS } from './constants.js';
|
|
10
|
+
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
11
|
+
import { ensureThinkingTime } from './actions/thinkingTime.js';
|
|
12
|
+
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
13
|
+
import { formatElapsed } from '../oracle/format.js';
|
|
14
|
+
import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
|
|
15
|
+
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
16
|
+
import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
|
|
17
|
+
import { cleanupStaleProfileState, acquireProfileRunLock, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
18
|
+
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
19
|
+
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
|
|
20
|
+
export async function runBrowserMode(options) {
|
|
21
|
+
const promptText = options.prompt?.trim();
|
|
22
|
+
if (!promptText) {
|
|
23
|
+
throw new Error('Prompt text is required when using browser mode.');
|
|
24
|
+
}
|
|
25
|
+
const attachments = options.attachments ?? [];
|
|
26
|
+
const fallbackSubmission = options.fallbackSubmission;
|
|
27
|
+
let config = resolveBrowserConfig(options.config);
|
|
28
|
+
const logger = options.log ?? ((_message) => { });
|
|
29
|
+
if (logger.verbose === undefined) {
|
|
30
|
+
logger.verbose = Boolean(config.debug);
|
|
31
|
+
}
|
|
32
|
+
if (logger.sessionLog === undefined && options.log?.sessionLog) {
|
|
33
|
+
logger.sessionLog = options.log.sessionLog;
|
|
34
|
+
}
|
|
35
|
+
const runtimeHintCb = options.runtimeHintCb;
|
|
36
|
+
let lastTargetId;
|
|
37
|
+
let lastUrl;
|
|
38
|
+
const emitRuntimeHint = async () => {
|
|
39
|
+
if (!runtimeHintCb || !chrome?.port) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
|
|
43
|
+
const hint = {
|
|
44
|
+
chromePid: chrome.pid,
|
|
45
|
+
chromePort: chrome.port,
|
|
46
|
+
chromeHost,
|
|
47
|
+
chromeTargetId: lastTargetId,
|
|
48
|
+
tabUrl: lastUrl,
|
|
49
|
+
conversationId,
|
|
50
|
+
userDataDir,
|
|
51
|
+
controllerPid: process.pid,
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
await runtimeHintCb(hint);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
logger(`Failed to persist runtime hint: ${message}`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') {
|
|
62
|
+
logger(`[browser-mode] config: ${JSON.stringify({
|
|
63
|
+
...config,
|
|
64
|
+
promptLength: promptText.length,
|
|
65
|
+
})}`);
|
|
66
|
+
}
|
|
67
|
+
if (!config.remoteChrome && !config.manualLogin) {
|
|
68
|
+
const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
|
|
69
|
+
const availablePort = await pickAvailableDebugPort(preferredPort, logger);
|
|
70
|
+
if (availablePort !== preferredPort) {
|
|
71
|
+
logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
|
|
72
|
+
}
|
|
73
|
+
config = { ...config, debugPort: availablePort };
|
|
74
|
+
}
|
|
75
|
+
// Remote Chrome mode - connect to existing browser
|
|
76
|
+
if (config.remoteChrome) {
|
|
77
|
+
// Warn about ignored local-only options
|
|
78
|
+
if (config.headless || config.hideWindow || config.keepBrowser || config.chromePath) {
|
|
79
|
+
logger('Note: --remote-chrome ignores local Chrome flags ' +
|
|
80
|
+
'(--browser-headless, --browser-hide-window, --browser-keep-browser, --browser-chrome-path).');
|
|
81
|
+
}
|
|
82
|
+
return runRemoteBrowserMode(promptText, attachments, config, logger, options);
|
|
83
|
+
}
|
|
84
|
+
const manualLogin = Boolean(config.manualLogin);
|
|
85
|
+
const manualProfileDir = config.manualLoginProfileDir
|
|
86
|
+
? path.resolve(config.manualLoginProfileDir)
|
|
87
|
+
: path.join(os.homedir(), '.oracle', 'browser-profile');
|
|
88
|
+
const userDataDir = manualLogin
|
|
89
|
+
? manualProfileDir
|
|
90
|
+
: await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
|
|
91
|
+
if (manualLogin) {
|
|
92
|
+
// Learned: manual login reuses a persistent profile so cookies/SSO survive.
|
|
93
|
+
await mkdir(userDataDir, { recursive: true });
|
|
94
|
+
logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
98
|
+
}
|
|
99
|
+
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
100
|
+
const reusedChrome = manualLogin
|
|
101
|
+
? await maybeReuseRunningChrome(userDataDir, logger, { waitForPortMs: config.reuseChromeWaitMs })
|
|
102
|
+
: null;
|
|
103
|
+
const chrome = reusedChrome ??
|
|
104
|
+
(await launchChrome({
|
|
105
|
+
...config,
|
|
106
|
+
remoteChrome: config.remoteChrome,
|
|
107
|
+
}, userDataDir, logger));
|
|
108
|
+
const chromeHost = chrome.host ?? '127.0.0.1';
|
|
109
|
+
// Persist profile state so future manual-login runs can reuse this Chrome.
|
|
110
|
+
if (manualLogin && chrome.port) {
|
|
111
|
+
await writeDevToolsActivePort(userDataDir, chrome.port);
|
|
112
|
+
if (!reusedChrome && chrome.pid) {
|
|
113
|
+
await writeChromePid(userDataDir, chrome.pid);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let removeTerminationHooks = null;
|
|
117
|
+
try {
|
|
118
|
+
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
|
|
119
|
+
isInFlight: () => runStatus !== 'complete',
|
|
120
|
+
emitRuntimeHint,
|
|
121
|
+
preserveUserDataDir: manualLogin,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// ignore failure; cleanup still happens below
|
|
126
|
+
}
|
|
127
|
+
let client = null;
|
|
128
|
+
let isolatedTargetId = null;
|
|
129
|
+
const startedAt = Date.now();
|
|
130
|
+
let answerText = '';
|
|
131
|
+
let answerMarkdown = '';
|
|
132
|
+
let answerHtml = '';
|
|
133
|
+
let runStatus = 'attempted';
|
|
134
|
+
let connectionClosedUnexpectedly = false;
|
|
135
|
+
let stopThinkingMonitor = null;
|
|
136
|
+
let removeDialogHandler = null;
|
|
137
|
+
let appliedCookies = 0;
|
|
138
|
+
try {
|
|
139
|
+
try {
|
|
140
|
+
const strictTabIsolation = Boolean(manualLogin && reusedChrome);
|
|
141
|
+
const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
|
|
142
|
+
fallbackToDefault: !strictTabIsolation,
|
|
143
|
+
retries: strictTabIsolation ? 3 : 0,
|
|
144
|
+
retryDelayMs: 500,
|
|
145
|
+
});
|
|
146
|
+
client = connection.client;
|
|
147
|
+
isolatedTargetId = connection.targetId ?? null;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
|
|
151
|
+
if (hint) {
|
|
152
|
+
logger(hint);
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const disconnectPromise = new Promise((_, reject) => {
|
|
157
|
+
client?.on('disconnect', () => {
|
|
158
|
+
connectionClosedUnexpectedly = true;
|
|
159
|
+
logger('Chrome window closed; attempting to abort run.');
|
|
160
|
+
reject(new Error('Chrome window closed before oracle finished. Please keep it open until completion.'));
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
|
|
164
|
+
const { Network, Page, Runtime, Input, DOM } = client;
|
|
165
|
+
if (!config.headless && config.hideWindow) {
|
|
166
|
+
await hideChromeWindow(chrome, logger);
|
|
167
|
+
}
|
|
168
|
+
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
|
|
169
|
+
if (DOM && typeof DOM.enable === 'function') {
|
|
170
|
+
domainEnablers.push(DOM.enable());
|
|
171
|
+
}
|
|
172
|
+
await Promise.all(domainEnablers);
|
|
173
|
+
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
|
|
174
|
+
if (!manualLogin) {
|
|
175
|
+
await Network.clearBrowserCookies();
|
|
176
|
+
}
|
|
177
|
+
const manualLoginCookieSync = manualLogin && Boolean(config.manualLoginCookieSync);
|
|
178
|
+
const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
|
|
179
|
+
if (cookieSyncEnabled) {
|
|
180
|
+
if (manualLoginCookieSync) {
|
|
181
|
+
logger('Manual login mode: seeding persistent profile with cookies from your Chrome profile.');
|
|
182
|
+
}
|
|
183
|
+
if (!config.inlineCookies) {
|
|
184
|
+
logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
logger('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
|
|
188
|
+
}
|
|
189
|
+
// Learned: always sync cookies before the first navigation so /backend-api/me succeeds.
|
|
190
|
+
const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
|
|
191
|
+
allowErrors: config.allowCookieErrors ?? false,
|
|
192
|
+
filterNames: config.cookieNames ?? undefined,
|
|
193
|
+
inlineCookies: config.inlineCookies ?? undefined,
|
|
194
|
+
cookiePath: config.chromeCookiePath ?? undefined,
|
|
195
|
+
waitMs: config.cookieSyncWaitMs ?? 0,
|
|
196
|
+
});
|
|
197
|
+
appliedCookies = cookieCount;
|
|
198
|
+
if (config.inlineCookies && cookieCount === 0) {
|
|
199
|
+
throw new Error('No inline cookies were applied; aborting before navigation.');
|
|
200
|
+
}
|
|
201
|
+
logger(cookieCount > 0
|
|
202
|
+
? config.inlineCookies
|
|
203
|
+
? `Applied ${cookieCount} inline cookies`
|
|
204
|
+
: `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
|
|
205
|
+
: config.inlineCookies
|
|
206
|
+
? 'No inline cookies applied; continuing without session reuse'
|
|
207
|
+
: 'No Chrome cookies found; continuing without session reuse');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
logger(manualLogin
|
|
211
|
+
? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
|
|
212
|
+
: 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
|
|
213
|
+
}
|
|
214
|
+
if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
|
|
215
|
+
// Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
|
|
216
|
+
// Fail early so the user knows to sign in.
|
|
217
|
+
throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
|
|
218
|
+
'Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, ' +
|
|
219
|
+
'or retry with --browser-cookie-wait 5s if Keychain prompts are slow.', {
|
|
220
|
+
stage: 'execute-browser',
|
|
221
|
+
details: {
|
|
222
|
+
profile: config.chromeProfile ?? 'Default',
|
|
223
|
+
cookiePath: config.chromeCookiePath ?? null,
|
|
224
|
+
hint: 'If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.',
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const baseUrl = CHATGPT_URL;
|
|
229
|
+
// First load the base ChatGPT homepage to satisfy potential interstitials,
|
|
230
|
+
// then hop to the requested URL if it differs.
|
|
231
|
+
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
|
|
232
|
+
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
233
|
+
// Learned: login checks must happen on the base domain before jumping into project URLs.
|
|
234
|
+
await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
|
|
235
|
+
if (config.url !== baseUrl) {
|
|
236
|
+
await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
|
|
237
|
+
url: config.url,
|
|
238
|
+
fallbackUrl: baseUrl,
|
|
239
|
+
timeoutMs: config.inputTimeoutMs,
|
|
240
|
+
headless: config.headless,
|
|
241
|
+
logger,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
246
|
+
}
|
|
247
|
+
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
248
|
+
const captureRuntimeSnapshot = async () => {
|
|
249
|
+
try {
|
|
250
|
+
if (client?.Target?.getTargetInfo) {
|
|
251
|
+
const info = await client.Target.getTargetInfo({});
|
|
252
|
+
lastTargetId = info?.targetInfo?.targetId ?? lastTargetId;
|
|
253
|
+
lastUrl = info?.targetInfo?.url ?? lastUrl;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// ignore
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const { result } = await Runtime.evaluate({
|
|
261
|
+
expression: 'location.href',
|
|
262
|
+
returnByValue: true,
|
|
263
|
+
});
|
|
264
|
+
if (typeof result?.value === 'string') {
|
|
265
|
+
lastUrl = result.value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// ignore
|
|
270
|
+
}
|
|
271
|
+
if (lastUrl) {
|
|
272
|
+
logger(`[browser] url = ${lastUrl}`);
|
|
273
|
+
}
|
|
274
|
+
if (chrome?.port) {
|
|
275
|
+
const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
|
|
276
|
+
if (lastUrl) {
|
|
277
|
+
logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`);
|
|
281
|
+
}
|
|
282
|
+
await emitRuntimeHint();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
let conversationHintInFlight = null;
|
|
286
|
+
const updateConversationHint = async (label, timeoutMs = 10_000) => {
|
|
287
|
+
if (!chrome?.port) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
while (Date.now() - start < timeoutMs) {
|
|
292
|
+
try {
|
|
293
|
+
const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
294
|
+
if (typeof result?.value === 'string' && result.value.includes('/c/')) {
|
|
295
|
+
lastUrl = result.value;
|
|
296
|
+
logger(`[browser] conversation url (${label}) = ${lastUrl}`);
|
|
297
|
+
await emitRuntimeHint();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// ignore; keep polling until timeout
|
|
303
|
+
}
|
|
304
|
+
await delay(250);
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
};
|
|
308
|
+
const scheduleConversationHint = (label, timeoutMs) => {
|
|
309
|
+
if (conversationHintInFlight) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Learned: the /c/ URL can update after the answer; emit hints in the background.
|
|
313
|
+
// Run in the background so prompt submission/streaming isn't blocked by slow URL updates.
|
|
314
|
+
conversationHintInFlight = updateConversationHint(label, timeoutMs)
|
|
315
|
+
.catch(() => false)
|
|
316
|
+
.finally(() => {
|
|
317
|
+
conversationHintInFlight = null;
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
await captureRuntimeSnapshot();
|
|
321
|
+
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
322
|
+
if (config.desiredModel && modelStrategy !== 'ignore') {
|
|
323
|
+
await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
324
|
+
retries: 2,
|
|
325
|
+
delayMs: 300,
|
|
326
|
+
onRetry: (attempt, error) => {
|
|
327
|
+
if (options.verbose) {
|
|
328
|
+
logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
})).catch((error) => {
|
|
332
|
+
const base = error instanceof Error ? error.message : String(error);
|
|
333
|
+
const hint = appliedCookies === 0
|
|
334
|
+
? ' No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies (--browser-inline-cookies[(-file)] or ORACLE_BROWSER_COOKIES_JSON).'
|
|
335
|
+
: '';
|
|
336
|
+
throw new Error(`${base}${hint}`);
|
|
337
|
+
});
|
|
338
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
339
|
+
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
340
|
+
}
|
|
341
|
+
else if (modelStrategy === 'ignore') {
|
|
342
|
+
logger('Model picker: skipped (strategy=ignore)');
|
|
343
|
+
}
|
|
344
|
+
// Handle thinking time selection if specified
|
|
345
|
+
const thinkingTime = config.thinkingTime;
|
|
346
|
+
if (thinkingTime) {
|
|
347
|
+
await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
|
|
348
|
+
retries: 2,
|
|
349
|
+
delayMs: 300,
|
|
350
|
+
onRetry: (attempt, error) => {
|
|
351
|
+
if (options.verbose) {
|
|
352
|
+
logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
|
|
358
|
+
let profileLock = null;
|
|
359
|
+
const acquireProfileLockIfNeeded = async () => {
|
|
360
|
+
if (profileLockTimeoutMs <= 0)
|
|
361
|
+
return;
|
|
362
|
+
profileLock = await acquireProfileRunLock(userDataDir, {
|
|
363
|
+
timeoutMs: profileLockTimeoutMs,
|
|
364
|
+
logger,
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
const releaseProfileLockIfHeld = async () => {
|
|
368
|
+
if (!profileLock)
|
|
369
|
+
return;
|
|
370
|
+
const handle = profileLock;
|
|
371
|
+
profileLock = null;
|
|
372
|
+
await handle.release().catch(() => undefined);
|
|
373
|
+
};
|
|
374
|
+
const submitOnce = async (prompt, submissionAttachments) => {
|
|
375
|
+
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
376
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
377
|
+
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
378
|
+
let attachmentWaitTimedOut = false;
|
|
379
|
+
let inputOnlyAttachments = false;
|
|
380
|
+
if (submissionAttachments.length > 0) {
|
|
381
|
+
if (!DOM) {
|
|
382
|
+
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
383
|
+
}
|
|
384
|
+
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
385
|
+
for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
|
|
386
|
+
const attachment = submissionAttachments[attachmentIndex];
|
|
387
|
+
logger(`Uploading attachment: ${attachment.displayPath}`);
|
|
388
|
+
const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 });
|
|
389
|
+
if (!uiConfirmed) {
|
|
390
|
+
inputOnlyAttachments = true;
|
|
391
|
+
}
|
|
392
|
+
await delay(500);
|
|
393
|
+
}
|
|
394
|
+
// Scale timeout based on number of files: base 45s + 20s per additional file.
|
|
395
|
+
const baseTimeout = config.inputTimeoutMs ?? 30_000;
|
|
396
|
+
const perFileTimeout = 20_000;
|
|
397
|
+
const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
|
|
398
|
+
try {
|
|
399
|
+
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
400
|
+
logger('All attachments uploaded');
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
404
|
+
if (/Attachments did not finish uploading before timeout/i.test(message)) {
|
|
405
|
+
attachmentWaitTimedOut = true;
|
|
406
|
+
logger(`[browser] Attachment upload timed out after ${Math.round(waitBudget / 1000)}s; continuing without confirmation.`);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
414
|
+
// Learned: return baselineTurns so assistant polling can ignore earlier content.
|
|
415
|
+
const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
|
|
416
|
+
const committedTurns = await submitPrompt({
|
|
417
|
+
runtime: Runtime,
|
|
418
|
+
input: Input,
|
|
419
|
+
attachmentNames: sendAttachmentNames,
|
|
420
|
+
baselineTurns: baselineTurns ?? undefined,
|
|
421
|
+
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
422
|
+
}, prompt, logger);
|
|
423
|
+
if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
|
|
424
|
+
if (baselineTurns === null || committedTurns > baselineTurns) {
|
|
425
|
+
baselineTurns = Math.max(0, committedTurns - 1);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (attachmentNames.length > 0) {
|
|
429
|
+
if (attachmentWaitTimedOut) {
|
|
430
|
+
logger('Attachment confirmation timed out; skipping user-turn attachment verification.');
|
|
431
|
+
}
|
|
432
|
+
else if (inputOnlyAttachments) {
|
|
433
|
+
logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
|
|
437
|
+
if (!verified) {
|
|
438
|
+
throw new Error('Sent user message did not expose attachment UI after upload.');
|
|
439
|
+
}
|
|
440
|
+
logger('Verified attachments present on sent user message');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
|
|
444
|
+
scheduleConversationHint('post-submit', config.timeoutMs ?? 120_000);
|
|
445
|
+
return { baselineTurns, baselineAssistantText };
|
|
446
|
+
};
|
|
447
|
+
let baselineTurns = null;
|
|
448
|
+
let baselineAssistantText = null;
|
|
449
|
+
await acquireProfileLockIfNeeded();
|
|
450
|
+
try {
|
|
451
|
+
try {
|
|
452
|
+
const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
453
|
+
baselineTurns = submission.baselineTurns;
|
|
454
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
458
|
+
error.details?.code === 'prompt-too-large';
|
|
459
|
+
if (fallbackSubmission && isPromptTooLarge) {
|
|
460
|
+
// Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
|
|
461
|
+
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
462
|
+
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
463
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
464
|
+
const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
|
|
465
|
+
baselineTurns = submission.baselineTurns;
|
|
466
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await releaseProfileLockIfHeld();
|
|
475
|
+
}
|
|
476
|
+
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
477
|
+
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
478
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
479
|
+
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
480
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
481
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
482
|
+
: '';
|
|
483
|
+
const deadline = Date.now() + timeoutMs;
|
|
484
|
+
while (Date.now() < deadline) {
|
|
485
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
486
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
487
|
+
if (text) {
|
|
488
|
+
const normalized = normalizeForComparison(text);
|
|
489
|
+
const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
490
|
+
if (!isBaseline) {
|
|
491
|
+
return {
|
|
492
|
+
text,
|
|
493
|
+
html: snapshot?.html ?? undefined,
|
|
494
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
await delay(350);
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
};
|
|
502
|
+
let answer;
|
|
503
|
+
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
504
|
+
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
505
|
+
const attemptAssistantRecheck = async () => {
|
|
506
|
+
if (!recheckDelayMs)
|
|
507
|
+
return null;
|
|
508
|
+
logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
|
|
509
|
+
await raceWithDisconnect(delay(recheckDelayMs));
|
|
510
|
+
await updateConversationHint('assistant-recheck', 15_000).catch(() => false);
|
|
511
|
+
await captureRuntimeSnapshot().catch(() => undefined);
|
|
512
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
513
|
+
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
514
|
+
logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
|
|
515
|
+
await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
|
|
516
|
+
await raceWithDisconnect(delay(1000));
|
|
517
|
+
}
|
|
518
|
+
// Validate session before attempting recheck - sessions can expire during the delay
|
|
519
|
+
const sessionValid = await validateChatGPTSession(Runtime, logger);
|
|
520
|
+
if (!sessionValid.valid) {
|
|
521
|
+
logger(`[browser] Session validation failed: ${sessionValid.reason}`);
|
|
522
|
+
// Update session metadata to indicate login is needed
|
|
523
|
+
await emitRuntimeHint();
|
|
524
|
+
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
525
|
+
`Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
|
|
526
|
+
`Please sign in and retry.`, {
|
|
527
|
+
stage: 'assistant-recheck',
|
|
528
|
+
details: {
|
|
529
|
+
conversationUrl: conversationUrl || lastUrl || null,
|
|
530
|
+
sessionStatus: 'needs_login',
|
|
531
|
+
validationReason: sessionValid.reason,
|
|
532
|
+
},
|
|
533
|
+
runtime: {
|
|
534
|
+
chromePid: chrome.pid,
|
|
535
|
+
chromePort: chrome.port,
|
|
536
|
+
chromeHost,
|
|
537
|
+
userDataDir,
|
|
538
|
+
chromeTargetId: lastTargetId,
|
|
539
|
+
tabUrl: lastUrl,
|
|
540
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
541
|
+
controllerPid: process.pid,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
546
|
+
const rechecked = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined));
|
|
547
|
+
logger('Recovered assistant response after delayed recheck');
|
|
548
|
+
return rechecked;
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
555
|
+
const rechecked = await attemptAssistantRecheck().catch(() => null);
|
|
556
|
+
if (rechecked) {
|
|
557
|
+
answer = rechecked;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
await updateConversationHint('assistant-timeout', 15_000).catch(() => false);
|
|
561
|
+
await captureRuntimeSnapshot().catch(() => undefined);
|
|
562
|
+
const runtime = {
|
|
563
|
+
chromePid: chrome.pid,
|
|
564
|
+
chromePort: chrome.port,
|
|
565
|
+
chromeHost,
|
|
566
|
+
userDataDir,
|
|
567
|
+
chromeTargetId: lastTargetId,
|
|
568
|
+
tabUrl: lastUrl,
|
|
569
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
570
|
+
controllerPid: process.pid,
|
|
571
|
+
};
|
|
572
|
+
throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Ensure we store the final conversation URL even if the UI updated late.
|
|
580
|
+
await updateConversationHint('post-response', 15_000);
|
|
581
|
+
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
582
|
+
if (baselineNormalized) {
|
|
583
|
+
const normalizedAnswer = normalizeForComparison(answer.text ?? '');
|
|
584
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
585
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
586
|
+
: '';
|
|
587
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
588
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
589
|
+
if (isBaseline) {
|
|
590
|
+
logger('Detected stale assistant response; waiting for new response...');
|
|
591
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
592
|
+
if (refreshed) {
|
|
593
|
+
answer = refreshed;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
answerText = answer.text;
|
|
598
|
+
answerHtml = answer.html ?? '';
|
|
599
|
+
const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
|
|
600
|
+
const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
|
|
601
|
+
if (!attempt) {
|
|
602
|
+
throw new Error('copy-missing');
|
|
603
|
+
}
|
|
604
|
+
return attempt;
|
|
605
|
+
}, {
|
|
606
|
+
retries: 2,
|
|
607
|
+
delayMs: 350,
|
|
608
|
+
onRetry: (attempt, error) => {
|
|
609
|
+
if (options.verbose) {
|
|
610
|
+
logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
})).catch(() => null);
|
|
614
|
+
answerMarkdown = copiedMarkdown ?? answerText;
|
|
615
|
+
const promptEchoMatcher = buildPromptEchoMatcher(promptText);
|
|
616
|
+
({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
|
|
617
|
+
runtime: Runtime,
|
|
618
|
+
baselineTurns,
|
|
619
|
+
answerText,
|
|
620
|
+
answerMarkdown,
|
|
621
|
+
logger,
|
|
622
|
+
allowMarkdownUpdate: !copiedMarkdown,
|
|
623
|
+
}));
|
|
624
|
+
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
625
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
626
|
+
const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
|
|
627
|
+
if (finalText && finalText !== promptText.trim()) {
|
|
628
|
+
const trimmedMarkdown = answerMarkdown.trim();
|
|
629
|
+
const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
|
|
630
|
+
const lengthDelta = finalText.length - trimmedMarkdown.length;
|
|
631
|
+
const missingCopy = !copiedMarkdown && lengthDelta >= 0;
|
|
632
|
+
const likelyTruncatedCopy = copiedMarkdown &&
|
|
633
|
+
trimmedMarkdown.length > 0 &&
|
|
634
|
+
lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
|
|
635
|
+
if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
|
|
636
|
+
logger('Refreshed assistant response via final DOM snapshot');
|
|
637
|
+
answerText = finalText;
|
|
638
|
+
answerMarkdown = finalText;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
642
|
+
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
643
|
+
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
644
|
+
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
645
|
+
});
|
|
646
|
+
answerText = alignedEcho.answerText;
|
|
647
|
+
answerMarkdown = alignedEcho.answerMarkdown;
|
|
648
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
649
|
+
if (isPromptEcho) {
|
|
650
|
+
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
651
|
+
const deadline = Date.now() + 15_000;
|
|
652
|
+
let bestText = null;
|
|
653
|
+
let stableCount = 0;
|
|
654
|
+
while (Date.now() < deadline) {
|
|
655
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
656
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
657
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
658
|
+
if (!isStillEcho) {
|
|
659
|
+
if (!bestText || text.length > bestText.length) {
|
|
660
|
+
bestText = text;
|
|
661
|
+
stableCount = 0;
|
|
662
|
+
}
|
|
663
|
+
else if (text === bestText) {
|
|
664
|
+
stableCount += 1;
|
|
665
|
+
}
|
|
666
|
+
if (stableCount >= 2) {
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
671
|
+
}
|
|
672
|
+
if (bestText) {
|
|
673
|
+
logger('Recovered assistant response after detecting prompt echo');
|
|
674
|
+
answerText = bestText;
|
|
675
|
+
answerMarkdown = bestText;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const minAnswerChars = 16;
|
|
679
|
+
if (answerText.trim().length > 0 && answerText.trim().length < minAnswerChars) {
|
|
680
|
+
const deadline = Date.now() + 12_000;
|
|
681
|
+
let bestText = answerText.trim();
|
|
682
|
+
let stableCycles = 0;
|
|
683
|
+
while (Date.now() < deadline) {
|
|
684
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
685
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
686
|
+
if (text && text.length > bestText.length) {
|
|
687
|
+
bestText = text;
|
|
688
|
+
stableCycles = 0;
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
stableCycles += 1;
|
|
692
|
+
}
|
|
693
|
+
if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
await delay(400);
|
|
697
|
+
}
|
|
698
|
+
if (bestText.length > answerText.trim().length) {
|
|
699
|
+
logger('Refreshed short assistant response from latest DOM snapshot');
|
|
700
|
+
answerText = bestText;
|
|
701
|
+
answerMarkdown = bestText;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (connectionClosedUnexpectedly) {
|
|
705
|
+
// Bail out on mid-run disconnects so the session stays reattachable.
|
|
706
|
+
throw new Error('Chrome disconnected before completion');
|
|
707
|
+
}
|
|
708
|
+
stopThinkingMonitor?.();
|
|
709
|
+
runStatus = 'complete';
|
|
710
|
+
const durationMs = Date.now() - startedAt;
|
|
711
|
+
const answerChars = answerText.length;
|
|
712
|
+
const answerTokens = estimateTokenCount(answerMarkdown);
|
|
713
|
+
return {
|
|
714
|
+
answerText,
|
|
715
|
+
answerMarkdown,
|
|
716
|
+
answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
|
|
717
|
+
tookMs: durationMs,
|
|
718
|
+
answerTokens,
|
|
719
|
+
answerChars,
|
|
720
|
+
chromePid: chrome.pid,
|
|
721
|
+
chromePort: chrome.port,
|
|
722
|
+
chromeHost,
|
|
723
|
+
userDataDir,
|
|
724
|
+
chromeTargetId: lastTargetId,
|
|
725
|
+
tabUrl: lastUrl,
|
|
726
|
+
controllerPid: process.pid,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
731
|
+
stopThinkingMonitor?.();
|
|
732
|
+
const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
|
|
733
|
+
connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
|
|
734
|
+
if (!socketClosed) {
|
|
735
|
+
logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
|
|
736
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
|
|
737
|
+
logger(normalizedError.stack);
|
|
738
|
+
}
|
|
739
|
+
throw normalizedError;
|
|
740
|
+
}
|
|
741
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
|
|
742
|
+
logger(`Chrome window closed before completion: ${normalizedError.message}`);
|
|
743
|
+
logger(normalizedError.stack);
|
|
744
|
+
}
|
|
745
|
+
await emitRuntimeHint();
|
|
746
|
+
throw new BrowserAutomationError('Chrome window closed before oracle finished. Please keep it open until completion.', {
|
|
747
|
+
stage: 'connection-lost',
|
|
748
|
+
runtime: {
|
|
749
|
+
chromePid: chrome.pid,
|
|
750
|
+
chromePort: chrome.port,
|
|
751
|
+
chromeHost,
|
|
752
|
+
userDataDir,
|
|
753
|
+
chromeTargetId: lastTargetId,
|
|
754
|
+
tabUrl: lastUrl,
|
|
755
|
+
controllerPid: process.pid,
|
|
756
|
+
},
|
|
757
|
+
}, normalizedError);
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
try {
|
|
761
|
+
if (!connectionClosedUnexpectedly) {
|
|
762
|
+
await client?.close();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
// ignore
|
|
767
|
+
}
|
|
768
|
+
// Close the isolated tab once the response has been fully captured to prevent
|
|
769
|
+
// tab accumulation across repeated runs. Keep the tab open on incomplete runs
|
|
770
|
+
// so reattach can recover the response.
|
|
771
|
+
if (runStatus === 'complete' && isolatedTargetId && chrome?.port) {
|
|
772
|
+
await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
|
|
773
|
+
}
|
|
774
|
+
removeDialogHandler?.();
|
|
775
|
+
removeTerminationHooks?.();
|
|
776
|
+
if (!effectiveKeepBrowser) {
|
|
777
|
+
if (!connectionClosedUnexpectedly) {
|
|
778
|
+
try {
|
|
779
|
+
await chrome.kill();
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
// ignore kill failures
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (manualLogin) {
|
|
786
|
+
const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
|
|
787
|
+
connectionClosedUnexpectedly,
|
|
788
|
+
host: chromeHost,
|
|
789
|
+
});
|
|
790
|
+
if (shouldCleanup) {
|
|
791
|
+
// Preserve the persistent manual-login profile, but clear stale reattach hints.
|
|
792
|
+
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'never' }).catch(() => undefined);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
797
|
+
}
|
|
798
|
+
if (!connectionClosedUnexpectedly) {
|
|
799
|
+
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
800
|
+
logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else if (!connectionClosedUnexpectedly) {
|
|
804
|
+
logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const DEFAULT_DEBUG_PORT = 9222;
|
|
809
|
+
async function pickAvailableDebugPort(preferredPort, logger) {
|
|
810
|
+
const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT;
|
|
811
|
+
for (let offset = 0; offset < 10; offset++) {
|
|
812
|
+
const candidate = start + offset;
|
|
813
|
+
if (await isPortAvailable(candidate)) {
|
|
814
|
+
return candidate;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const fallback = await findEphemeralPort();
|
|
818
|
+
logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`);
|
|
819
|
+
return fallback;
|
|
820
|
+
}
|
|
821
|
+
async function isPortAvailable(port) {
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
const server = net.createServer();
|
|
824
|
+
server.once('error', () => resolve(false));
|
|
825
|
+
server.once('listening', () => {
|
|
826
|
+
server.close(() => resolve(true));
|
|
827
|
+
});
|
|
828
|
+
server.listen(port, '127.0.0.1');
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async function findEphemeralPort() {
|
|
832
|
+
return new Promise((resolve, reject) => {
|
|
833
|
+
const server = net.createServer();
|
|
834
|
+
server.once('error', (error) => {
|
|
835
|
+
server.close();
|
|
836
|
+
reject(error);
|
|
837
|
+
});
|
|
838
|
+
server.listen(0, '127.0.0.1', () => {
|
|
839
|
+
const address = server.address();
|
|
840
|
+
if (address && typeof address === 'object') {
|
|
841
|
+
const port = address.port;
|
|
842
|
+
server.close(() => resolve(port));
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
server.close(() => reject(new Error('Failed to acquire ephemeral port')));
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
|
|
851
|
+
if (!manualLogin) {
|
|
852
|
+
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const deadline = Date.now() + Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
|
|
856
|
+
let lastNotice = 0;
|
|
857
|
+
while (Date.now() < deadline) {
|
|
858
|
+
try {
|
|
859
|
+
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
864
|
+
const loginDetected = message?.toLowerCase().includes('login button');
|
|
865
|
+
const sessionMissing = message?.toLowerCase().includes('session not detected');
|
|
866
|
+
if (!loginDetected && !sessionMissing) {
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
const now = Date.now();
|
|
870
|
+
if (now - lastNotice > 5000) {
|
|
871
|
+
logger('Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...');
|
|
872
|
+
lastNotice = now;
|
|
873
|
+
}
|
|
874
|
+
await delay(1000);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
throw new Error('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
|
|
878
|
+
}
|
|
879
|
+
async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) {
|
|
880
|
+
// Learned: long streaming responses can still be rendering after initial capture.
|
|
881
|
+
// Add a brief delay and re-poll to catch any additional content (#71).
|
|
882
|
+
const capturedLength = answerText.trim().length;
|
|
883
|
+
if (capturedLength <= 500) {
|
|
884
|
+
return { answerText, answerMarkdown };
|
|
885
|
+
}
|
|
886
|
+
await delay(1500);
|
|
887
|
+
let bestLength = capturedLength;
|
|
888
|
+
let bestText = answerText;
|
|
889
|
+
for (let i = 0; i < 5; i++) {
|
|
890
|
+
const laterSnapshot = await readAssistantSnapshot(runtime, baselineTurns ?? undefined).catch(() => null);
|
|
891
|
+
const laterText = typeof laterSnapshot?.text === 'string' ? laterSnapshot.text.trim() : '';
|
|
892
|
+
if (laterText.length > bestLength) {
|
|
893
|
+
bestLength = laterText.length;
|
|
894
|
+
bestText = laterText;
|
|
895
|
+
await delay(800); // More content appeared, keep waiting
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
break; // Stable, stop polling
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (bestLength > capturedLength) {
|
|
902
|
+
logger(`Recovered ${bestLength - capturedLength} additional chars via delayed re-read`);
|
|
903
|
+
return {
|
|
904
|
+
answerText: bestText,
|
|
905
|
+
answerMarkdown: allowMarkdownUpdate ? bestText : answerMarkdown,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
return { answerText, answerMarkdown };
|
|
909
|
+
}
|
|
910
|
+
async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
|
|
911
|
+
const deadline = Date.now() + timeoutMs;
|
|
912
|
+
let lastUrl = '';
|
|
913
|
+
while (Date.now() < deadline) {
|
|
914
|
+
const { result } = await runtime.evaluate({
|
|
915
|
+
expression: 'typeof location === "object" && location.href ? location.href : ""',
|
|
916
|
+
returnByValue: true,
|
|
917
|
+
});
|
|
918
|
+
const url = typeof result?.value === 'string' ? result.value : '';
|
|
919
|
+
lastUrl = url;
|
|
920
|
+
if (/^https?:\/\//i.test(url)) {
|
|
921
|
+
return url;
|
|
922
|
+
}
|
|
923
|
+
await delay(250);
|
|
924
|
+
}
|
|
925
|
+
throw new BrowserAutomationError('ChatGPT session not detected; page never left new tab.', {
|
|
926
|
+
stage: 'execute-browser',
|
|
927
|
+
details: { url: lastUrl || '(empty)' },
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
|
|
931
|
+
const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0);
|
|
932
|
+
let port = await readDevToolsPort(userDataDir);
|
|
933
|
+
if (!port && waitForPortMs > 0) {
|
|
934
|
+
const deadline = Date.now() + waitForPortMs;
|
|
935
|
+
logger(`Waiting up to ${formatElapsed(waitForPortMs)} for shared Chrome to appear...`);
|
|
936
|
+
while (!port && Date.now() < deadline) {
|
|
937
|
+
await delay(250);
|
|
938
|
+
port = await readDevToolsPort(userDataDir);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (!port)
|
|
942
|
+
return null;
|
|
943
|
+
const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
|
|
944
|
+
if (!probe.ok) {
|
|
945
|
+
logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
|
|
946
|
+
// Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
|
|
947
|
+
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: 'if_oracle_pid_dead' });
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
const pid = await readChromePid(userDataDir);
|
|
951
|
+
logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
|
|
952
|
+
return {
|
|
953
|
+
port,
|
|
954
|
+
pid: pid ?? undefined,
|
|
955
|
+
kill: async () => { },
|
|
956
|
+
process: undefined,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
|
|
960
|
+
const remoteChromeConfig = config.remoteChrome;
|
|
961
|
+
if (!remoteChromeConfig) {
|
|
962
|
+
throw new Error('Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.');
|
|
963
|
+
}
|
|
964
|
+
const { host, port } = remoteChromeConfig;
|
|
965
|
+
logger(`Connecting to remote Chrome at ${host}:${port}`);
|
|
966
|
+
let client = null;
|
|
967
|
+
let remoteTargetId = null;
|
|
968
|
+
let lastUrl;
|
|
969
|
+
const runtimeHintCb = options.runtimeHintCb;
|
|
970
|
+
const emitRuntimeHint = async () => {
|
|
971
|
+
if (!runtimeHintCb)
|
|
972
|
+
return;
|
|
973
|
+
try {
|
|
974
|
+
await runtimeHintCb({
|
|
975
|
+
chromePort: port,
|
|
976
|
+
chromeHost: host,
|
|
977
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
978
|
+
tabUrl: lastUrl,
|
|
979
|
+
controllerPid: process.pid,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
984
|
+
logger(`Failed to persist runtime hint: ${message}`);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
const startedAt = Date.now();
|
|
988
|
+
let answerText = '';
|
|
989
|
+
let answerMarkdown = '';
|
|
990
|
+
let answerHtml = '';
|
|
991
|
+
let connectionClosedUnexpectedly = false;
|
|
992
|
+
let stopThinkingMonitor = null;
|
|
993
|
+
let removeDialogHandler = null;
|
|
994
|
+
try {
|
|
995
|
+
const connection = await connectToRemoteChrome(host, port, logger, config.url);
|
|
996
|
+
client = connection.client;
|
|
997
|
+
remoteTargetId = connection.targetId ?? null;
|
|
998
|
+
await emitRuntimeHint();
|
|
999
|
+
const markConnectionLost = () => {
|
|
1000
|
+
connectionClosedUnexpectedly = true;
|
|
1001
|
+
};
|
|
1002
|
+
client.on('disconnect', markConnectionLost);
|
|
1003
|
+
const { Network, Page, Runtime, Input, DOM } = client;
|
|
1004
|
+
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
|
|
1005
|
+
if (DOM && typeof DOM.enable === 'function') {
|
|
1006
|
+
domainEnablers.push(DOM.enable());
|
|
1007
|
+
}
|
|
1008
|
+
await Promise.all(domainEnablers);
|
|
1009
|
+
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
|
|
1010
|
+
// Skip cookie sync for remote Chrome - it already has cookies
|
|
1011
|
+
logger('Skipping cookie sync for remote Chrome (using existing session)');
|
|
1012
|
+
await navigateToChatGPT(Page, Runtime, config.url, logger);
|
|
1013
|
+
await ensureNotBlocked(Runtime, config.headless, logger);
|
|
1014
|
+
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
|
|
1015
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1016
|
+
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1017
|
+
try {
|
|
1018
|
+
const { result } = await Runtime.evaluate({
|
|
1019
|
+
expression: 'location.href',
|
|
1020
|
+
returnByValue: true,
|
|
1021
|
+
});
|
|
1022
|
+
if (typeof result?.value === 'string') {
|
|
1023
|
+
lastUrl = result.value;
|
|
1024
|
+
}
|
|
1025
|
+
await emitRuntimeHint();
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
// ignore
|
|
1029
|
+
}
|
|
1030
|
+
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
1031
|
+
if (config.desiredModel && modelStrategy !== 'ignore') {
|
|
1032
|
+
await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
1033
|
+
retries: 2,
|
|
1034
|
+
delayMs: 300,
|
|
1035
|
+
onRetry: (attempt, error) => {
|
|
1036
|
+
if (options.verbose) {
|
|
1037
|
+
logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1042
|
+
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1043
|
+
}
|
|
1044
|
+
else if (modelStrategy === 'ignore') {
|
|
1045
|
+
logger('Model picker: skipped (strategy=ignore)');
|
|
1046
|
+
}
|
|
1047
|
+
// Handle thinking time selection if specified
|
|
1048
|
+
const thinkingTime = config.thinkingTime;
|
|
1049
|
+
if (thinkingTime) {
|
|
1050
|
+
await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
|
|
1051
|
+
retries: 2,
|
|
1052
|
+
delayMs: 300,
|
|
1053
|
+
onRetry: (attempt, error) => {
|
|
1054
|
+
if (options.verbose) {
|
|
1055
|
+
logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const submitOnce = async (prompt, submissionAttachments) => {
|
|
1061
|
+
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
1062
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
1063
|
+
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
1064
|
+
if (submissionAttachments.length > 0) {
|
|
1065
|
+
if (!DOM) {
|
|
1066
|
+
throw new Error('Chrome DOM domain unavailable while uploading attachments.');
|
|
1067
|
+
}
|
|
1068
|
+
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
1069
|
+
// Use remote file transfer for remote Chrome (reads local files and injects via CDP)
|
|
1070
|
+
for (const attachment of submissionAttachments) {
|
|
1071
|
+
logger(`Uploading attachment: ${attachment.displayPath}`);
|
|
1072
|
+
await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
|
|
1073
|
+
await delay(500);
|
|
1074
|
+
}
|
|
1075
|
+
// Scale timeout based on number of files: base 30s + 15s per additional file
|
|
1076
|
+
const baseTimeout = config.inputTimeoutMs ?? 30_000;
|
|
1077
|
+
const perFileTimeout = 15_000;
|
|
1078
|
+
const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
|
|
1079
|
+
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
1080
|
+
logger('All attachments uploaded');
|
|
1081
|
+
}
|
|
1082
|
+
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
1083
|
+
const committedTurns = await submitPrompt({
|
|
1084
|
+
runtime: Runtime,
|
|
1085
|
+
input: Input,
|
|
1086
|
+
attachmentNames,
|
|
1087
|
+
baselineTurns: baselineTurns ?? undefined,
|
|
1088
|
+
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
1089
|
+
}, prompt, logger);
|
|
1090
|
+
if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
|
|
1091
|
+
if (baselineTurns === null || committedTurns > baselineTurns) {
|
|
1092
|
+
baselineTurns = Math.max(0, committedTurns - 1);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return { baselineTurns, baselineAssistantText };
|
|
1096
|
+
};
|
|
1097
|
+
let baselineTurns = null;
|
|
1098
|
+
let baselineAssistantText = null;
|
|
1099
|
+
try {
|
|
1100
|
+
const submission = await submitOnce(promptText, attachments);
|
|
1101
|
+
baselineTurns = submission.baselineTurns;
|
|
1102
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
const isPromptTooLarge = error instanceof BrowserAutomationError &&
|
|
1106
|
+
error.details?.code === 'prompt-too-large';
|
|
1107
|
+
if (options.fallbackSubmission && isPromptTooLarge) {
|
|
1108
|
+
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
1109
|
+
await clearPromptComposer(Runtime, logger);
|
|
1110
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1111
|
+
const submission = await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
|
|
1112
|
+
baselineTurns = submission.baselineTurns;
|
|
1113
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
1120
|
+
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
1121
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
1122
|
+
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
1123
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
1124
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
1125
|
+
: '';
|
|
1126
|
+
const deadline = Date.now() + timeoutMs;
|
|
1127
|
+
while (Date.now() < deadline) {
|
|
1128
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
1129
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
1130
|
+
if (text) {
|
|
1131
|
+
const normalized = normalizeForComparison(text);
|
|
1132
|
+
const isBaseline = normalized === baselineNormalized || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
1133
|
+
if (!isBaseline) {
|
|
1134
|
+
return {
|
|
1135
|
+
text,
|
|
1136
|
+
html: snapshot?.html ?? undefined,
|
|
1137
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
await delay(350);
|
|
1142
|
+
}
|
|
1143
|
+
return null;
|
|
1144
|
+
};
|
|
1145
|
+
let answer;
|
|
1146
|
+
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
1147
|
+
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
1148
|
+
const attemptAssistantRecheck = async () => {
|
|
1149
|
+
if (!recheckDelayMs)
|
|
1150
|
+
return null;
|
|
1151
|
+
logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
|
|
1152
|
+
await delay(recheckDelayMs);
|
|
1153
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
1154
|
+
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
1155
|
+
lastUrl = conversationUrl;
|
|
1156
|
+
logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
|
|
1157
|
+
await Page.navigate({ url: conversationUrl });
|
|
1158
|
+
await delay(1000);
|
|
1159
|
+
}
|
|
1160
|
+
// Validate session before attempting recheck - sessions can expire during the delay
|
|
1161
|
+
const sessionValid = await validateChatGPTSession(Runtime, logger);
|
|
1162
|
+
if (!sessionValid.valid) {
|
|
1163
|
+
logger(`[browser] Session validation failed: ${sessionValid.reason}`);
|
|
1164
|
+
// Update session metadata to indicate login is needed
|
|
1165
|
+
await emitRuntimeHint();
|
|
1166
|
+
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
1167
|
+
`Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
|
|
1168
|
+
`Please sign in and retry.`, {
|
|
1169
|
+
stage: 'assistant-recheck',
|
|
1170
|
+
details: {
|
|
1171
|
+
conversationUrl: conversationUrl || lastUrl || null,
|
|
1172
|
+
sessionStatus: 'needs_login',
|
|
1173
|
+
validationReason: sessionValid.reason,
|
|
1174
|
+
},
|
|
1175
|
+
runtime: {
|
|
1176
|
+
chromeHost: host,
|
|
1177
|
+
chromePort: port,
|
|
1178
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1179
|
+
tabUrl: lastUrl,
|
|
1180
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1181
|
+
controllerPid: process.pid,
|
|
1182
|
+
},
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
await emitRuntimeHint();
|
|
1186
|
+
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
1187
|
+
const rechecked = await waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined);
|
|
1188
|
+
logger('Recovered assistant response after delayed recheck');
|
|
1189
|
+
return rechecked;
|
|
1190
|
+
};
|
|
1191
|
+
try {
|
|
1192
|
+
answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
|
|
1193
|
+
}
|
|
1194
|
+
catch (error) {
|
|
1195
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
1196
|
+
const rechecked = await attemptAssistantRecheck().catch(() => null);
|
|
1197
|
+
if (rechecked) {
|
|
1198
|
+
answer = rechecked;
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
try {
|
|
1202
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
1203
|
+
if (conversationUrl) {
|
|
1204
|
+
lastUrl = conversationUrl;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
// ignore
|
|
1209
|
+
}
|
|
1210
|
+
await emitRuntimeHint();
|
|
1211
|
+
const runtime = {
|
|
1212
|
+
chromePort: port,
|
|
1213
|
+
chromeHost: host,
|
|
1214
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1215
|
+
tabUrl: lastUrl,
|
|
1216
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1217
|
+
controllerPid: process.pid,
|
|
1218
|
+
};
|
|
1219
|
+
throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
throw error;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
|
|
1227
|
+
if (baselineNormalized) {
|
|
1228
|
+
const normalizedAnswer = normalizeForComparison(answer.text ?? '');
|
|
1229
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
1230
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
1231
|
+
: '';
|
|
1232
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
1233
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
1234
|
+
if (isBaseline) {
|
|
1235
|
+
logger('Detected stale assistant response; waiting for new response...');
|
|
1236
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
1237
|
+
if (refreshed) {
|
|
1238
|
+
answer = refreshed;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
answerText = answer.text;
|
|
1243
|
+
answerHtml = answer.html ?? '';
|
|
1244
|
+
const copiedMarkdown = await withRetries(async () => {
|
|
1245
|
+
const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
|
|
1246
|
+
if (!attempt) {
|
|
1247
|
+
throw new Error('copy-missing');
|
|
1248
|
+
}
|
|
1249
|
+
return attempt;
|
|
1250
|
+
}, {
|
|
1251
|
+
retries: 2,
|
|
1252
|
+
delayMs: 350,
|
|
1253
|
+
onRetry: (attempt, error) => {
|
|
1254
|
+
if (options.verbose) {
|
|
1255
|
+
logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
}).catch(() => null);
|
|
1259
|
+
answerMarkdown = copiedMarkdown ?? answerText;
|
|
1260
|
+
({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
|
|
1261
|
+
runtime: Runtime,
|
|
1262
|
+
baselineTurns,
|
|
1263
|
+
answerText,
|
|
1264
|
+
answerMarkdown,
|
|
1265
|
+
logger,
|
|
1266
|
+
allowMarkdownUpdate: !copiedMarkdown,
|
|
1267
|
+
}));
|
|
1268
|
+
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
1269
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
1270
|
+
const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
|
|
1271
|
+
if (finalText &&
|
|
1272
|
+
finalText !== answerMarkdown.trim() &&
|
|
1273
|
+
finalText !== promptText.trim() &&
|
|
1274
|
+
finalText.length >= answerMarkdown.trim().length) {
|
|
1275
|
+
logger('Refreshed assistant response via final DOM snapshot');
|
|
1276
|
+
answerText = finalText;
|
|
1277
|
+
answerMarkdown = finalText;
|
|
1278
|
+
}
|
|
1279
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
1280
|
+
const promptEchoMatcher = buildPromptEchoMatcher(promptText);
|
|
1281
|
+
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
1282
|
+
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
1283
|
+
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
1284
|
+
});
|
|
1285
|
+
answerText = alignedEcho.answerText;
|
|
1286
|
+
answerMarkdown = alignedEcho.answerMarkdown;
|
|
1287
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
1288
|
+
if (isPromptEcho) {
|
|
1289
|
+
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
1290
|
+
const deadline = Date.now() + 15_000;
|
|
1291
|
+
let bestText = null;
|
|
1292
|
+
let stableCount = 0;
|
|
1293
|
+
while (Date.now() < deadline) {
|
|
1294
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
1295
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
1296
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
1297
|
+
if (!isStillEcho) {
|
|
1298
|
+
if (!bestText || text.length > bestText.length) {
|
|
1299
|
+
bestText = text;
|
|
1300
|
+
stableCount = 0;
|
|
1301
|
+
}
|
|
1302
|
+
else if (text === bestText) {
|
|
1303
|
+
stableCount += 1;
|
|
1304
|
+
}
|
|
1305
|
+
if (stableCount >= 2) {
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1310
|
+
}
|
|
1311
|
+
if (bestText) {
|
|
1312
|
+
logger('Recovered assistant response after detecting prompt echo');
|
|
1313
|
+
answerText = bestText;
|
|
1314
|
+
answerMarkdown = bestText;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
stopThinkingMonitor?.();
|
|
1318
|
+
const durationMs = Date.now() - startedAt;
|
|
1319
|
+
const answerChars = answerText.length;
|
|
1320
|
+
const answerTokens = estimateTokenCount(answerMarkdown);
|
|
1321
|
+
return {
|
|
1322
|
+
answerText,
|
|
1323
|
+
answerMarkdown,
|
|
1324
|
+
answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
|
|
1325
|
+
tookMs: durationMs,
|
|
1326
|
+
answerTokens,
|
|
1327
|
+
answerChars,
|
|
1328
|
+
chromePid: undefined,
|
|
1329
|
+
chromePort: port,
|
|
1330
|
+
chromeHost: host,
|
|
1331
|
+
userDataDir: undefined,
|
|
1332
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1333
|
+
tabUrl: lastUrl,
|
|
1334
|
+
controllerPid: process.pid,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
1339
|
+
stopThinkingMonitor?.();
|
|
1340
|
+
const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
|
|
1341
|
+
connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
|
|
1342
|
+
if (!socketClosed) {
|
|
1343
|
+
logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
|
|
1344
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
|
|
1345
|
+
logger(normalizedError.stack);
|
|
1346
|
+
}
|
|
1347
|
+
throw normalizedError;
|
|
1348
|
+
}
|
|
1349
|
+
throw new BrowserAutomationError('Remote Chrome connection lost before Oracle finished.', {
|
|
1350
|
+
stage: 'connection-lost',
|
|
1351
|
+
runtime: {
|
|
1352
|
+
chromeHost: host,
|
|
1353
|
+
chromePort: port,
|
|
1354
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1355
|
+
tabUrl: lastUrl,
|
|
1356
|
+
controllerPid: process.pid,
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
finally {
|
|
1361
|
+
try {
|
|
1362
|
+
if (!connectionClosedUnexpectedly && client) {
|
|
1363
|
+
await client.close();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
// ignore
|
|
1368
|
+
}
|
|
1369
|
+
removeDialogHandler?.();
|
|
1370
|
+
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
|
|
1371
|
+
// Don't kill remote Chrome - it's not ours to manage
|
|
1372
|
+
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
1373
|
+
logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
export { estimateTokenCount } from './utils.js';
|
|
1377
|
+
export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
|
|
1378
|
+
export { syncCookies } from './cookies.js';
|
|
1379
|
+
export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
|
|
1380
|
+
export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) {
|
|
1381
|
+
return maybeReuseRunningChrome(userDataDir, logger, options);
|
|
1382
|
+
}
|
|
1383
|
+
function isWebSocketClosureError(error) {
|
|
1384
|
+
const message = error.message.toLowerCase();
|
|
1385
|
+
return (message.includes('websocket connection closed') ||
|
|
1386
|
+
message.includes('websocket is closed') ||
|
|
1387
|
+
message.includes('websocket error') ||
|
|
1388
|
+
message.includes('target closed'));
|
|
1389
|
+
}
|
|
1390
|
+
export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
|
|
1391
|
+
const elapsedMs = now - startedAt;
|
|
1392
|
+
const elapsedText = formatElapsed(elapsedMs);
|
|
1393
|
+
const progress = Math.min(1, elapsedMs / 600_000); // soft target: 10 minutes
|
|
1394
|
+
const pct = Math.round(progress * 100)
|
|
1395
|
+
.toString()
|
|
1396
|
+
.padStart(3, ' ');
|
|
1397
|
+
const statusLabel = message ? ` — ${message}` : '';
|
|
1398
|
+
return `${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
|
|
1399
|
+
}
|
|
1400
|
+
async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex) {
|
|
1401
|
+
try {
|
|
1402
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
1403
|
+
}
|
|
1404
|
+
catch (error) {
|
|
1405
|
+
if (!shouldReloadAfterAssistantError(error)) {
|
|
1406
|
+
throw error;
|
|
1407
|
+
}
|
|
1408
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
1409
|
+
if (!conversationUrl || !isConversationUrl(conversationUrl)) {
|
|
1410
|
+
throw error;
|
|
1411
|
+
}
|
|
1412
|
+
logger('Assistant response stalled; reloading conversation and retrying once');
|
|
1413
|
+
await Page.navigate({ url: conversationUrl });
|
|
1414
|
+
await delay(1000);
|
|
1415
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
function shouldReloadAfterAssistantError(error) {
|
|
1419
|
+
if (!(error instanceof Error))
|
|
1420
|
+
return false;
|
|
1421
|
+
const message = error.message.toLowerCase();
|
|
1422
|
+
return (message.includes('assistant-response') ||
|
|
1423
|
+
message.includes('watchdog') ||
|
|
1424
|
+
message.includes('timeout') ||
|
|
1425
|
+
message.includes('capture assistant response'));
|
|
1426
|
+
}
|
|
1427
|
+
function isAssistantResponseTimeoutError(error) {
|
|
1428
|
+
if (!(error instanceof Error))
|
|
1429
|
+
return false;
|
|
1430
|
+
const message = error.message.toLowerCase();
|
|
1431
|
+
if (!message)
|
|
1432
|
+
return false;
|
|
1433
|
+
return (message.includes('assistant-response') ||
|
|
1434
|
+
message.includes('assistant response') ||
|
|
1435
|
+
message.includes('watchdog') ||
|
|
1436
|
+
message.includes('capture assistant response'));
|
|
1437
|
+
}
|
|
1438
|
+
async function readConversationUrl(Runtime) {
|
|
1439
|
+
try {
|
|
1440
|
+
const currentUrl = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
1441
|
+
return typeof currentUrl.result?.value === 'string' ? currentUrl.result.value : null;
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Validates that the ChatGPT session is still active by checking for login CTAs
|
|
1449
|
+
* and textarea availability. Sessions can expire during long delays (e.g., recheck).
|
|
1450
|
+
*
|
|
1451
|
+
* @param Runtime - Chrome Runtime client
|
|
1452
|
+
* @param logger - Browser logger for diagnostics
|
|
1453
|
+
* @returns SessionValidationResult indicating if session is valid and reason if not
|
|
1454
|
+
*/
|
|
1455
|
+
async function validateChatGPTSession(Runtime, logger) {
|
|
1456
|
+
try {
|
|
1457
|
+
const outcome = await Runtime.evaluate({
|
|
1458
|
+
expression: buildSessionValidationExpression(),
|
|
1459
|
+
awaitPromise: true,
|
|
1460
|
+
returnByValue: true,
|
|
1461
|
+
});
|
|
1462
|
+
const result = outcome.result?.value;
|
|
1463
|
+
if (!result) {
|
|
1464
|
+
return { valid: false, reason: 'Failed to evaluate session state' };
|
|
1465
|
+
}
|
|
1466
|
+
if (result.onAuthPage) {
|
|
1467
|
+
return { valid: false, reason: 'Redirected to auth page' };
|
|
1468
|
+
}
|
|
1469
|
+
if (result.hasLoginCta) {
|
|
1470
|
+
return { valid: false, reason: 'Login button detected on page' };
|
|
1471
|
+
}
|
|
1472
|
+
if (!result.hasTextarea) {
|
|
1473
|
+
return { valid: false, reason: 'Prompt textarea not available' };
|
|
1474
|
+
}
|
|
1475
|
+
return { valid: true };
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1479
|
+
logger(`[browser] Session validation error: ${message}`);
|
|
1480
|
+
return { valid: false, reason: `Validation error: ${message}` };
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
function buildSessionValidationExpression() {
|
|
1484
|
+
const selectorLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
1485
|
+
return `(async () => {
|
|
1486
|
+
const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
|
|
1487
|
+
const onAuthPage =
|
|
1488
|
+
typeof location === 'object' &&
|
|
1489
|
+
typeof location.pathname === 'string' &&
|
|
1490
|
+
/^\\/(auth|login|signin)/i.test(location.pathname);
|
|
1491
|
+
|
|
1492
|
+
// Check for login CTAs (similar to ensureLoggedIn logic)
|
|
1493
|
+
const hasLoginCta = (() => {
|
|
1494
|
+
const candidates = Array.from(
|
|
1495
|
+
document.querySelectorAll(
|
|
1496
|
+
[
|
|
1497
|
+
'a[href*="/auth/login"]',
|
|
1498
|
+
'a[href*="/auth/signin"]',
|
|
1499
|
+
'button[type="submit"]',
|
|
1500
|
+
'button[data-testid*="login"]',
|
|
1501
|
+
'button[data-testid*="log-in"]',
|
|
1502
|
+
'button[data-testid*="sign-in"]',
|
|
1503
|
+
'button[data-testid*="signin"]',
|
|
1504
|
+
'button',
|
|
1505
|
+
'a',
|
|
1506
|
+
].join(','),
|
|
1507
|
+
),
|
|
1508
|
+
);
|
|
1509
|
+
const textMatches = (text) => {
|
|
1510
|
+
if (!text) return false;
|
|
1511
|
+
const normalized = text.toLowerCase().trim();
|
|
1512
|
+
return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
|
|
1513
|
+
normalized.startsWith(needle),
|
|
1514
|
+
);
|
|
1515
|
+
};
|
|
1516
|
+
for (const node of candidates) {
|
|
1517
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
1518
|
+
const label =
|
|
1519
|
+
node.textContent?.trim() ||
|
|
1520
|
+
node.getAttribute('aria-label') ||
|
|
1521
|
+
node.getAttribute('title') ||
|
|
1522
|
+
'';
|
|
1523
|
+
if (textMatches(label)) {
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return false;
|
|
1528
|
+
})();
|
|
1529
|
+
|
|
1530
|
+
// Check for textarea availability
|
|
1531
|
+
const hasTextarea = (() => {
|
|
1532
|
+
const selectors = ${selectorLiteral};
|
|
1533
|
+
for (const selector of selectors) {
|
|
1534
|
+
const node = document.querySelector(selector);
|
|
1535
|
+
if (node) {
|
|
1536
|
+
return true;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return false;
|
|
1540
|
+
})();
|
|
1541
|
+
|
|
1542
|
+
return {
|
|
1543
|
+
valid: !onAuthPage && !hasLoginCta && hasTextarea,
|
|
1544
|
+
hasLoginCta,
|
|
1545
|
+
hasTextarea,
|
|
1546
|
+
onAuthPage,
|
|
1547
|
+
pageUrl,
|
|
1548
|
+
};
|
|
1549
|
+
})()`;
|
|
1550
|
+
}
|
|
1551
|
+
async function readConversationTurnCount(Runtime, logger) {
|
|
1552
|
+
const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
1553
|
+
const attempts = 4;
|
|
1554
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1555
|
+
try {
|
|
1556
|
+
const { result } = await Runtime.evaluate({
|
|
1557
|
+
expression: `document.querySelectorAll(${selectorLiteral}).length`,
|
|
1558
|
+
returnByValue: true,
|
|
1559
|
+
});
|
|
1560
|
+
const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
|
|
1561
|
+
if (!Number.isFinite(raw)) {
|
|
1562
|
+
throw new Error('Turn count not numeric');
|
|
1563
|
+
}
|
|
1564
|
+
return Math.max(0, Math.floor(raw));
|
|
1565
|
+
}
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
if (attempt < attempts - 1) {
|
|
1568
|
+
await delay(150);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
if (logger?.verbose) {
|
|
1572
|
+
logger(`Failed to read conversation turn count: ${error instanceof Error ? error.message : String(error)}`);
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
function isConversationUrl(url) {
|
|
1580
|
+
return /\/c\/[a-z0-9-]+/i.test(url);
|
|
1581
|
+
}
|
|
1582
|
+
function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
|
|
1583
|
+
let stopped = false;
|
|
1584
|
+
let pending = false;
|
|
1585
|
+
let lastMessage = null;
|
|
1586
|
+
const startedAt = Date.now();
|
|
1587
|
+
const interval = setInterval(async () => {
|
|
1588
|
+
// stop flag flips asynchronously
|
|
1589
|
+
if (stopped || pending) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
pending = true;
|
|
1593
|
+
try {
|
|
1594
|
+
const nextMessage = await readThinkingStatus(Runtime);
|
|
1595
|
+
if (nextMessage && nextMessage !== lastMessage) {
|
|
1596
|
+
lastMessage = nextMessage;
|
|
1597
|
+
let locatorSuffix = '';
|
|
1598
|
+
if (includeDiagnostics) {
|
|
1599
|
+
try {
|
|
1600
|
+
const snapshot = await readAssistantSnapshot(Runtime);
|
|
1601
|
+
locatorSuffix = ` | assistant-turn=${snapshot ? 'present' : 'missing'}`;
|
|
1602
|
+
}
|
|
1603
|
+
catch {
|
|
1604
|
+
locatorSuffix = ' | assistant-turn=error';
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
logger(formatThinkingLog(startedAt, Date.now(), nextMessage, locatorSuffix));
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
// ignore DOM polling errors
|
|
1612
|
+
}
|
|
1613
|
+
finally {
|
|
1614
|
+
pending = false;
|
|
1615
|
+
}
|
|
1616
|
+
}, 1500);
|
|
1617
|
+
interval.unref?.();
|
|
1618
|
+
return () => {
|
|
1619
|
+
// multiple callers may race to stop
|
|
1620
|
+
if (stopped) {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
stopped = true;
|
|
1624
|
+
clearInterval(interval);
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
async function readThinkingStatus(Runtime) {
|
|
1628
|
+
const expression = buildThinkingStatusExpression();
|
|
1629
|
+
try {
|
|
1630
|
+
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
1631
|
+
const value = typeof result.value === 'string' ? result.value.trim() : '';
|
|
1632
|
+
const sanitized = sanitizeThinkingText(value);
|
|
1633
|
+
return sanitized || null;
|
|
1634
|
+
}
|
|
1635
|
+
catch {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
function sanitizeThinkingText(raw) {
|
|
1640
|
+
if (!raw) {
|
|
1641
|
+
return '';
|
|
1642
|
+
}
|
|
1643
|
+
const trimmed = raw.trim();
|
|
1644
|
+
const prefixPattern = /^(pro thinking)\s*[•:\-–—]*\s*/i;
|
|
1645
|
+
if (prefixPattern.test(trimmed)) {
|
|
1646
|
+
return trimmed.replace(prefixPattern, '').trim();
|
|
1647
|
+
}
|
|
1648
|
+
return trimmed;
|
|
1649
|
+
}
|
|
1650
|
+
function describeDevtoolsFirewallHint(host, port) {
|
|
1651
|
+
if (!isWsl())
|
|
1652
|
+
return null;
|
|
1653
|
+
return [
|
|
1654
|
+
`DevTools port ${host}:${port} is blocked from WSL.`,
|
|
1655
|
+
'',
|
|
1656
|
+
'PowerShell (admin):',
|
|
1657
|
+
`New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
|
|
1658
|
+
"New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
|
|
1659
|
+
'',
|
|
1660
|
+
'Re-run the same oracle command after adding the rule.',
|
|
1661
|
+
].join('\n');
|
|
1662
|
+
}
|
|
1663
|
+
function isWsl() {
|
|
1664
|
+
if (process.platform !== 'linux')
|
|
1665
|
+
return false;
|
|
1666
|
+
if (process.env.WSL_DISTRO_NAME)
|
|
1667
|
+
return true;
|
|
1668
|
+
return os.release().toLowerCase().includes('microsoft');
|
|
1669
|
+
}
|
|
1670
|
+
function extractConversationIdFromUrl(url) {
|
|
1671
|
+
const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
|
|
1672
|
+
return match?.[1];
|
|
1673
|
+
}
|
|
1674
|
+
async function resolveUserDataBaseDir() {
|
|
1675
|
+
// On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
|
|
1676
|
+
if (isWsl()) {
|
|
1677
|
+
const candidates = [
|
|
1678
|
+
'/mnt/c/Users/Public/AppData/Local/Temp',
|
|
1679
|
+
'/mnt/c/Temp',
|
|
1680
|
+
'/mnt/c/Windows/Temp',
|
|
1681
|
+
];
|
|
1682
|
+
for (const candidate of candidates) {
|
|
1683
|
+
try {
|
|
1684
|
+
await mkdir(candidate, { recursive: true });
|
|
1685
|
+
return candidate;
|
|
1686
|
+
}
|
|
1687
|
+
catch {
|
|
1688
|
+
// try next
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return os.tmpdir();
|
|
1693
|
+
}
|
|
1694
|
+
function buildThinkingStatusExpression() {
|
|
1695
|
+
const selectors = [
|
|
1696
|
+
'span.loading-shimmer',
|
|
1697
|
+
'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
|
|
1698
|
+
'[data-testid*="thinking"]',
|
|
1699
|
+
'[data-testid*="reasoning"]',
|
|
1700
|
+
'[role="status"]',
|
|
1701
|
+
'[aria-live="polite"]',
|
|
1702
|
+
];
|
|
1703
|
+
const keywords = ['pro thinking', 'thinking', 'reasoning', 'clarifying', 'planning', 'drafting', 'summarizing'];
|
|
1704
|
+
const selectorLiteral = JSON.stringify(selectors);
|
|
1705
|
+
const keywordsLiteral = JSON.stringify(keywords);
|
|
1706
|
+
return `(() => {
|
|
1707
|
+
const selectors = ${selectorLiteral};
|
|
1708
|
+
const keywords = ${keywordsLiteral};
|
|
1709
|
+
const nodes = new Set();
|
|
1710
|
+
for (const selector of selectors) {
|
|
1711
|
+
document.querySelectorAll(selector).forEach((node) => nodes.add(node));
|
|
1712
|
+
}
|
|
1713
|
+
document.querySelectorAll('[data-testid]').forEach((node) => nodes.add(node));
|
|
1714
|
+
for (const node of nodes) {
|
|
1715
|
+
if (!(node instanceof HTMLElement)) {
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const text = node.textContent?.trim();
|
|
1719
|
+
if (!text) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
const classLabel = (node.className || '').toLowerCase();
|
|
1723
|
+
const dataLabel = ((node.getAttribute('data-testid') || '') + ' ' + (node.getAttribute('aria-label') || ''))
|
|
1724
|
+
.toLowerCase();
|
|
1725
|
+
const normalizedText = text.toLowerCase();
|
|
1726
|
+
const matches = keywords.some((keyword) =>
|
|
1727
|
+
normalizedText.includes(keyword) || classLabel.includes(keyword) || dataLabel.includes(keyword)
|
|
1728
|
+
);
|
|
1729
|
+
if (matches) {
|
|
1730
|
+
const shimmerChild = node.querySelector(
|
|
1731
|
+
'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
|
|
1732
|
+
);
|
|
1733
|
+
if (shimmerChild?.textContent?.trim()) {
|
|
1734
|
+
return shimmerChild.textContent.trim();
|
|
1735
|
+
}
|
|
1736
|
+
return text.trim();
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
})()`;
|
|
1741
|
+
}
|