@steipete/oracle 1.0.8 → 1.1.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.
Files changed (58) hide show
  1. package/README.md +3 -0
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +9 -3
  4. package/dist/markdansi/types/index.js +4 -0
  5. package/dist/oracle/bin/oracle-cli.js +472 -0
  6. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  7. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  8. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  9. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  10. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  11. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  12. package/dist/oracle/src/browser/config.js +33 -0
  13. package/dist/oracle/src/browser/constants.js +40 -0
  14. package/dist/oracle/src/browser/cookies.js +210 -0
  15. package/dist/oracle/src/browser/domDebug.js +36 -0
  16. package/dist/oracle/src/browser/index.js +331 -0
  17. package/dist/oracle/src/browser/pageActions.js +5 -0
  18. package/dist/oracle/src/browser/prompt.js +88 -0
  19. package/dist/oracle/src/browser/promptSummary.js +20 -0
  20. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  21. package/dist/oracle/src/browser/types.js +1 -0
  22. package/dist/oracle/src/browser/utils.js +62 -0
  23. package/dist/oracle/src/browserMode.js +1 -0
  24. package/dist/oracle/src/cli/browserConfig.js +44 -0
  25. package/dist/oracle/src/cli/dryRun.js +59 -0
  26. package/dist/oracle/src/cli/engine.js +17 -0
  27. package/dist/oracle/src/cli/errorUtils.js +9 -0
  28. package/dist/oracle/src/cli/help.js +70 -0
  29. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  30. package/dist/oracle/src/cli/options.js +103 -0
  31. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  32. package/dist/oracle/src/cli/rootAlias.js +30 -0
  33. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  34. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  35. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  36. package/dist/oracle/src/heartbeat.js +43 -0
  37. package/dist/oracle/src/oracle/client.js +48 -0
  38. package/dist/oracle/src/oracle/config.js +29 -0
  39. package/dist/oracle/src/oracle/errors.js +101 -0
  40. package/dist/oracle/src/oracle/files.js +220 -0
  41. package/dist/oracle/src/oracle/format.js +33 -0
  42. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  43. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  44. package/dist/oracle/src/oracle/request.js +48 -0
  45. package/dist/oracle/src/oracle/run.js +444 -0
  46. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  47. package/dist/oracle/src/oracle/types.js +1 -0
  48. package/dist/oracle/src/oracle.js +9 -0
  49. package/dist/oracle/src/sessionManager.js +205 -0
  50. package/dist/oracle/src/version.js +39 -0
  51. package/dist/src/cli/markdownRenderer.js +18 -0
  52. package/dist/src/cli/rootAlias.js +14 -0
  53. package/dist/src/cli/sessionCommand.js +60 -2
  54. package/dist/src/cli/sessionDisplay.js +129 -4
  55. package/dist/src/oracle/oscProgress.js +60 -0
  56. package/dist/src/oracle/run.js +63 -51
  57. package/dist/src/sessionManager.js +17 -0
  58. package/package.json +14 -22
