@steipete/oracle 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/browser/actions/assistantResponse.js +53 -33
- package/dist/src/browser/actions/attachments.js +276 -133
- package/dist/src/browser/actions/modelSelection.js +33 -2
- package/dist/src/browser/actions/promptComposer.js +38 -45
- package/dist/src/browser/chromeLifecycle.js +2 -0
- package/dist/src/browser/config.js +7 -2
- package/dist/src/browser/index.js +12 -2
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/reattach.js +192 -17
- package/dist/src/browser/utils.js +10 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +11 -6
- package/dist/src/cli/notifier.js +8 -2
- package/dist/src/cli/oscUtils.js +1 -19
- package/dist/src/cli/sessionDisplay.js +6 -3
- package/dist/src/cli/sessionTable.js +5 -1
- package/dist/src/oracle/files.js +8 -1
- package/dist/src/oracle/modelResolver.js +11 -4
- package/dist/src/oracle/multiModelRunner.js +3 -14
- package/dist/src/oracle/oscProgress.js +12 -61
- package/dist/src/oracle/run.js +62 -34
- package/dist/src/sessionManager.js +91 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/package.json +43 -26
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +0 -0
|
@@ -135,7 +135,6 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
135
135
|
logger('Clicked send button');
|
|
136
136
|
}
|
|
137
137
|
await verifyPromptCommitted(runtime, prompt, 30_000, logger);
|
|
138
|
-
await clickAnswerNowIfPresent(runtime, logger);
|
|
139
138
|
}
|
|
140
139
|
export async function clearPromptComposer(Runtime, logger) {
|
|
141
140
|
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
@@ -186,6 +185,43 @@ async function waitForDomReady(Runtime, logger) {
|
|
|
186
185
|
}
|
|
187
186
|
logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
|
|
188
187
|
}
|
|
188
|
+
function buildAttachmentReadyExpression(attachmentNames) {
|
|
189
|
+
const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
|
|
190
|
+
return `(() => {
|
|
191
|
+
const names = ${namesLiteral};
|
|
192
|
+
const composer =
|
|
193
|
+
document.querySelector('[data-testid*="composer"]') ||
|
|
194
|
+
document.querySelector('form') ||
|
|
195
|
+
document.body ||
|
|
196
|
+
document;
|
|
197
|
+
const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
|
|
198
|
+
|
|
199
|
+
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
200
|
+
const attachmentSelectors = [
|
|
201
|
+
'[data-testid*="chip"]',
|
|
202
|
+
'[data-testid*="attachment"]',
|
|
203
|
+
'[data-testid*="upload"]',
|
|
204
|
+
'[aria-label="Remove file"]',
|
|
205
|
+
'button[aria-label="Remove file"]',
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const chipsReady = names.every((name) =>
|
|
209
|
+
Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
|
|
210
|
+
);
|
|
211
|
+
const inputsReady = names.every((name) =>
|
|
212
|
+
Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
|
|
213
|
+
Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
|
|
214
|
+
file?.name?.toLowerCase?.().includes(name),
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return chipsReady || inputsReady;
|
|
220
|
+
})()`;
|
|
221
|
+
}
|
|
222
|
+
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
223
|
+
return buildAttachmentReadyExpression(attachmentNames);
|
|
224
|
+
}
|
|
189
225
|
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
190
226
|
const script = `(() => {
|
|
191
227
|
${buildClickDispatcher()}
|
|
@@ -215,19 +251,7 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
215
251
|
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
216
252
|
if (needAttachment) {
|
|
217
253
|
const ready = await Runtime.evaluate({
|
|
218
|
-
expression:
|
|
219
|
-
const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
|
|
220
|
-
const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
|
|
221
|
-
const chipsReady = names.every((name) =>
|
|
222
|
-
Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
|
|
223
|
-
);
|
|
224
|
-
const inputsReady = names.every((name) =>
|
|
225
|
-
Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
|
|
226
|
-
Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
|
|
227
|
-
),
|
|
228
|
-
);
|
|
229
|
-
return chipsReady || inputsReady;
|
|
230
|
-
})()`,
|
|
254
|
+
expression: buildAttachmentReadyExpression(attachmentNames),
|
|
231
255
|
returnByValue: true,
|
|
232
256
|
});
|
|
233
257
|
if (!ready?.result?.value) {
|
|
@@ -246,37 +270,6 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
|
246
270
|
}
|
|
247
271
|
return false;
|
|
248
272
|
}
|
|
249
|
-
async function clickAnswerNowIfPresent(Runtime, logger) {
|
|
250
|
-
const script = `(() => {
|
|
251
|
-
${buildClickDispatcher()}
|
|
252
|
-
const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
|
|
253
|
-
const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
|
|
254
|
-
if (!candidate) return 'missing';
|
|
255
|
-
const button = candidate.closest('button') ?? candidate;
|
|
256
|
-
const style = window.getComputedStyle(button);
|
|
257
|
-
const disabled =
|
|
258
|
-
button.hasAttribute('disabled') ||
|
|
259
|
-
button.getAttribute('aria-disabled') === 'true' ||
|
|
260
|
-
style.pointerEvents === 'none' ||
|
|
261
|
-
style.display === 'none';
|
|
262
|
-
if (disabled) return 'disabled';
|
|
263
|
-
dispatchClickSequence(button);
|
|
264
|
-
return 'clicked';
|
|
265
|
-
})()`;
|
|
266
|
-
const deadline = Date.now() + 3_000;
|
|
267
|
-
while (Date.now() < deadline) {
|
|
268
|
-
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
269
|
-
const status = result.value;
|
|
270
|
-
if (status === 'clicked') {
|
|
271
|
-
logger?.('Clicked "Answer now" gate');
|
|
272
|
-
await delay(500);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (status === 'missing')
|
|
276
|
-
return;
|
|
277
|
-
await delay(100);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
273
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
|
|
281
274
|
const deadline = Date.now() + timeoutMs;
|
|
282
275
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
@@ -25,6 +25,7 @@ export async function launchChrome(config, userDataDir, logger) {
|
|
|
25
25
|
chromePath: config.chromePath ?? undefined,
|
|
26
26
|
chromeFlags,
|
|
27
27
|
userDataDir,
|
|
28
|
+
handleSIGINT: false,
|
|
28
29
|
port: debugPort ?? undefined,
|
|
29
30
|
});
|
|
30
31
|
const pidLabel = typeof launcher.pid === 'number' ? ` (pid ${launcher.pid})` : '';
|
|
@@ -216,6 +217,7 @@ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host
|
|
|
216
217
|
chromePath: chromePath ?? undefined,
|
|
217
218
|
chromeFlags,
|
|
218
219
|
userDataDir,
|
|
220
|
+
handleSIGINT: false,
|
|
219
221
|
port: requestedPort ?? undefined,
|
|
220
222
|
});
|
|
221
223
|
if (host) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
2
|
-
import { normalizeChatgptUrl } from './utils.js';
|
|
2
|
+
import { isTemporaryChatUrl, normalizeChatgptUrl } from './utils.js';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
export const DEFAULT_BROWSER_CONFIG = {
|
|
@@ -32,6 +32,11 @@ export function resolveBrowserConfig(config) {
|
|
|
32
32
|
(process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? '').trim() === '1';
|
|
33
33
|
const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
|
|
34
34
|
const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
|
|
35
|
+
const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
|
|
36
|
+
if (isTemporaryChatUrl(normalizedUrl) && /\bpro\b/i.test(desiredModel)) {
|
|
37
|
+
throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
|
|
38
|
+
'Remove "temporary-chat=true" from your browser URL, or use a non-Pro model label (e.g. "GPT-5.2").');
|
|
39
|
+
}
|
|
35
40
|
const isWindows = process.platform === 'win32';
|
|
36
41
|
const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
|
|
37
42
|
const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
|
|
@@ -53,7 +58,7 @@ export function resolveBrowserConfig(config) {
|
|
|
53
58
|
headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
|
|
54
59
|
keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
|
|
55
60
|
hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
|
|
56
|
-
desiredModel
|
|
61
|
+
desiredModel,
|
|
57
62
|
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
|
|
58
63
|
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
59
64
|
chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
|
|
@@ -5,7 +5,7 @@ import net from 'node:net';
|
|
|
5
5
|
import { resolveBrowserConfig } from './config.js';
|
|
6
6
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
|
|
7
7
|
import { syncCookies } from './cookies.js';
|
|
8
|
-
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
|
|
8
|
+
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
9
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
10
10
|
import { ensureExtendedThinking } from './actions/thinkingTime.js';
|
|
11
11
|
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
@@ -14,7 +14,7 @@ import { CHATGPT_URL } from './constants.js';
|
|
|
14
14
|
import { BrowserAutomationError } from '../oracle/errors.js';
|
|
15
15
|
import { cleanupStaleProfileState, readChromePid, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
|
|
16
16
|
export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
17
|
-
export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
|
|
17
|
+
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
|
|
18
18
|
export async function runBrowserMode(options) {
|
|
19
19
|
const promptText = options.prompt?.trim();
|
|
20
20
|
if (!promptText) {
|
|
@@ -37,12 +37,14 @@ export async function runBrowserMode(options) {
|
|
|
37
37
|
if (!runtimeHintCb || !chrome?.port) {
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
|
+
const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
|
|
40
41
|
const hint = {
|
|
41
42
|
chromePid: chrome.pid,
|
|
42
43
|
chromePort: chrome.port,
|
|
43
44
|
chromeHost,
|
|
44
45
|
chromeTargetId: lastTargetId,
|
|
45
46
|
tabUrl: lastUrl,
|
|
47
|
+
conversationId,
|
|
46
48
|
userDataDir,
|
|
47
49
|
controllerPid: process.pid,
|
|
48
50
|
};
|
|
@@ -293,6 +295,10 @@ export async function runBrowserMode(options) {
|
|
|
293
295
|
logger('All attachments uploaded');
|
|
294
296
|
}
|
|
295
297
|
await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, prompt, logger);
|
|
298
|
+
if (attachmentNames.length > 0) {
|
|
299
|
+
await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
|
|
300
|
+
logger('Verified attachments present on sent user message');
|
|
301
|
+
}
|
|
296
302
|
};
|
|
297
303
|
try {
|
|
298
304
|
await raceWithDisconnect(submitOnce(promptText, attachments));
|
|
@@ -943,6 +949,10 @@ function isWsl() {
|
|
|
943
949
|
return true;
|
|
944
950
|
return os.release().toLowerCase().includes('microsoft');
|
|
945
951
|
}
|
|
952
|
+
function extractConversationIdFromUrl(url) {
|
|
953
|
+
const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
|
|
954
|
+
return match?.[1];
|
|
955
|
+
}
|
|
946
956
|
async function resolveUserDataBaseDir() {
|
|
947
957
|
// On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
|
|
948
958
|
if (isWsl()) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
|
|
2
2
|
export { ensureModelSelection } from './actions/modelSelection.js';
|
|
3
3
|
export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
|
|
4
|
-
export { uploadAttachmentFile, waitForAttachmentCompletion } from './actions/attachments.js';
|
|
4
|
+
export { uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments } from './actions/attachments.js';
|
|
5
5
|
export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, } from './actions/assistantResponse.js';
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import CDP from 'chrome-remote-interface';
|
|
2
|
-
import
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from './pageActions.js';
|
|
6
|
+
import { launchChrome, connectToChrome, hideChromeWindow } from './chromeLifecycle.js';
|
|
7
|
+
import { resolveBrowserConfig } from './config.js';
|
|
8
|
+
import { syncCookies } from './cookies.js';
|
|
9
|
+
import { CHATGPT_URL } from './constants.js';
|
|
10
|
+
import { delay } from './utils.js';
|
|
3
11
|
function pickTarget(targets, runtime) {
|
|
4
12
|
if (!Array.isArray(targets) || targets.length === 0) {
|
|
5
13
|
return undefined;
|
|
@@ -18,33 +26,114 @@ function pickTarget(targets, runtime) {
|
|
|
18
26
|
return targets.find((t) => t.type === 'page') ?? targets[0];
|
|
19
27
|
}
|
|
20
28
|
export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
|
|
29
|
+
const recoverSession = deps.recoverSession ??
|
|
30
|
+
(async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
|
|
21
31
|
if (!runtime.chromePort) {
|
|
22
|
-
|
|
32
|
+
logger('No running Chrome detected; reopening browser to locate the session.');
|
|
33
|
+
return recoverSession(runtime, config);
|
|
23
34
|
}
|
|
24
35
|
const host = runtime.chromeHost ?? '127.0.0.1';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
try {
|
|
37
|
+
const listTargets = deps.listTargets ??
|
|
38
|
+
(async () => {
|
|
39
|
+
const targets = await CDP.List({ host, port: runtime.chromePort });
|
|
40
|
+
return targets;
|
|
41
|
+
});
|
|
42
|
+
const connect = deps.connect ?? ((options) => CDP(options));
|
|
43
|
+
const targetList = (await listTargets());
|
|
44
|
+
const target = pickTarget(targetList, runtime);
|
|
45
|
+
const client = (await connect({
|
|
46
|
+
host,
|
|
47
|
+
port: runtime.chromePort,
|
|
48
|
+
target: target?.targetId,
|
|
49
|
+
}));
|
|
50
|
+
const { Runtime, DOM } = client;
|
|
51
|
+
if (Runtime?.enable) {
|
|
52
|
+
await Runtime.enable();
|
|
53
|
+
}
|
|
54
|
+
if (DOM && typeof DOM.enable === 'function') {
|
|
55
|
+
await DOM.enable();
|
|
56
|
+
}
|
|
57
|
+
const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
|
|
58
|
+
const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
|
|
59
|
+
const timeoutMs = config?.timeoutMs ?? 120_000;
|
|
60
|
+
const answer = await waitForResponse(Runtime, timeoutMs, logger);
|
|
61
|
+
const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
|
|
62
|
+
if (client && typeof client.close === 'function') {
|
|
63
|
+
try {
|
|
64
|
+
await client.close();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { answerText: answer.text, answerMarkdown: markdown };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
|
|
75
|
+
return recoverSession(runtime, config);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
|
|
79
|
+
const resolved = resolveBrowserConfig(config ?? {});
|
|
80
|
+
const manualLogin = Boolean(resolved.manualLogin);
|
|
81
|
+
const userDataDir = manualLogin
|
|
82
|
+
? resolved.manualLoginProfileDir ?? path.join(os.homedir(), '.oracle', 'browser-profile')
|
|
83
|
+
: await mkdtemp(path.join(os.tmpdir(), 'oracle-reattach-'));
|
|
84
|
+
if (manualLogin) {
|
|
85
|
+
await mkdir(userDataDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
const chrome = await launchChrome(resolved, userDataDir, logger);
|
|
88
|
+
const chromeHost = chrome.host ?? '127.0.0.1';
|
|
89
|
+
const client = await connectToChrome(chrome.port, logger, chromeHost);
|
|
90
|
+
const { Network, Page, Runtime, DOM } = client;
|
|
39
91
|
if (Runtime?.enable) {
|
|
40
92
|
await Runtime.enable();
|
|
41
93
|
}
|
|
42
94
|
if (DOM && typeof DOM.enable === 'function') {
|
|
43
95
|
await DOM.enable();
|
|
44
96
|
}
|
|
97
|
+
if (!resolved.headless && resolved.hideWindow) {
|
|
98
|
+
await hideChromeWindow(chrome, logger);
|
|
99
|
+
}
|
|
100
|
+
let appliedCookies = 0;
|
|
101
|
+
if (!manualLogin && resolved.cookieSync) {
|
|
102
|
+
appliedCookies = await syncCookies(Network, resolved.url, resolved.chromeProfile, logger, {
|
|
103
|
+
allowErrors: resolved.allowCookieErrors,
|
|
104
|
+
filterNames: resolved.cookieNames ?? undefined,
|
|
105
|
+
inlineCookies: resolved.inlineCookies ?? undefined,
|
|
106
|
+
cookiePath: resolved.chromeCookiePath ?? undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
|
|
110
|
+
await ensureNotBlocked(Runtime, resolved.headless, logger);
|
|
111
|
+
await ensureLoggedIn(Runtime, logger, { appliedCookies });
|
|
112
|
+
if (resolved.url !== CHATGPT_URL) {
|
|
113
|
+
await navigateToChatGPT(Page, Runtime, resolved.url, logger);
|
|
114
|
+
await ensureNotBlocked(Runtime, resolved.headless, logger);
|
|
115
|
+
}
|
|
116
|
+
await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
|
|
117
|
+
const conversationUrl = buildConversationUrl(runtime, resolved.url);
|
|
118
|
+
if (conversationUrl) {
|
|
119
|
+
logger(`Reopening conversation at ${conversationUrl}`);
|
|
120
|
+
await navigateToChatGPT(Page, Runtime, conversationUrl, logger);
|
|
121
|
+
await ensureNotBlocked(Runtime, resolved.headless, logger);
|
|
122
|
+
await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const opened = await openConversationFromSidebar(Runtime, {
|
|
126
|
+
conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ''),
|
|
127
|
+
preferProjects: resolved.url !== CHATGPT_URL,
|
|
128
|
+
});
|
|
129
|
+
if (!opened) {
|
|
130
|
+
throw new Error('Unable to locate prior ChatGPT conversation in sidebar.');
|
|
131
|
+
}
|
|
132
|
+
await waitForLocationChange(Runtime, 15_000);
|
|
133
|
+
}
|
|
45
134
|
const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
|
|
46
135
|
const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
|
|
47
|
-
const timeoutMs =
|
|
136
|
+
const timeoutMs = resolved.timeoutMs ?? 120_000;
|
|
48
137
|
const answer = await waitForResponse(Runtime, timeoutMs, logger);
|
|
49
138
|
const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
|
|
50
139
|
if (client && typeof client.close === 'function') {
|
|
@@ -55,5 +144,91 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
|
|
|
55
144
|
// ignore
|
|
56
145
|
}
|
|
57
146
|
}
|
|
147
|
+
if (!resolved.keepBrowser && !manualLogin) {
|
|
148
|
+
try {
|
|
149
|
+
await chrome.kill();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
155
|
+
}
|
|
58
156
|
return { answerText: answer.text, answerMarkdown: markdown };
|
|
59
157
|
}
|
|
158
|
+
function extractConversationIdFromUrl(url) {
|
|
159
|
+
if (!url)
|
|
160
|
+
return undefined;
|
|
161
|
+
const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
|
|
162
|
+
return match?.[1];
|
|
163
|
+
}
|
|
164
|
+
function buildConversationUrl(runtime, baseUrl) {
|
|
165
|
+
if (runtime.tabUrl) {
|
|
166
|
+
return runtime.tabUrl;
|
|
167
|
+
}
|
|
168
|
+
const conversationId = runtime.conversationId;
|
|
169
|
+
if (!conversationId) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const base = new URL(baseUrl);
|
|
174
|
+
return `${base.origin}/c/${conversationId}`;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function openConversationFromSidebar(Runtime, options) {
|
|
181
|
+
const response = await Runtime.evaluate({
|
|
182
|
+
expression: `(() => {
|
|
183
|
+
const conversationId = ${JSON.stringify(options.conversationId ?? null)};
|
|
184
|
+
const preferProjects = ${JSON.stringify(Boolean(options.preferProjects))};
|
|
185
|
+
const nav = document.querySelector('nav') || document.querySelector('aside') || document.body;
|
|
186
|
+
if (preferProjects) {
|
|
187
|
+
const projectLink = Array.from(nav.querySelectorAll('a,button'))
|
|
188
|
+
.find((el) => (el.textContent || '').trim().toLowerCase() === 'projects');
|
|
189
|
+
if (projectLink) {
|
|
190
|
+
projectLink.click();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const links = Array.from(nav.querySelectorAll('a[href]'))
|
|
194
|
+
.filter((el) => el instanceof HTMLAnchorElement)
|
|
195
|
+
.map((el) => el);
|
|
196
|
+
const convoLinks = links.filter((el) => el.href.includes('/c/'));
|
|
197
|
+
let target = null;
|
|
198
|
+
if (conversationId) {
|
|
199
|
+
target = convoLinks.find((el) => el.href.includes('/c/' + conversationId));
|
|
200
|
+
}
|
|
201
|
+
if (!target && convoLinks.length > 0) {
|
|
202
|
+
target = convoLinks[0];
|
|
203
|
+
}
|
|
204
|
+
if (target) {
|
|
205
|
+
target.scrollIntoView({ block: 'center' });
|
|
206
|
+
target.click();
|
|
207
|
+
return { ok: true, href: target.href, count: convoLinks.length };
|
|
208
|
+
}
|
|
209
|
+
return { ok: false, count: convoLinks.length };
|
|
210
|
+
})()`,
|
|
211
|
+
returnByValue: true,
|
|
212
|
+
});
|
|
213
|
+
return Boolean(response.result?.value?.ok);
|
|
214
|
+
}
|
|
215
|
+
async function waitForLocationChange(Runtime, timeoutMs) {
|
|
216
|
+
const start = Date.now();
|
|
217
|
+
let lastHref = '';
|
|
218
|
+
while (Date.now() - start < timeoutMs) {
|
|
219
|
+
const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
220
|
+
const href = typeof result?.value === 'string' ? result.value : '';
|
|
221
|
+
if (lastHref && href !== lastHref) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
lastHref = href;
|
|
225
|
+
await delay(200);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
229
|
+
export const __test__ = {
|
|
230
|
+
pickTarget,
|
|
231
|
+
extractConversationIdFromUrl,
|
|
232
|
+
buildConversationUrl,
|
|
233
|
+
openConversationFromSidebar,
|
|
234
|
+
};
|
|
@@ -110,3 +110,13 @@ export function normalizeChatgptUrl(raw, fallback) {
|
|
|
110
110
|
// Preserve user-provided path/query; URL#toString will normalize trailing slashes appropriately.
|
|
111
111
|
return parsed.toString();
|
|
112
112
|
}
|
|
113
|
+
export function isTemporaryChatUrl(url) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
|
|
117
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/src/browserMode.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, } from './browser/index.js';
|
|
1
|
+
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
3
|
+
import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
4
4
|
import { getOracleHomeDir } from '../oracleHome.js';
|
|
5
5
|
const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
|
|
6
6
|
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
|
|
@@ -54,6 +54,15 @@ export async function buildBrowserConfig(options) {
|
|
|
54
54
|
}
|
|
55
55
|
const rawUrl = options.chatgptUrl ?? options.browserUrl;
|
|
56
56
|
const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;
|
|
57
|
+
const desiredModel = isChatGptModel
|
|
58
|
+
? mapModelToBrowserLabel(options.model)
|
|
59
|
+
: shouldUseOverride
|
|
60
|
+
? desiredModelOverride
|
|
61
|
+
: mapModelToBrowserLabel(options.model);
|
|
62
|
+
if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
|
|
63
|
+
throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
|
|
64
|
+
'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
|
|
65
|
+
}
|
|
57
66
|
return {
|
|
58
67
|
chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
|
|
59
68
|
chromePath: options.browserChromePath ?? null,
|
|
@@ -72,11 +81,7 @@ export async function buildBrowserConfig(options) {
|
|
|
72
81
|
keepBrowser: options.browserKeepBrowser ? true : undefined,
|
|
73
82
|
manualLogin: options.browserManualLogin ? true : undefined,
|
|
74
83
|
hideWindow: options.browserHideWindow ? true : undefined,
|
|
75
|
-
desiredModel
|
|
76
|
-
? mapModelToBrowserLabel(options.model)
|
|
77
|
-
: shouldUseOverride
|
|
78
|
-
? desiredModelOverride
|
|
79
|
-
: mapModelToBrowserLabel(options.model),
|
|
84
|
+
desiredModel,
|
|
80
85
|
debug: options.verbose ? true : undefined,
|
|
81
86
|
// Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
|
|
82
87
|
allowCookieErrors: options.browserAllowCookieErrors ?? true,
|
package/dist/src/cli/notifier.js
CHANGED
|
@@ -2,6 +2,7 @@ import notifier from 'toasted-notifier';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { formatUSD, formatNumber } from '../oracle/format.js';
|
|
4
4
|
import { MODEL_CONFIGS } from '../oracle/config.js';
|
|
5
|
+
import { estimateUsdCost } from 'tokentally';
|
|
5
6
|
import fs from 'node:fs/promises';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
@@ -131,8 +132,13 @@ function inferCost(payload) {
|
|
|
131
132
|
const config = MODEL_CONFIGS[model];
|
|
132
133
|
if (!config?.pricing)
|
|
133
134
|
return undefined;
|
|
134
|
-
return (
|
|
135
|
-
usage.outputTokens
|
|
135
|
+
return (estimateUsdCost({
|
|
136
|
+
usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens },
|
|
137
|
+
pricing: {
|
|
138
|
+
inputUsdPerToken: config.pricing.inputPerToken,
|
|
139
|
+
outputUsdPerToken: config.pricing.outputPerToken,
|
|
140
|
+
},
|
|
141
|
+
})?.totalUsd ?? undefined);
|
|
136
142
|
}
|
|
137
143
|
function parseToggle(value) {
|
|
138
144
|
if (value == null)
|
package/dist/src/cli/oscUtils.js
CHANGED
|
@@ -1,20 +1,2 @@
|
|
|
1
1
|
// Utilities for handling OSC progress codes embedded in stored logs.
|
|
2
|
-
|
|
3
|
-
const OSC_END = '\u001b\\';
|
|
4
|
-
/**
|
|
5
|
-
* Optionally removes OSC 9;4 progress sequences (used by Ghostty/WezTerm to show progress bars).
|
|
6
|
-
* Keep them when replaying to a real TTY; strip when piping to non-TTY outputs.
|
|
7
|
-
*/
|
|
8
|
-
export function sanitizeOscProgress(text, keepOsc) {
|
|
9
|
-
if (keepOsc) {
|
|
10
|
-
return text;
|
|
11
|
-
}
|
|
12
|
-
let current = text;
|
|
13
|
-
while (current.includes(OSC_PROGRESS_PREFIX)) {
|
|
14
|
-
const start = current.indexOf(OSC_PROGRESS_PREFIX);
|
|
15
|
-
const end = current.indexOf(OSC_END, start + OSC_PROGRESS_PREFIX.length);
|
|
16
|
-
const cutEnd = end === -1 ? start + OSC_PROGRESS_PREFIX.length : end + OSC_END.length;
|
|
17
|
-
current = `${current.slice(0, start)}${current.slice(cutEnd)}`;
|
|
18
|
-
}
|
|
19
|
-
return current;
|
|
20
|
-
}
|
|
2
|
+
export { sanitizeOscProgress } from 'osc-progress';
|
|
@@ -76,10 +76,13 @@ export async function attachSession(sessionId, options) {
|
|
|
76
76
|
const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
|
|
77
77
|
const runtime = metadata.browser?.runtime;
|
|
78
78
|
const controllerAlive = isProcessAlive(runtime?.controllerPid);
|
|
79
|
-
const
|
|
79
|
+
const hasChromeDisconnect = metadata.response?.incompleteReason === 'chrome-disconnected';
|
|
80
|
+
const statusAllowsReattach = metadata.status === 'running' || (metadata.status === 'error' && hasChromeDisconnect);
|
|
81
|
+
const hasFallbackSessionInfo = Boolean(runtime?.chromePort || runtime?.tabUrl || runtime?.conversationId);
|
|
82
|
+
const canReattach = statusAllowsReattach &&
|
|
80
83
|
metadata.mode === 'browser' &&
|
|
81
|
-
|
|
82
|
-
(
|
|
84
|
+
hasFallbackSessionInfo &&
|
|
85
|
+
(hasChromeDisconnect || (runtime?.controllerPid && !controllerAlive));
|
|
83
86
|
if (canReattach) {
|
|
84
87
|
const portInfo = runtime?.chromePort ? `port ${runtime.chromePort}` : 'unknown port';
|
|
85
88
|
const urlInfo = runtime?.tabUrl ? `url=${runtime.tabUrl}` : 'url=unknown';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import kleur from 'kleur';
|
|
3
3
|
import { MODEL_CONFIGS } from '../oracle.js';
|
|
4
|
+
import { estimateUsdCost } from 'tokentally';
|
|
4
5
|
const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
|
|
5
6
|
const dim = (text, rich) => (rich ? kleur.dim(text) : text);
|
|
6
7
|
export const STATUS_PAD = 9;
|
|
@@ -48,7 +49,10 @@ export function resolveSessionCost(meta) {
|
|
|
48
49
|
}
|
|
49
50
|
const input = meta.usage.inputTokens ?? 0;
|
|
50
51
|
const output = meta.usage.outputTokens ?? 0;
|
|
51
|
-
const cost =
|
|
52
|
+
const cost = estimateUsdCost({
|
|
53
|
+
usage: { inputTokens: input, outputTokens: output },
|
|
54
|
+
pricing: { inputUsdPerToken: pricing.inputPerToken, outputUsdPerToken: pricing.outputPerToken },
|
|
55
|
+
})?.totalUsd ?? 0;
|
|
52
56
|
return cost > 0 ? cost : null;
|
|
53
57
|
}
|
|
54
58
|
export function formatTimestampAligned(iso) {
|
package/dist/src/oracle/files.js
CHANGED
|
@@ -13,7 +13,14 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
|
|
|
13
13
|
const useNativeFilesystem = fsModule === DEFAULT_FS || isNativeFsModule(fsModule);
|
|
14
14
|
let candidatePaths = [];
|
|
15
15
|
if (useNativeFilesystem) {
|
|
16
|
-
|
|
16
|
+
if (partitioned.globPatterns.length === 0 &&
|
|
17
|
+
partitioned.excludePatterns.length === 0 &&
|
|
18
|
+
partitioned.literalDirectories.length === 0) {
|
|
19
|
+
candidatePaths = Array.from(new Set(partitioned.literalFiles));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
candidatePaths = await expandWithNativeGlob(partitioned, cwd);
|
|
23
|
+
}
|
|
17
24
|
}
|
|
18
25
|
else {
|
|
19
26
|
if (partitioned.globPatterns.length > 0 || partitioned.excludePatterns.length > 0) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MODEL_CONFIGS, PRO_MODELS } from './config.js';
|
|
2
2
|
import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro';
|
|
3
|
+
import { pricingFromUsdPerMillion } from 'tokentally';
|
|
3
4
|
const OPENROUTER_DEFAULT_BASE = 'https://openrouter.ai/api/v1';
|
|
4
5
|
const OPENROUTER_MODELS_ENDPOINT = 'https://openrouter.ai/api/v1/models';
|
|
5
6
|
export function isKnownModel(model) {
|
|
@@ -96,10 +97,16 @@ export async function resolveModelConfig(model, options = {}) {
|
|
|
96
97
|
provider: known?.provider ?? 'other',
|
|
97
98
|
inputLimit: info.context_length ?? known?.inputLimit ?? 200_000,
|
|
98
99
|
pricing: info.pricing && info.pricing.prompt != null && info.pricing.completion != null
|
|
99
|
-
? {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
? (() => {
|
|
101
|
+
const pricing = pricingFromUsdPerMillion({
|
|
102
|
+
inputUsdPerMillion: info.pricing.prompt,
|
|
103
|
+
outputUsdPerMillion: info.pricing.completion,
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
inputPerToken: pricing.inputUsdPerToken,
|
|
107
|
+
outputPerToken: pricing.outputUsdPerToken,
|
|
108
|
+
};
|
|
109
|
+
})()
|
|
103
110
|
: known?.pricing ?? null,
|
|
104
111
|
supportsBackground: known?.supportsBackground ?? true,
|
|
105
112
|
supportsSearch: known?.supportsSearch ?? true,
|