@@ -0,0 +1,210 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { COOKIE_URLS } from './constants.js';
5
+ export class ChromeCookieSyncError extends Error {
6
+ }
7
+ export async function syncCookies(Network, url, profile, logger, allowErrors = false) {
8
+ try {
9
+ const cookies = await readChromeCookies(url, profile);
10
+ if (!cookies.length) {
11
+ return 0;
12
+ }
13
+ let applied = 0;
14
+ for (const cookie of cookies) {
15
+ const cookieWithUrl = { ...cookie };
16
+ if (!cookieWithUrl.domain || cookieWithUrl.domain === 'localhost') {
17
+ cookieWithUrl.url = url;
18
+ }
19
+ else if (!cookieWithUrl.domain.startsWith('.')) {
20
+ cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
21
+ }
22
+ try {
23
+ const result = await Network.setCookie(cookieWithUrl);
24
+ if (result?.success) {
25
+ applied += 1;
26
+ }
27
+ }
28
+ catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ logger(`Failed to set cookie ${cookie.name}: ${message}`);
31
+ }
32
+ }
33
+ return applied;
34
+ }
35
+ catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ if (allowErrors) {
38
+ logger(`Cookie sync failed (continuing with override): ${message}`);
39
+ return 0;
40
+ }
41
+ throw error instanceof ChromeCookieSyncError ? error : new ChromeCookieSyncError(message);
42
+ }
43
+ }
44
+ async function readChromeCookies(url, profile) {
45
+ const chromeModule = await loadChromeCookiesModule();
46
+ const urlsToCheck = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
47
+ const merged = new Map();
48
+ for (const candidateUrl of urlsToCheck) {
49
+ let rawCookies;
50
+ rawCookies = await chromeModule.getCookiesPromised(candidateUrl, 'puppeteer', profile ?? undefined);
51
+ if (!Array.isArray(rawCookies)) {
52
+ continue;
53
+ }
54
+ const fallbackHostname = new URL(candidateUrl).hostname;
55
+ for (const cookie of rawCookies) {
56
+ const normalized = normalizeCookie(cookie, fallbackHostname);
57
+ if (!normalized) {
58
+ continue;
59
+ }
60
+ const key = `${normalized.domain ?? fallbackHostname}:${normalized.name}`;
61
+ if (!merged.has(key)) {
62
+ merged.set(key, normalized);
63
+ }
64
+ }
65
+ }
66
+ return Array.from(merged.values());
67
+ }
68
+ function normalizeCookie(cookie, fallbackHost) {
69
+ if (!cookie?.name) {
70
+ return null;
71
+ }
72
+ const domain = cookie.domain?.startsWith('.') ? cookie.domain : cookie.domain ?? fallbackHost;
73
+ const expires = normalizeExpiration(cookie.expires);
74
+ const secure = typeof cookie.Secure === 'boolean' ? cookie.Secure : true;
75
+ const httpOnly = typeof cookie.HttpOnly === 'boolean' ? cookie.HttpOnly : false;
76
+ return {
77
+ name: cookie.name,
78
+ value: cookie.value ?? '',
79
+ domain,
80
+ path: cookie.path ?? '/',
81
+ expires,
82
+ secure,
83
+ httpOnly,
84
+ };
85
+ }
86
+ function stripQuery(url) {
87
+ try {
88
+ const parsed = new URL(url);
89
+ parsed.hash = '';
90
+ parsed.search = '';
91
+ return parsed.toString();
92
+ }
93
+ catch {
94
+ return url;
95
+ }
96
+ }
97
+ function normalizeExpiration(expires) {
98
+ if (!expires || Number.isNaN(expires)) {
99
+ return undefined;
100
+ }
101
+ const value = Number(expires);
102
+ if (value <= 0) {
103
+ return undefined;
104
+ }
105
+ if (value > 1_000_000_000_000) {
106
+ return Math.round(value / 1_000_000 - 11644473600);
107
+ }
108
+ if (value > 1_000_000_000) {
109
+ return Math.round(value / 1000);
110
+ }
111
+ return Math.round(value);
112
+ }
113
+ const WORKSPACE_MANIFEST_PATH = fileURLToPath(new URL('../../pnpm-workspace.yaml', import.meta.url));
114
+ const HAS_PNPM_WORKSPACE = existsSync(WORKSPACE_MANIFEST_PATH);
115
+ const SQLITE_NODE_PATTERN = /node_sqlite3\.node/i;
116
+ const SQLITE_BINDINGS_PATTERN = /bindings file/i;
117
+ const SQLITE_SELF_REGISTER_PATTERN = /Module did not self-register/i;
118
+ const SQLITE_BINDING_HINT = [
119
+ 'Chrome cookie sync needs sqlite3 bindings for Node 25.',
120
+ 'If the automatic rebuild fails, run:',
121
+ ' PYTHON=/usr/bin/python3 npm_config_build_from_source=1 pnpm rebuild chrome-cookies-secure sqlite3 keytar --workspace-root',
122
+ ].join('\n');
123
+ let attemptedSqliteRebuild = false;
124
+ async function loadChromeCookiesModule() {
125
+ let imported;
126
+ try {
127
+ imported = await import('chrome-cookies-secure');
128
+ }
129
+ catch (error) {
130
+ console.warn('Failed to load chrome-cookies-secure to copy cookies:', error);
131
+ if (isSqliteBindingError(error)) {
132
+ const rebuilt = await attemptSqliteRebuild();
133
+ if (rebuilt) {
134
+ return loadChromeCookiesModule();
135
+ }
136
+ console.warn(SQLITE_BINDING_HINT);
137
+ }
138
+ else {
139
+ console.warn('If this persists, run `pnpm rebuild chrome-cookies-secure sqlite3 keytar --workspace-root`.');
140
+ }
141
+ throw new ChromeCookieSyncError('Unable to load chrome-cookies-secure. Cookie copy is required.');
142
+ }
143
+ const secureModule = resolveChromeCookieModule(imported);
144
+ if (!secureModule) {
145
+ console.warn('chrome-cookies-secure does not expose getCookiesPromised(); skipping cookie copy.');
146
+ throw new ChromeCookieSyncError('chrome-cookies-secure did not expose getCookiesPromised');
147
+ }
148
+ return secureModule;
149
+ }
150
+ function resolveChromeCookieModule(candidate) {
151
+ if (hasGetCookiesPromised(candidate)) {
152
+ return candidate;
153
+ }
154
+ if (typeof candidate === 'object' && candidate !== null) {
155
+ const defaultExport = Reflect.get(candidate, 'default');
156
+ if (hasGetCookiesPromised(defaultExport)) {
157
+ return defaultExport;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ function hasGetCookiesPromised(value) {
163
+ return Boolean(value && typeof value.getCookiesPromised === 'function');
164
+ }
165
+ function isSqliteBindingError(error) {
166
+ if (!(error instanceof Error)) {
167
+ return false;
168
+ }
169
+ const message = error.message ?? '';
170
+ return (SQLITE_NODE_PATTERN.test(message) ||
171
+ SQLITE_BINDINGS_PATTERN.test(message) ||
172
+ SQLITE_SELF_REGISTER_PATTERN.test(message));
173
+ }
174
+ async function attemptSqliteRebuild() {
175
+ if (attemptedSqliteRebuild) {
176
+ return false;
177
+ }
178
+ attemptedSqliteRebuild = true;
179
+ if (process.env.ORACLE_ALLOW_SQLITE_REBUILD !== '1') {
180
+ console.warn('[oracle] sqlite3 bindings missing. Set ORACLE_ALLOW_SQLITE_REBUILD=1 if you want Oracle to attempt an automatic rebuild, or run `pnpm rebuild chrome-cookies-secure sqlite3 keytar --workspace-root` manually.');
181
+ return false;
182
+ }
183
+ const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
184
+ const args = ['rebuild', 'chrome-cookies-secure', 'sqlite3', 'keytar'];
185
+ if (HAS_PNPM_WORKSPACE) {
186
+ args.push('--workspace-root');
187
+ }
188
+ const childEnv = { ...process.env };
189
+ childEnv.npm_config_build_from_source = '1';
190
+ childEnv.PYTHON = childEnv.PYTHON ?? '/usr/bin/python3';
191
+ console.warn('[oracle] Attempting to rebuild sqlite3 bindings automatically…');
192
+ console.warn(`[oracle] Running: npm_config_build_from_source=1 PYTHON=${childEnv.PYTHON} ${pnpmCommand} ${args.join(' ')}`);
193
+ return new Promise((resolve) => {
194
+ const child = spawn(pnpmCommand, args, { stdio: 'inherit', env: childEnv });
195
+ child.on('exit', (code) => {
196
+ if (code === 0) {
197
+ console.warn('[oracle] sqlite3 rebuild completed successfully.');
198
+ resolve(true);
199
+ }
200
+ else {
201
+ console.warn('[oracle] sqlite3 rebuild failed with exit code', code ?? 0);
202
+ resolve(false);
203
+ }
204
+ });
205
+ child.on('error', (error) => {
206
+ console.warn('[oracle] Unable to spawn pnpm to rebuild sqlite3:', error);
207
+ resolve(false);
208
+ });
209
+ });
210
+ }
@@ -0,0 +1,36 @@
1
+ import { CONVERSATION_TURN_SELECTOR } from './constants.js';
2
+ export function buildConversationDebugExpression() {
3
+ return `(() => {
4
+ const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
5
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
6
+ return turns.map((node) => ({
7
+ role: node.getAttribute('data-message-author-role'),
8
+ text: node.innerText?.slice(0, 200),
9
+ testid: node.getAttribute('data-testid'),
10
+ }));
11
+ })()`;
12
+ }
13
+ export async function logConversationSnapshot(Runtime, logger) {
14
+ const expression = buildConversationDebugExpression();
15
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
16
+ if (Array.isArray(result.value)) {
17
+ const recent = result.value.slice(-3);
18
+ logger(`Conversation snapshot: ${JSON.stringify(recent)}`);
19
+ }
20
+ }
21
+ export async function logDomFailure(Runtime, logger, context) {
22
+ if (!logger?.verbose) {
23
+ return;
24
+ }
25
+ try {
26
+ const entry = `Browser automation failure (${context}); capturing DOM snapshot for debugging...`;
27
+ logger(entry);
28
+ if (logger.sessionLog && logger.sessionLog !== logger) {
29
+ logger.sessionLog(entry);
30
+ }
31
+ await logConversationSnapshot(Runtime, logger);
32
+ }
33
+ catch {
34
+ // ignore snapshot failures
35
+ }
36
+ }
@@ -0,0 +1,331 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { resolveBrowserConfig } from './config.js';
5
+ import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome } from './chromeLifecycle.js';
6
+ import { syncCookies } from './cookies.js';
7
+ import { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
+ import { estimateTokenCount, withRetries } from './utils.js';
9
+ import { formatElapsed } from '../oracle/format.js';
10
+ export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
11
+ export { parseDuration, delay } from './utils.js';
12
+ export async function runBrowserMode(options) {
13
+ const promptText = options.prompt?.trim();
14
+ if (!promptText) {
15
+ throw new Error('Prompt text is required when using browser mode.');
16
+ }
17
+ const attachments = options.attachments ?? [];
18
+ const config = resolveBrowserConfig(options.config);
19
+ const logger = options.log ?? ((_message) => { });
20
+ if (logger.verbose === undefined) {
21
+ logger.verbose = Boolean(config.debug);
22
+ }
23
+ if (logger.sessionLog === undefined && options.log?.sessionLog) {
24
+ logger.sessionLog = options.log.sessionLog;
25
+ }
26
+ if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') {
27
+ logger(`[browser-mode] config: ${JSON.stringify({
28
+ ...config,
29
+ promptLength: promptText.length,
30
+ })}`);
31
+ }
32
+ const userDataDir = await mkdtemp(path.join(os.tmpdir(), 'oracle-browser-'));
33
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
34
+ const chrome = await launchChrome(config, userDataDir, logger);
35
+ let removeTerminationHooks = null;
36
+ try {
37
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, config.keepBrowser, logger);
38
+ }
39
+ catch {
40
+ // ignore failure; cleanup still happens below
41
+ }
42
+ let client = null;
43
+ const startedAt = Date.now();
44
+ let answerText = '';
45
+ let answerMarkdown = '';
46
+ let answerHtml = '';
47
+ let runStatus = 'attempted';
48
+ let connectionClosedUnexpectedly = false;
49
+ let stopThinkingMonitor = null;
50
+ try {
51
+ client = await connectToChrome(chrome.port, logger);
52
+ const markConnectionLost = () => {
53
+ connectionClosedUnexpectedly = true;
54
+ };
55
+ client.on('disconnect', markConnectionLost);
56
+ const { Network, Page, Runtime, Input, DOM } = client;
57
+ if (!config.headless && config.hideWindow) {
58
+ await hideChromeWindow(chrome, logger);
59
+ }
60
+ const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
61
+ if (DOM && typeof DOM.enable === 'function') {
62
+ domainEnablers.push(DOM.enable());
63
+ }
64
+ await Promise.all(domainEnablers);
65
+ await Network.clearBrowserCookies();
66
+ if (config.cookieSync) {
67
+ const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, config.allowCookieErrors ?? false);
68
+ logger(cookieCount > 0
69
+ ? `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
70
+ : 'No Chrome cookies found; continuing without session reuse');
71
+ }
72
+ else {
73
+ logger('Skipping Chrome cookie sync (--browser-no-cookie-sync)');
74
+ }
75
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
76
+ await ensureNotBlocked(Runtime, config.headless, logger);
77
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
78
+ logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
79
+ if (config.desiredModel) {
80
+ await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
81
+ retries: 2,
82
+ delayMs: 300,
83
+ onRetry: (attempt, error) => {
84
+ if (options.verbose) {
85
+ logger(`[retry] Model picker attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
86
+ }
87
+ },
88
+ });
89
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
90
+ logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
91
+ }
92
+ if (attachments.length > 0) {
93
+ if (!DOM) {
94
+ throw new Error('Chrome DOM domain unavailable while uploading attachments.');
95
+ }
96
+ for (const attachment of attachments) {
97
+ logger(`Uploading attachment: ${attachment.displayPath}`);
98
+ await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
99
+ }
100
+ const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
101
+ await waitForAttachmentCompletion(Runtime, waitBudget, logger);
102
+ logger('All attachments uploaded');
103
+ }
104
+ await submitPrompt({ runtime: Runtime, input: Input }, promptText, logger);
105
+ stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
106
+ const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
107
+ answerText = answer.text;
108
+ answerHtml = answer.html ?? '';
109
+ const copiedMarkdown = await withRetries(async () => {
110
+ const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
111
+ if (!attempt) {
112
+ throw new Error('copy-missing');
113
+ }
114
+ return attempt;
115
+ }, {
116
+ retries: 2,
117
+ delayMs: 350,
118
+ onRetry: (attempt, error) => {
119
+ if (options.verbose) {
120
+ logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
121
+ }
122
+ },
123
+ }).catch(() => null);
124
+ answerMarkdown = copiedMarkdown ?? answerText;
125
+ stopThinkingMonitor?.();
126
+ runStatus = 'complete';
127
+ const durationMs = Date.now() - startedAt;
128
+ const answerChars = answerText.length;
129
+ const answerTokens = estimateTokenCount(answerMarkdown);
130
+ return {
131
+ answerText,
132
+ answerMarkdown,
133
+ answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
134
+ tookMs: durationMs,
135
+ answerTokens,
136
+ answerChars,
137
+ chromePid: chrome.pid,
138
+ chromePort: chrome.port,
139
+ userDataDir,
140
+ };
141
+ }
142
+ catch (error) {
143
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
144
+ stopThinkingMonitor?.();
145
+ const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
146
+ connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
147
+ if (!socketClosed) {
148
+ logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
149
+ if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
150
+ logger(normalizedError.stack);
151
+ }
152
+ throw normalizedError;
153
+ }
154
+ if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
155
+ logger(`Chrome window closed before completion: ${normalizedError.message}`);
156
+ logger(normalizedError.stack);
157
+ }
158
+ throw new Error('Chrome window closed before Oracle finished. Please keep it open until completion.', {
159
+ cause: normalizedError,
160
+ });
161
+ }
162
+ finally {
163
+ try {
164
+ if (!connectionClosedUnexpectedly) {
165
+ await client?.close();
166
+ }
167
+ }
168
+ catch {
169
+ // ignore
170
+ }
171
+ removeTerminationHooks?.();
172
+ if (!config.keepBrowser) {
173
+ if (!connectionClosedUnexpectedly) {
174
+ try {
175
+ await chrome.kill();
176
+ }
177
+ catch {
178
+ // ignore kill failures
179
+ }
180
+ }
181
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
182
+ if (!connectionClosedUnexpectedly) {
183
+ const totalSeconds = (Date.now() - startedAt) / 1000;
184
+ logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
185
+ }
186
+ }
187
+ else if (!connectionClosedUnexpectedly) {
188
+ logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
189
+ }
190
+ }
191
+ }
192
+ export { estimateTokenCount } from './utils.js';
193
+ export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
194
+ export { syncCookies } from './cookies.js';
195
+ export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
196
+ function isWebSocketClosureError(error) {
197
+ const message = error.message.toLowerCase();
198
+ return (message.includes('websocket connection closed') ||
199
+ message.includes('websocket is closed') ||
200
+ message.includes('websocket error') ||
201
+ message.includes('target closed'));
202
+ }
203
+ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
204
+ const elapsedMs = now - startedAt;
205
+ const elapsedText = formatElapsed(elapsedMs);
206
+ const progress = Math.min(1, elapsedMs / 600_000); // soft target: 10 minutes
207
+ const barSegments = 10;
208
+ const filled = Math.round(progress * barSegments);
209
+ const bar = `${'█'.repeat(filled).padEnd(barSegments, '░')}`;
210
+ const pct = Math.round(progress * 100)
211
+ .toString()
212
+ .padStart(3, ' ');
213
+ const statusLabel = message ? ` — ${message}` : '';
214
+ return `[${elapsedText} / ~10m] ${bar} ${pct}%${statusLabel}${locatorSuffix}`;
215
+ }
216
+ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
217
+ let stopped = false;
218
+ let pending = false;
219
+ let lastMessage = null;
220
+ const startedAt = Date.now();
221
+ const interval = setInterval(async () => {
222
+ // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
223
+ if (stopped || pending) {
224
+ return;
225
+ }
226
+ pending = true;
227
+ try {
228
+ const nextMessage = await readThinkingStatus(Runtime);
229
+ if (nextMessage && nextMessage !== lastMessage) {
230
+ lastMessage = nextMessage;
231
+ let locatorSuffix = '';
232
+ if (includeDiagnostics) {
233
+ try {
234
+ const snapshot = await readAssistantSnapshot(Runtime);
235
+ locatorSuffix = ` | assistant-turn=${snapshot ? 'present' : 'missing'}`;
236
+ }
237
+ catch {
238
+ locatorSuffix = ' | assistant-turn=error';
239
+ }
240
+ }
241
+ logger(formatThinkingLog(startedAt, Date.now(), nextMessage, locatorSuffix));
242
+ }
243
+ }
244
+ catch {
245
+ // ignore DOM polling errors
246
+ }
247
+ finally {
248
+ pending = false;
249
+ }
250
+ }, 1500);
251
+ interval.unref?.();
252
+ return () => {
253
+ // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
254
+ if (stopped) {
255
+ return;
256
+ }
257
+ stopped = true;
258
+ clearInterval(interval);
259
+ };
260
+ }
261
+ async function readThinkingStatus(Runtime) {
262
+ const expression = buildThinkingStatusExpression();
263
+ try {
264
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
265
+ const value = typeof result.value === 'string' ? result.value.trim() : '';
266
+ const sanitized = sanitizeThinkingText(value);
267
+ return sanitized || null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ function sanitizeThinkingText(raw) {
274
+ if (!raw) {
275
+ return '';
276
+ }
277
+ const trimmed = raw.trim();
278
+ const prefixPattern = /^(pro thinking)\s*[•:\-–—]*\s*/i;
279
+ if (prefixPattern.test(trimmed)) {
280
+ return trimmed.replace(prefixPattern, '').trim();
281
+ }
282
+ return trimmed;
283
+ }
284
+ function buildThinkingStatusExpression() {
285
+ const selectors = [
286
+ 'span.loading-shimmer',
287
+ 'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
288
+ '[data-testid*="thinking"]',
289
+ '[data-testid*="reasoning"]',
290
+ '[role="status"]',
291
+ '[aria-live="polite"]',
292
+ ];
293
+ const keywords = ['pro thinking', 'thinking', 'reasoning', 'clarifying', 'planning', 'drafting', 'summarizing'];
294
+ const selectorLiteral = JSON.stringify(selectors);
295
+ const keywordsLiteral = JSON.stringify(keywords);
296
+ return `(() => {
297
+ const selectors = ${selectorLiteral};
298
+ const keywords = ${keywordsLiteral};
299
+ const nodes = new Set();
300
+ for (const selector of selectors) {
301
+ document.querySelectorAll(selector).forEach((node) => nodes.add(node));
302
+ }
303
+ document.querySelectorAll('[data-testid]').forEach((node) => nodes.add(node));
304
+ for (const node of nodes) {
305
+ if (!(node instanceof HTMLElement)) {
306
+ continue;
307
+ }
308
+ const text = node.textContent?.trim();
309
+ if (!text) {
310
+ continue;
311
+ }
312
+ const classLabel = (node.className || '').toLowerCase();
313
+ const dataLabel = ((node.getAttribute('data-testid') || '') + ' ' + (node.getAttribute('aria-label') || ''))
314
+ .toLowerCase();
315
+ const normalizedText = text.toLowerCase();
316
+ const matches = keywords.some((keyword) =>
317
+ normalizedText.includes(keyword) || classLabel.includes(keyword) || dataLabel.includes(keyword)
318
+ );
319
+ if (matches) {
320
+ const shimmerChild = node.querySelector(
321
+ 'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
322
+ );
323
+ if (shimmerChild?.textContent?.trim()) {
324
+ return shimmerChild.textContent.trim();
325
+ }
326
+ return text.trim();
327
+ }
328
+ }
329
+ return null;
330
+ })()`;
331
+ }
@@ -0,0 +1,5 @@
1
+ export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady } from './actions/navigation.js';
2
+ export { ensureModelSelection } from './actions/modelSelection.js';
3
+ export { submitPrompt } from './actions/promptComposer.js';
4
+ export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
5
+ export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readFiles, createFileSections, DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from '../oracle.js';
5
+ export async function assembleBrowserPrompt(runOptions, deps = {}) {
6
+ const cwd = deps.cwd ?? process.cwd();
7
+ const readFilesFn = deps.readFilesImpl ?? readFiles;
8
+ const files = await readFilesFn(runOptions.file ?? [], { cwd });
9
+ const basePrompt = (runOptions.prompt ?? '').trim();
10
+ const userPrompt = basePrompt;
11
+ const systemPrompt = runOptions.system?.trim() || DEFAULT_SYSTEM_PROMPT;
12
+ const sections = createFileSections(files, cwd);
13
+ const lines = ['[SYSTEM]', systemPrompt, '', '[USER]', userPrompt, ''];
14
+ sections.forEach((section) => {
15
+ lines.push(`[FILE: ${section.displayPath}]`, section.content.trimEnd(), '');
16
+ });
17
+ const markdown = lines.join('\n').trimEnd();
18
+ const inlineFiles = Boolean(runOptions.browserInlineFiles);
19
+ const composerSections = [];
20
+ if (systemPrompt) {
21
+ composerSections.push(systemPrompt);
22
+ }
23
+ if (userPrompt) {
24
+ composerSections.push(userPrompt);
25
+ }
26
+ let inlineBlock = '';
27
+ if (inlineFiles && sections.length > 0) {
28
+ const inlineLines = [];
29
+ sections.forEach((section) => {
30
+ inlineLines.push(`[FILE: ${section.displayPath}]`, section.content.trimEnd(), '');
31
+ });
32
+ inlineBlock = inlineLines.join('\n').trim();
33
+ if (inlineBlock.length > 0) {
34
+ composerSections.push(inlineBlock);
35
+ }
36
+ }
37
+ const composerText = composerSections.join('\n\n').trim();
38
+ const attachments = inlineFiles
39
+ ? []
40
+ : sections.map((section) => ({
41
+ path: section.absolutePath,
42
+ displayPath: section.displayPath,
43
+ sizeBytes: Buffer.byteLength(section.content, 'utf8'),
44
+ }));
45
+ const MAX_BROWSER_ATTACHMENTS = 10;
46
+ if (!inlineFiles && attachments.length > MAX_BROWSER_ATTACHMENTS) {
47
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
48
+ const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
49
+ const bundleLines = [];
50
+ sections.forEach((section) => {
51
+ bundleLines.push(`### File: ${section.displayPath}`);
52
+ bundleLines.push(section.content.trimEnd());
53
+ bundleLines.push('');
54
+ });
55
+ const bundleText = `${bundleLines.join('\n').trimEnd()}\n`;
56
+ await fs.writeFile(bundlePath, bundleText, 'utf8');
57
+ attachments.length = 0;
58
+ attachments.push({
59
+ path: bundlePath,
60
+ displayPath: 'attachments-bundle.txt',
61
+ sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
62
+ });
63
+ }
64
+ const inlineFileCount = inlineFiles ? sections.length : 0;
65
+ const tokenizer = MODEL_CONFIGS[runOptions.model].tokenizer;
66
+ const tokenizerUserContent = inlineFileCount > 0 && inlineBlock
67
+ ? [userPrompt, inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
68
+ : userPrompt;
69
+ const tokenizerMessages = [
70
+ systemPrompt ? { role: 'system', content: systemPrompt } : null,
71
+ tokenizerUserContent ? { role: 'user', content: tokenizerUserContent } : null,
72
+ ].filter(Boolean);
73
+ const estimatedInputTokens = tokenizer(tokenizerMessages.length > 0
74
+ ? tokenizerMessages
75
+ : [{ role: 'user', content: '' }], TOKENIZER_OPTIONS);
76
+ const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(inlineBlock);
77
+ return {
78
+ markdown,
79
+ composerText,
80
+ estimatedInputTokens,
81
+ attachments,
82
+ inlineFileCount,
83
+ tokenEstimateIncludesInlineFiles,
84
+ bundled: !inlineFiles && attachments.length === 1 && sections.length > MAX_BROWSER_ATTACHMENTS && attachments[0]?.displayPath
85
+ ? { originalCount: sections.length, bundlePath: attachments[0].displayPath }
86
+ : null,
87
+ };
88
+ }