@steipete/oracle 0.5.0 → 0.5.2
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/.DS_Store +0 -0
- package/dist/src/browser/actions/assistantResponse.js +7 -3
- package/dist/src/browser/actions/attachments.js +24 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +6 -9
- package/dist/src/browser/actions/promptComposer.js +43 -10
- package/dist/src/browser/actions/remoteFileTransfer.js +2 -0
- package/dist/src/browser/chromeCookies.js +8 -1
- package/dist/src/browser/index.js +83 -1
- package/dist/src/browser/profileSync.js +141 -0
- package/dist/src/cli/browserDefaults.js +15 -11
- package/dist/src/cli/sessionDisplay.js +4 -99
- package/dist/src/cli/sessionTable.js +88 -0
- package/dist/src/cli/tui/index.js +15 -78
- package/dist/src/oracle/client.js +93 -3
- package/dist/src/oracle/run.js +2 -1
- package/package.json +15 -16
package/dist/.DS_Store
CHANGED
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from '../constants.js';
|
|
2
2
|
import { delay } from '../utils.js';
|
|
3
3
|
import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression } from '../domDebug.js';
|
|
4
|
+
import { buildClickDispatcher } from './domEvents.js';
|
|
4
5
|
const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
|
|
5
6
|
export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
|
|
6
7
|
logger('Waiting for ChatGPT response');
|
|
@@ -257,6 +258,7 @@ function buildAssistantSnapshotExpression() {
|
|
|
257
258
|
function buildResponseObserverExpression(timeoutMs) {
|
|
258
259
|
const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
|
|
259
260
|
return `(() => {
|
|
261
|
+
${buildClickDispatcher()}
|
|
260
262
|
const SELECTORS = ${selectorsLiteral};
|
|
261
263
|
const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
|
|
262
264
|
const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
|
|
@@ -293,7 +295,7 @@ function buildResponseObserverExpression(timeoutMs) {
|
|
|
293
295
|
if (ariaLabel.toLowerCase().includes('stop')) {
|
|
294
296
|
return;
|
|
295
297
|
}
|
|
296
|
-
stop
|
|
298
|
+
dispatchClickSequence(stop);
|
|
297
299
|
}, 500);
|
|
298
300
|
setTimeout(() => {
|
|
299
301
|
if (stopInterval) {
|
|
@@ -340,6 +342,7 @@ function buildAssistantExtractor(functionName) {
|
|
|
340
342
|
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
341
343
|
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
342
344
|
return `const ${functionName} = () => {
|
|
345
|
+
${buildClickDispatcher()}
|
|
343
346
|
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
344
347
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
345
348
|
const isAssistantTurn = (node) => {
|
|
@@ -367,7 +370,7 @@ function buildAssistantExtractor(functionName) {
|
|
|
367
370
|
testid.includes('markdown') ||
|
|
368
371
|
testid.includes('toggle')
|
|
369
372
|
) {
|
|
370
|
-
button
|
|
373
|
+
dispatchClickSequence(button);
|
|
371
374
|
}
|
|
372
375
|
}
|
|
373
376
|
};
|
|
@@ -397,6 +400,7 @@ function buildAssistantExtractor(functionName) {
|
|
|
397
400
|
}
|
|
398
401
|
function buildCopyExpression(meta) {
|
|
399
402
|
return `(() => {
|
|
403
|
+
${buildClickDispatcher()}
|
|
400
404
|
const BUTTON_SELECTOR = '${COPY_BUTTON_SELECTOR}';
|
|
401
405
|
const TIMEOUT_MS = 5000;
|
|
402
406
|
|
|
@@ -498,7 +502,7 @@ function buildCopyExpression(meta) {
|
|
|
498
502
|
|
|
499
503
|
button.addEventListener('copy', handleCopy, true);
|
|
500
504
|
button.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
501
|
-
button
|
|
505
|
+
dispatchClickSequence(button);
|
|
502
506
|
pollId = setInterval(() => {
|
|
503
507
|
const payload = readIntercepted();
|
|
504
508
|
if (payload.success) {
|
|
@@ -28,6 +28,7 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
|
|
|
28
28
|
await logDomFailure(runtime, logger, 'file-upload');
|
|
29
29
|
throw new Error('Attachment did not register with the ChatGPT composer in time.');
|
|
30
30
|
}
|
|
31
|
+
await waitForAttachmentVisible(runtime, expectedName, 10_000, logger);
|
|
31
32
|
logger('Attachment queued');
|
|
32
33
|
}
|
|
33
34
|
export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
|
|
@@ -83,6 +84,29 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
|
|
|
83
84
|
await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
|
|
84
85
|
throw new Error('Attachments did not finish uploading before timeout.');
|
|
85
86
|
}
|
|
87
|
+
export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
|
|
88
|
+
const deadline = Date.now() + Math.min(timeoutMs, 2_000);
|
|
89
|
+
const expression = `(() => {
|
|
90
|
+
const expected = ${JSON.stringify(expectedName)};
|
|
91
|
+
const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
|
|
92
|
+
const userTurns = turns.filter((node) => node.querySelector('[data-message-author-role="user"]'));
|
|
93
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
94
|
+
if (!lastUser) return { found: false, userTurns: userTurns.length };
|
|
95
|
+
const chips = Array.from(lastUser.querySelectorAll('a, div')).some((el) => (el.textContent || '').includes(expected));
|
|
96
|
+
return { found: chips, userTurns: userTurns.length };
|
|
97
|
+
})()`;
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
100
|
+
const value = result?.value;
|
|
101
|
+
if (value?.found) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await delay(200);
|
|
105
|
+
}
|
|
106
|
+
logger?.('Attachment not visible in composer; giving up.');
|
|
107
|
+
await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
|
|
108
|
+
throw new Error('Attachment did not appear in ChatGPT composer.');
|
|
109
|
+
}
|
|
86
110
|
async function waitForAttachmentSelection(Runtime, expectedName, timeoutMs) {
|
|
87
111
|
const deadline = Date.now() + timeoutMs;
|
|
88
112
|
const expression = `(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const CLICK_TYPES = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
2
|
+
export function buildClickDispatcher(functionName = 'dispatchClickSequence') {
|
|
3
|
+
const typesLiteral = JSON.stringify(CLICK_TYPES);
|
|
4
|
+
return `function ${functionName}(target){
|
|
5
|
+
if(!target || !(target instanceof EventTarget)) return false;
|
|
6
|
+
const types = ${typesLiteral};
|
|
7
|
+
for (const type of types) {
|
|
8
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
9
|
+
let event;
|
|
10
|
+
if (type.startsWith('pointer') && 'PointerEvent' in window) {
|
|
11
|
+
event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
|
|
12
|
+
} else {
|
|
13
|
+
event = new MouseEvent(type, common);
|
|
14
|
+
}
|
|
15
|
+
target.dispatchEvent(event);
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}`;
|
|
19
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from '../constants.js';
|
|
2
2
|
import { logDomFailure } from '../domDebug.js';
|
|
3
|
+
import { buildClickDispatcher } from './domEvents.js';
|
|
3
4
|
export async function ensureModelSelection(Runtime, desiredModel, logger) {
|
|
4
5
|
const outcome = await Runtime.evaluate({
|
|
5
6
|
expression: buildModelSelectionExpression(desiredModel),
|
|
@@ -37,6 +38,7 @@ function buildModelSelectionExpression(targetModel) {
|
|
|
37
38
|
const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
|
|
38
39
|
const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
|
|
39
40
|
return `(() => {
|
|
41
|
+
${buildClickDispatcher()}
|
|
40
42
|
// Capture the selectors and matcher literals up front so the browser expression stays pure.
|
|
41
43
|
const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}';
|
|
42
44
|
const LABEL_TOKENS = ${labelLiteral};
|
|
@@ -69,14 +71,9 @@ function buildModelSelectionExpression(targetModel) {
|
|
|
69
71
|
|
|
70
72
|
let lastPointerClick = 0;
|
|
71
73
|
const pointerClick = () => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const click = new MouseEvent('click', { bubbles: true });
|
|
76
|
-
button.dispatchEvent(down);
|
|
77
|
-
button.dispatchEvent(up);
|
|
78
|
-
button.dispatchEvent(click);
|
|
79
|
-
lastPointerClick = performance.now();
|
|
74
|
+
if (dispatchClickSequence(button)) {
|
|
75
|
+
lastPointerClick = performance.now();
|
|
76
|
+
}
|
|
80
77
|
};
|
|
81
78
|
|
|
82
79
|
const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
|
|
@@ -188,7 +185,7 @@ function buildModelSelectionExpression(targetModel) {
|
|
|
188
185
|
resolve({ status: 'already-selected', label: match.label });
|
|
189
186
|
return;
|
|
190
187
|
}
|
|
191
|
-
match.node
|
|
188
|
+
dispatchClickSequence(match.node);
|
|
192
189
|
resolve({ status: 'switched', label: match.label });
|
|
193
190
|
return;
|
|
194
191
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, } from '../constants.js';
|
|
2
2
|
import { delay } from '../utils.js';
|
|
3
3
|
import { logDomFailure } from '../domDebug.js';
|
|
4
|
+
import { buildClickDispatcher } from './domEvents.js';
|
|
4
5
|
const ENTER_KEY_EVENT = {
|
|
5
6
|
key: 'Enter',
|
|
6
7
|
code: 'Enter',
|
|
@@ -13,20 +14,13 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
13
14
|
const encodedPrompt = JSON.stringify(prompt);
|
|
14
15
|
const focusResult = await runtime.evaluate({
|
|
15
16
|
expression: `(() => {
|
|
17
|
+
${buildClickDispatcher()}
|
|
16
18
|
const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
|
|
17
|
-
const dispatchPointer = (target) => {
|
|
18
|
-
if (!(target instanceof HTMLElement)) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
|
22
|
-
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
19
|
const focusNode = (node) => {
|
|
26
20
|
if (!node) {
|
|
27
21
|
return false;
|
|
28
22
|
}
|
|
29
|
-
|
|
23
|
+
dispatchClickSequence(node);
|
|
30
24
|
if (typeof node.focus === 'function') {
|
|
31
25
|
node.focus();
|
|
32
26
|
}
|
|
@@ -59,6 +53,9 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
59
53
|
throw new Error('Failed to focus prompt textarea');
|
|
60
54
|
}
|
|
61
55
|
await input.insertText({ text: prompt });
|
|
56
|
+
// Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
|
|
57
|
+
// before the send button becomes enabled; give it a short breather to avoid races.
|
|
58
|
+
await delay(500);
|
|
62
59
|
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
63
60
|
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
64
61
|
const verification = await runtime.evaluate({
|
|
@@ -86,6 +83,8 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
86
83
|
const editor = document.querySelector(${primarySelectorLiteral});
|
|
87
84
|
if (editor) {
|
|
88
85
|
editor.textContent = ${encodedPrompt};
|
|
86
|
+
// Nudge ProseMirror to register the textContent write so its state/send-button updates
|
|
87
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
|
|
89
88
|
}
|
|
90
89
|
})()`,
|
|
91
90
|
});
|
|
@@ -108,9 +107,11 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
108
107
|
logger('Clicked send button');
|
|
109
108
|
}
|
|
110
109
|
await verifyPromptCommitted(runtime, prompt, 30_000, logger);
|
|
110
|
+
await clickAnswerNowIfPresent(runtime, logger);
|
|
111
111
|
}
|
|
112
112
|
async function attemptSendButton(Runtime) {
|
|
113
113
|
const script = `(() => {
|
|
114
|
+
${buildClickDispatcher()}
|
|
114
115
|
const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
115
116
|
let button = null;
|
|
116
117
|
for (const selector of selectors) {
|
|
@@ -128,7 +129,8 @@ async function attemptSendButton(Runtime) {
|
|
|
128
129
|
style.pointerEvents === 'none' ||
|
|
129
130
|
style.display === 'none';
|
|
130
131
|
if (disabled) return 'disabled';
|
|
131
|
-
|
|
132
|
+
// Use unified pointer/mouse sequence to satisfy React handlers.
|
|
133
|
+
dispatchClickSequence(button);
|
|
132
134
|
return 'clicked';
|
|
133
135
|
})()`;
|
|
134
136
|
const deadline = Date.now() + 2_000;
|
|
@@ -144,6 +146,37 @@ async function attemptSendButton(Runtime) {
|
|
|
144
146
|
}
|
|
145
147
|
return false;
|
|
146
148
|
}
|
|
149
|
+
async function clickAnswerNowIfPresent(Runtime, logger) {
|
|
150
|
+
const script = `(() => {
|
|
151
|
+
${buildClickDispatcher()}
|
|
152
|
+
const matchesText = (el) => (el?.textContent || '').trim().toLowerCase() === 'answer now';
|
|
153
|
+
const candidate = Array.from(document.querySelectorAll('button,span')).find(matchesText);
|
|
154
|
+
if (!candidate) return 'missing';
|
|
155
|
+
const button = candidate.closest('button') ?? candidate;
|
|
156
|
+
const style = window.getComputedStyle(button);
|
|
157
|
+
const disabled =
|
|
158
|
+
button.hasAttribute('disabled') ||
|
|
159
|
+
button.getAttribute('aria-disabled') === 'true' ||
|
|
160
|
+
style.pointerEvents === 'none' ||
|
|
161
|
+
style.display === 'none';
|
|
162
|
+
if (disabled) return 'disabled';
|
|
163
|
+
dispatchClickSequence(button);
|
|
164
|
+
return 'clicked';
|
|
165
|
+
})()`;
|
|
166
|
+
const deadline = Date.now() + 3_000;
|
|
167
|
+
while (Date.now() < deadline) {
|
|
168
|
+
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
169
|
+
const status = result.value;
|
|
170
|
+
if (status === 'clicked') {
|
|
171
|
+
logger?.('Clicked "Answer now" gate');
|
|
172
|
+
await delay(500);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (status === 'missing')
|
|
176
|
+
return;
|
|
177
|
+
await delay(100);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
147
180
|
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
|
|
148
181
|
const deadline = Date.now() + timeoutMs;
|
|
149
182
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { FILE_INPUT_SELECTORS } from '../constants.js';
|
|
4
|
+
import { waitForAttachmentVisible } from './attachments.js';
|
|
4
5
|
import { delay } from '../utils.js';
|
|
5
6
|
import { logDomFailure } from '../domDebug.js';
|
|
6
7
|
/**
|
|
@@ -132,6 +133,7 @@ export async function uploadAttachmentViaDataTransfer(deps, attachment, logger)
|
|
|
132
133
|
logger(`File transferred: ${uploadResult.fileName} (${uploadResult.size} bytes)`);
|
|
133
134
|
// Give ChatGPT a moment to process the file
|
|
134
135
|
await delay(500);
|
|
136
|
+
await waitForAttachmentVisible(runtime, fileName, 10_000, logger);
|
|
135
137
|
logger('Attachment queued');
|
|
136
138
|
}
|
|
137
139
|
function guessMimeType(fileName) {
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
5
6
|
import chromeCookies from 'chrome-cookies-secure';
|
|
6
7
|
import { COOKIE_URLS } from './constants.js';
|
|
7
8
|
import { ensureCookiesDirForFallback } from './windowsCookies.js';
|
|
@@ -57,13 +58,19 @@ async function ensureMacKeychainReadable() {
|
|
|
57
58
|
// chrome-cookies-secure can hang forever when the platform keyring rejects access (e.g., SSH/no GUI).
|
|
58
59
|
// Probe the keyring ourselves with a timeout so callers fail fast instead of blocking the run.
|
|
59
60
|
let keytar = null;
|
|
61
|
+
let keytarPath = null;
|
|
60
62
|
try {
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
keytarPath = require.resolve('keytar');
|
|
61
65
|
const keytarModule = await import('keytar');
|
|
62
66
|
keytar = (keytarModule.default ?? keytarModule);
|
|
63
67
|
}
|
|
64
68
|
catch (error) {
|
|
65
69
|
const base = error instanceof Error ? error.message : String(error);
|
|
66
|
-
|
|
70
|
+
const rebuildHint = keytarPath
|
|
71
|
+
? ` You may need to rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside ${path.dirname(keytarPath)}).`
|
|
72
|
+
: '';
|
|
73
|
+
throw new Error(`Failed to load keytar for secure cookie copy (${base}). Install keyring deps (macOS Keychain / libsecret) or rerun with --render --copy to paste into ChatGPT manually.${rebuildHint}`);
|
|
67
74
|
}
|
|
68
75
|
const password = await settleWithTimeout(findKeychainPassword(keytar, MAC_KEYCHAIN_LABELS), KEYCHAIN_PROBE_TIMEOUT_MS, `Timed out reading macOS Keychain while looking up Chrome Safe Storage (after ${KEYCHAIN_PROBE_TIMEOUT_MS} ms). Unlock the login keychain or start oracle serve from a GUI session.`);
|
|
69
76
|
if (!password) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import net from 'node:net';
|
|
4
5
|
import { resolveBrowserConfig } from './config.js';
|
|
5
6
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
|
|
6
7
|
import { syncCookies } from './cookies.js';
|
|
@@ -18,7 +19,7 @@ export async function runBrowserMode(options) {
|
|
|
18
19
|
throw new Error('Prompt text is required when using browser mode.');
|
|
19
20
|
}
|
|
20
21
|
const attachments = options.attachments ?? [];
|
|
21
|
-
|
|
22
|
+
let config = resolveBrowserConfig(options.config);
|
|
22
23
|
const logger = options.log ?? ((_message) => { });
|
|
23
24
|
if (logger.verbose === undefined) {
|
|
24
25
|
logger.verbose = Boolean(config.debug);
|
|
@@ -56,6 +57,14 @@ export async function runBrowserMode(options) {
|
|
|
56
57
|
promptLength: promptText.length,
|
|
57
58
|
})}`);
|
|
58
59
|
}
|
|
60
|
+
if (!config.remoteChrome && !config.manualLogin) {
|
|
61
|
+
const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
|
|
62
|
+
const availablePort = await pickAvailableDebugPort(preferredPort, logger);
|
|
63
|
+
if (availablePort !== preferredPort) {
|
|
64
|
+
logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
|
|
65
|
+
}
|
|
66
|
+
config = { ...config, debugPort: availablePort };
|
|
67
|
+
}
|
|
59
68
|
// Remote Chrome mode - connect to existing browser
|
|
60
69
|
if (config.remoteChrome) {
|
|
61
70
|
// Warn about ignored local-only options
|
|
@@ -165,6 +174,17 @@ export async function runBrowserMode(options) {
|
|
|
165
174
|
? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
|
|
166
175
|
: 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
|
|
167
176
|
}
|
|
177
|
+
if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
|
|
178
|
+
throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
|
|
179
|
+
'Make sure ChatGPT is signed in in the selected profile or rebuild the keytar native module if it failed to load.', {
|
|
180
|
+
stage: 'execute-browser',
|
|
181
|
+
details: {
|
|
182
|
+
profile: config.chromeProfile ?? 'Default',
|
|
183
|
+
cookiePath: config.chromeCookiePath ?? null,
|
|
184
|
+
hint: 'Rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside the keytar path from the error), then retry.',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
168
188
|
const baseUrl = CHATGPT_URL;
|
|
169
189
|
// First load the base ChatGPT homepage to satisfy potential interstitials,
|
|
170
190
|
// then hop to the requested URL if it differs.
|
|
@@ -383,6 +403,48 @@ export async function runBrowserMode(options) {
|
|
|
383
403
|
}
|
|
384
404
|
}
|
|
385
405
|
}
|
|
406
|
+
const DEFAULT_DEBUG_PORT = 9222;
|
|
407
|
+
async function pickAvailableDebugPort(preferredPort, logger) {
|
|
408
|
+
const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT;
|
|
409
|
+
for (let offset = 0; offset < 10; offset++) {
|
|
410
|
+
const candidate = start + offset;
|
|
411
|
+
if (await isPortAvailable(candidate)) {
|
|
412
|
+
return candidate;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const fallback = await findEphemeralPort();
|
|
416
|
+
logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`);
|
|
417
|
+
return fallback;
|
|
418
|
+
}
|
|
419
|
+
async function isPortAvailable(port) {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const server = net.createServer();
|
|
422
|
+
server.once('error', () => resolve(false));
|
|
423
|
+
server.once('listening', () => {
|
|
424
|
+
server.close(() => resolve(true));
|
|
425
|
+
});
|
|
426
|
+
server.listen(port, '127.0.0.1');
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function findEphemeralPort() {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const server = net.createServer();
|
|
432
|
+
server.once('error', (error) => {
|
|
433
|
+
server.close();
|
|
434
|
+
reject(error);
|
|
435
|
+
});
|
|
436
|
+
server.listen(0, '127.0.0.1', () => {
|
|
437
|
+
const address = server.address();
|
|
438
|
+
if (address && typeof address === 'object') {
|
|
439
|
+
const port = address.port;
|
|
440
|
+
server.close(() => resolve(port));
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
server.close(() => reject(new Error('Failed to acquire ephemeral port')));
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
386
448
|
async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
|
|
387
449
|
if (!manualLogin) {
|
|
388
450
|
await ensureLoggedIn(runtime, logger, { appliedCookies });
|
|
@@ -412,6 +474,26 @@ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, time
|
|
|
412
474
|
}
|
|
413
475
|
throw new Error('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
|
|
414
476
|
}
|
|
477
|
+
async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
|
|
478
|
+
const deadline = Date.now() + timeoutMs;
|
|
479
|
+
let lastUrl = '';
|
|
480
|
+
while (Date.now() < deadline) {
|
|
481
|
+
const { result } = await runtime.evaluate({
|
|
482
|
+
expression: 'typeof location === "object" && location.href ? location.href : ""',
|
|
483
|
+
returnByValue: true,
|
|
484
|
+
});
|
|
485
|
+
const url = typeof result?.value === 'string' ? result.value : '';
|
|
486
|
+
lastUrl = url;
|
|
487
|
+
if (/^https?:\/\//i.test(url)) {
|
|
488
|
+
return url;
|
|
489
|
+
}
|
|
490
|
+
await delay(250);
|
|
491
|
+
}
|
|
492
|
+
throw new BrowserAutomationError('ChatGPT session not detected; page never left new tab.', {
|
|
493
|
+
stage: 'execute-browser',
|
|
494
|
+
details: { url: lastUrl || '(empty)' },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
415
497
|
async function maybeReuseRunningChrome(userDataDir, logger) {
|
|
416
498
|
const port = await readDevToolsPort(userDataDir);
|
|
417
499
|
if (!port)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdir, rm, cp as copyDir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { defaultProfileRoot, expandPath, looksLikePath } from './chromeCookies.js';
|
|
6
|
+
const DIR_EXCLUDES = [
|
|
7
|
+
'Cache',
|
|
8
|
+
'Code Cache',
|
|
9
|
+
'GPUCache',
|
|
10
|
+
'Service Worker',
|
|
11
|
+
'Crashpad',
|
|
12
|
+
'BrowserMetrics*',
|
|
13
|
+
'GrShaderCache',
|
|
14
|
+
'ShaderCache',
|
|
15
|
+
'OptimizationGuide',
|
|
16
|
+
];
|
|
17
|
+
const FILE_EXCLUDES = [
|
|
18
|
+
'SingletonLock',
|
|
19
|
+
'SingletonSocket',
|
|
20
|
+
'SingletonCookie',
|
|
21
|
+
'*.lock',
|
|
22
|
+
'lockfile',
|
|
23
|
+
'Lock',
|
|
24
|
+
'*.tmp',
|
|
25
|
+
'DevToolsActivePort',
|
|
26
|
+
path.join('Default', 'DevToolsActivePort'),
|
|
27
|
+
path.join('Sessions', '*'),
|
|
28
|
+
'Current Session',
|
|
29
|
+
'Current Tabs',
|
|
30
|
+
'Last Session',
|
|
31
|
+
'Last Tabs',
|
|
32
|
+
];
|
|
33
|
+
export async function syncChromeProfile(options) {
|
|
34
|
+
const { targetDir } = options;
|
|
35
|
+
await mkdir(targetDir, { recursive: true });
|
|
36
|
+
const { sourceDir, profileName } = await resolveProfileSource(options.profile, options.explicitPath);
|
|
37
|
+
const logger = options.logger;
|
|
38
|
+
if (!existsSync(sourceDir)) {
|
|
39
|
+
throw new Error(`Chrome profile not found at ${sourceDir}. Log in once in Chrome, then retry.`);
|
|
40
|
+
}
|
|
41
|
+
// Clean any stale DevTools ports/locks in the target before copying.
|
|
42
|
+
await rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
|
43
|
+
await mkdir(targetDir, { recursive: true });
|
|
44
|
+
const result = process.platform === 'win32'
|
|
45
|
+
? await copyWithRobocopy(sourceDir, targetDir, logger)
|
|
46
|
+
: await copyWithRsync(sourceDir, targetDir, logger);
|
|
47
|
+
// Remove lock files in the copied profile to avoid "already running" errors.
|
|
48
|
+
await removeLocks(targetDir);
|
|
49
|
+
return {
|
|
50
|
+
source: sourceDir,
|
|
51
|
+
profileName,
|
|
52
|
+
method: result.method,
|
|
53
|
+
status: result.copied ? 'copied' : 'skipped',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function copyWithRsync(sourceDir, targetDir, logger) {
|
|
57
|
+
const rsyncArgs = [
|
|
58
|
+
'-a',
|
|
59
|
+
'--delete',
|
|
60
|
+
...DIR_EXCLUDES.flatMap((entry) => ['--exclude', entry]),
|
|
61
|
+
...FILE_EXCLUDES.flatMap((entry) => ['--exclude', entry]),
|
|
62
|
+
`${sourceDir}/`,
|
|
63
|
+
`${targetDir}/`,
|
|
64
|
+
];
|
|
65
|
+
const attempt = spawnSync('rsync', rsyncArgs, { stdio: 'pipe' });
|
|
66
|
+
if (!attempt.error && (attempt.status ?? 0) === 0) {
|
|
67
|
+
return { copied: true, method: 'rsync' };
|
|
68
|
+
}
|
|
69
|
+
logger?.('rsync unavailable or failed; falling back to Node copy');
|
|
70
|
+
await copyDirWithFilter(sourceDir, targetDir);
|
|
71
|
+
return copyWithNodeFs();
|
|
72
|
+
}
|
|
73
|
+
async function copyWithRobocopy(sourceDir, targetDir, logger) {
|
|
74
|
+
const args = [sourceDir, targetDir, '/MIR', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/Z'];
|
|
75
|
+
if (DIR_EXCLUDES.length) {
|
|
76
|
+
args.push('/XD', ...DIR_EXCLUDES);
|
|
77
|
+
}
|
|
78
|
+
if (FILE_EXCLUDES.length) {
|
|
79
|
+
args.push('/XF', ...FILE_EXCLUDES);
|
|
80
|
+
}
|
|
81
|
+
const attempt = spawnSync('robocopy', args, { stdio: 'pipe' });
|
|
82
|
+
const exitCode = attempt.status ?? 0;
|
|
83
|
+
// Robocopy treats 0-7 as success/partial success; >=8 is failure.
|
|
84
|
+
if (!attempt.error && exitCode < 8) {
|
|
85
|
+
return { copied: true, method: 'robocopy' };
|
|
86
|
+
}
|
|
87
|
+
logger?.('robocopy failed; falling back to Node copy');
|
|
88
|
+
await copyDirWithFilter(sourceDir, targetDir);
|
|
89
|
+
return copyWithNodeFs();
|
|
90
|
+
}
|
|
91
|
+
function copyWithNodeFs() {
|
|
92
|
+
return { copied: true, method: 'node' };
|
|
93
|
+
}
|
|
94
|
+
function shouldExclude(relativePath) {
|
|
95
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
96
|
+
return DIR_EXCLUDES.some((entry) => normalized === entry || normalized.startsWith(`${entry}/`)) ||
|
|
97
|
+
FILE_EXCLUDES.some((entry) => {
|
|
98
|
+
if (entry.endsWith('*')) {
|
|
99
|
+
return normalized.startsWith(entry.slice(0, -1));
|
|
100
|
+
}
|
|
101
|
+
if (entry.includes('*')) {
|
|
102
|
+
// simple glob support for BrowserMetrics*
|
|
103
|
+
const prefix = entry.replace('*', '');
|
|
104
|
+
return normalized.startsWith(prefix);
|
|
105
|
+
}
|
|
106
|
+
return path.basename(normalized) === entry || normalized.endsWith(`/${entry}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function removeLocks(targetDir) {
|
|
110
|
+
const lockNames = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'DevToolsActivePort'];
|
|
111
|
+
for (const lock of lockNames) {
|
|
112
|
+
await rm(path.join(targetDir, lock), { force: true }).catch(() => undefined);
|
|
113
|
+
await rm(path.join(targetDir, 'Default', lock), { force: true }).catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function resolveProfileSource(profile, explicitPath) {
|
|
117
|
+
const profileName = profile?.trim() ? profile.trim() : 'Default';
|
|
118
|
+
if (explicitPath?.trim()) {
|
|
119
|
+
const resolved = expandPath(explicitPath.trim());
|
|
120
|
+
if (resolved.toLowerCase().endsWith('cookies')) {
|
|
121
|
+
return { sourceDir: path.dirname(resolved), profileName };
|
|
122
|
+
}
|
|
123
|
+
return { sourceDir: resolved, profileName };
|
|
124
|
+
}
|
|
125
|
+
if (looksLikePath(profileName)) {
|
|
126
|
+
return { sourceDir: expandPath(profileName), profileName };
|
|
127
|
+
}
|
|
128
|
+
const baseRoot = await defaultProfileRoot();
|
|
129
|
+
return { sourceDir: path.join(baseRoot, profileName), profileName };
|
|
130
|
+
}
|
|
131
|
+
async function copyDirWithFilter(sourceDir, targetDir) {
|
|
132
|
+
await copyDir(sourceDir, targetDir, {
|
|
133
|
+
recursive: true,
|
|
134
|
+
filter: async (source) => {
|
|
135
|
+
const rel = path.relative(sourceDir, source);
|
|
136
|
+
if (!rel)
|
|
137
|
+
return true;
|
|
138
|
+
return !shouldExclude(rel);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -3,39 +3,43 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
|
|
|
3
3
|
const browser = config.browser;
|
|
4
4
|
if (!browser)
|
|
5
5
|
return;
|
|
6
|
+
const isUnset = (key) => {
|
|
7
|
+
const source = getSource(key);
|
|
8
|
+
return source === undefined || source === 'default';
|
|
9
|
+
};
|
|
6
10
|
const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
|
|
7
11
|
const cliChatgptSet = options.chatgptUrl !== undefined || options.browserUrl !== undefined;
|
|
8
|
-
if ((
|
|
12
|
+
if (isUnset('chatgptUrl') && !cliChatgptSet && configuredChatgptUrl !== undefined) {
|
|
9
13
|
options.chatgptUrl = normalizeChatgptUrl(configuredChatgptUrl ?? '', CHATGPT_URL);
|
|
10
14
|
}
|
|
11
|
-
if (
|
|
15
|
+
if (isUnset('browserChromeProfile') && browser.chromeProfile !== undefined) {
|
|
12
16
|
options.browserChromeProfile = browser.chromeProfile ?? undefined;
|
|
13
17
|
}
|
|
14
|
-
if (
|
|
18
|
+
if (isUnset('browserChromePath') && browser.chromePath !== undefined) {
|
|
15
19
|
options.browserChromePath = browser.chromePath ?? undefined;
|
|
16
20
|
}
|
|
17
|
-
if (
|
|
21
|
+
if (isUnset('browserCookiePath') && browser.chromeCookiePath !== undefined) {
|
|
18
22
|
options.browserCookiePath = browser.chromeCookiePath ?? undefined;
|
|
19
23
|
}
|
|
20
|
-
if ((
|
|
24
|
+
if (isUnset('browserUrl') && options.browserUrl === undefined && browser.url !== undefined) {
|
|
21
25
|
options.browserUrl = browser.url;
|
|
22
26
|
}
|
|
23
|
-
if (
|
|
27
|
+
if (isUnset('browserTimeout') && typeof browser.timeoutMs === 'number') {
|
|
24
28
|
options.browserTimeout = String(browser.timeoutMs);
|
|
25
29
|
}
|
|
26
|
-
if (
|
|
30
|
+
if (isUnset('browserPort') && typeof browser.debugPort === 'number') {
|
|
27
31
|
options.browserPort = browser.debugPort;
|
|
28
32
|
}
|
|
29
|
-
if (
|
|
33
|
+
if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
|
|
30
34
|
options.browserInputTimeout = String(browser.inputTimeoutMs);
|
|
31
35
|
}
|
|
32
|
-
if (
|
|
36
|
+
if (isUnset('browserHeadless') && browser.headless !== undefined) {
|
|
33
37
|
options.browserHeadless = browser.headless;
|
|
34
38
|
}
|
|
35
|
-
if (
|
|
39
|
+
if (isUnset('browserHideWindow') && browser.hideWindow !== undefined) {
|
|
36
40
|
options.browserHideWindow = browser.hideWindow;
|
|
37
41
|
}
|
|
38
|
-
if (
|
|
42
|
+
if (isUnset('browserKeepBrowser') && browser.keepBrowser !== undefined) {
|
|
39
43
|
options.browserKeepBrowser = browser.keepBrowser;
|
|
40
44
|
}
|
|
41
45
|
}
|
|
@@ -2,15 +2,14 @@ import chalk from 'chalk';
|
|
|
2
2
|
import kleur from 'kleur';
|
|
3
3
|
import { renderMarkdownAnsi } from './markdownRenderer.js';
|
|
4
4
|
import { formatElapsed, formatUSD } from '../oracle/format.js';
|
|
5
|
-
import { MODEL_CONFIGS } from '../oracle.js';
|
|
6
5
|
import { sessionStore, wait } from '../sessionStore.js';
|
|
7
6
|
import { formatTokenCount, formatTokenValue } from '../oracle/runUtils.js';
|
|
8
7
|
import { resumeBrowserSession } from '../browser/reattach.js';
|
|
9
8
|
import { estimateTokenCount } from '../browser/utils.js';
|
|
9
|
+
import { formatSessionTableHeader, formatSessionTableRow, resolveSessionCost } from './sessionTable.js';
|
|
10
10
|
const isTty = () => Boolean(process.stdout.isTTY);
|
|
11
11
|
const dim = (text) => (isTty() ? kleur.dim(text) : text);
|
|
12
12
|
export const MAX_RENDER_BYTES = 200_000;
|
|
13
|
-
const MODEL_COLUMN_WIDTH = 18;
|
|
14
13
|
function isProcessAlive(pid) {
|
|
15
14
|
if (!pid)
|
|
16
15
|
return false;
|
|
@@ -36,17 +35,9 @@ export async function showStatus({ hours, includeAll, limit, showExamples = fals
|
|
|
36
35
|
return;
|
|
37
36
|
}
|
|
38
37
|
console.log(chalk.bold('Recent Sessions'));
|
|
39
|
-
console.log(
|
|
38
|
+
console.log(formatSessionTableHeader(richTty));
|
|
40
39
|
for (const entry of filteredEntries) {
|
|
41
|
-
|
|
42
|
-
const status = richTty ? colorStatus(entry.status ?? 'unknown', statusRaw) : statusRaw;
|
|
43
|
-
const modelColumn = formatModelColumn(entry, MODEL_COLUMN_WIDTH, richTty);
|
|
44
|
-
const created = formatTimestamp(entry.createdAt);
|
|
45
|
-
const chars = entry.options?.prompt?.length ?? entry.promptPreview?.length ?? 0;
|
|
46
|
-
const charLabel = chars > 0 ? String(chars).padStart(5) : ' -';
|
|
47
|
-
const costValue = resolveCost(entry);
|
|
48
|
-
const costLabel = costValue != null ? formatCostTable(costValue) : ' -';
|
|
49
|
-
console.log(`${created} | ${charLabel} | ${costLabel} | ${status} | ${modelColumn} | ${entry.id}`);
|
|
40
|
+
console.log(formatSessionTableRow(entry, { rich: richTty }));
|
|
50
41
|
}
|
|
51
42
|
if (truncated) {
|
|
52
43
|
const sessionsDir = sessionStore.sessionsDir();
|
|
@@ -56,18 +47,6 @@ export async function showStatus({ hours, includeAll, limit, showExamples = fals
|
|
|
56
47
|
printStatusExamples();
|
|
57
48
|
}
|
|
58
49
|
}
|
|
59
|
-
function colorStatus(status, padded) {
|
|
60
|
-
switch (status) {
|
|
61
|
-
case 'completed':
|
|
62
|
-
return chalk.green(padded);
|
|
63
|
-
case 'error':
|
|
64
|
-
return chalk.red(padded);
|
|
65
|
-
case 'running':
|
|
66
|
-
return chalk.yellow(padded);
|
|
67
|
-
default:
|
|
68
|
-
return padded;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
50
|
export async function attachSession(sessionId, options) {
|
|
72
51
|
let metadata = await sessionStore.readSession(sessionId);
|
|
73
52
|
if (!metadata) {
|
|
@@ -450,43 +429,6 @@ function matchesModel(entry, filter) {
|
|
|
450
429
|
const models = entry.models?.map((model) => model.model.toLowerCase()) ?? (entry.model ? [entry.model.toLowerCase()] : []);
|
|
451
430
|
return models.includes(normalized);
|
|
452
431
|
}
|
|
453
|
-
function formatModelColumn(entry, width, richTty) {
|
|
454
|
-
const models = entry.models && entry.models.length > 0
|
|
455
|
-
? entry.models
|
|
456
|
-
: entry.model
|
|
457
|
-
? [{ model: entry.model, status: entry.status }]
|
|
458
|
-
: [];
|
|
459
|
-
if (models.length === 0) {
|
|
460
|
-
return 'n/a'.padEnd(width);
|
|
461
|
-
}
|
|
462
|
-
const badges = models.map((model) => formatModelBadge(model, richTty));
|
|
463
|
-
const text = badges.join(' ');
|
|
464
|
-
if (text.length > width) {
|
|
465
|
-
return `${text.slice(0, width - 1)}…`;
|
|
466
|
-
}
|
|
467
|
-
return text.padEnd(width);
|
|
468
|
-
}
|
|
469
|
-
function formatModelBadge(model, richTty) {
|
|
470
|
-
const glyph = statusGlyph(model.status);
|
|
471
|
-
const text = `${model.model}${glyph}`;
|
|
472
|
-
return richTty ? chalk.cyan(text) : text;
|
|
473
|
-
}
|
|
474
|
-
function statusGlyph(status) {
|
|
475
|
-
switch (status) {
|
|
476
|
-
case 'completed':
|
|
477
|
-
return '✓';
|
|
478
|
-
case 'running':
|
|
479
|
-
return '⌛';
|
|
480
|
-
case 'pending':
|
|
481
|
-
return '…';
|
|
482
|
-
case 'error':
|
|
483
|
-
return '✖';
|
|
484
|
-
case 'cancelled':
|
|
485
|
-
return '⦻';
|
|
486
|
-
default:
|
|
487
|
-
return '?';
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
432
|
async function buildSessionLogForDisplay(sessionId, fallbackMeta, modelFilter) {
|
|
491
433
|
const normalizedFilter = modelFilter?.trim().toLowerCase();
|
|
492
434
|
const freshMetadata = (await sessionStore.readSession(sessionId)) ?? fallbackMeta;
|
|
@@ -557,28 +499,13 @@ function extractRenderableChunks(text, state) {
|
|
|
557
499
|
}
|
|
558
500
|
return { chunks, remainder: buffer };
|
|
559
501
|
}
|
|
560
|
-
function formatTimestamp(iso) {
|
|
561
|
-
const date = new Date(iso);
|
|
562
|
-
const locale = 'en-US';
|
|
563
|
-
const opts = {
|
|
564
|
-
year: 'numeric',
|
|
565
|
-
month: '2-digit',
|
|
566
|
-
day: '2-digit',
|
|
567
|
-
hour: 'numeric',
|
|
568
|
-
minute: '2-digit',
|
|
569
|
-
second: undefined,
|
|
570
|
-
hour12: true,
|
|
571
|
-
};
|
|
572
|
-
const formatted = date.toLocaleString(locale, opts);
|
|
573
|
-
return formatted.replace(/(, )(\d:)/, '$1 $2');
|
|
574
|
-
}
|
|
575
502
|
export function formatCompletionSummary(metadata, options = {}) {
|
|
576
503
|
if (!metadata.usage || metadata.elapsedMs == null) {
|
|
577
504
|
return null;
|
|
578
505
|
}
|
|
579
506
|
const modeLabel = metadata.mode === 'browser' ? `${metadata.model ?? 'n/a'}[browser]` : metadata.model ?? 'n/a';
|
|
580
507
|
const usage = metadata.usage;
|
|
581
|
-
const cost =
|
|
508
|
+
const cost = resolveSessionCost(metadata);
|
|
582
509
|
const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
|
|
583
510
|
const tokensDisplay = [
|
|
584
511
|
usage.inputTokens ?? 0,
|
|
@@ -598,28 +525,6 @@ export function formatCompletionSummary(metadata, options = {}) {
|
|
|
598
525
|
const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
|
|
599
526
|
return `Finished in ${formatElapsed(metadata.elapsedMs)} (${modeLabel}${costPart} | tok(i/o/r/t)=${tokensDisplay}${filesPart}${slugPart})`;
|
|
600
527
|
}
|
|
601
|
-
function resolveCost(metadata) {
|
|
602
|
-
if (metadata.mode === 'browser') {
|
|
603
|
-
return null;
|
|
604
|
-
}
|
|
605
|
-
if (metadata.usage?.cost != null) {
|
|
606
|
-
return metadata.usage.cost;
|
|
607
|
-
}
|
|
608
|
-
if (!metadata.model || !metadata.usage) {
|
|
609
|
-
return null;
|
|
610
|
-
}
|
|
611
|
-
const pricing = MODEL_CONFIGS[metadata.model]?.pricing;
|
|
612
|
-
if (!pricing) {
|
|
613
|
-
return null;
|
|
614
|
-
}
|
|
615
|
-
const input = metadata.usage.inputTokens ?? 0;
|
|
616
|
-
const output = metadata.usage.outputTokens ?? 0;
|
|
617
|
-
const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
|
|
618
|
-
return cost > 0 ? cost : null;
|
|
619
|
-
}
|
|
620
|
-
function formatCostTable(cost) {
|
|
621
|
-
return `$${cost.toFixed(3)}`.padStart(7);
|
|
622
|
-
}
|
|
623
528
|
async function readStoredPrompt(sessionId) {
|
|
624
529
|
const request = await sessionStore.readRequest(sessionId);
|
|
625
530
|
if (request?.prompt && request.prompt.trim().length > 0) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import { MODEL_CONFIGS } from '../oracle.js';
|
|
4
|
+
const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
|
|
5
|
+
const dim = (text, rich) => (rich ? kleur.dim(text) : text);
|
|
6
|
+
export const STATUS_PAD = 9;
|
|
7
|
+
export const MODEL_PAD = 13;
|
|
8
|
+
export const MODE_PAD = 7;
|
|
9
|
+
export const TIMESTAMP_PAD = 19;
|
|
10
|
+
export const CHARS_PAD = 5;
|
|
11
|
+
export const COST_PAD = 7;
|
|
12
|
+
export function formatSessionTableHeader(rich) {
|
|
13
|
+
const header = `${'Status'.padEnd(STATUS_PAD)} ${'Model'.padEnd(MODEL_PAD)} ${'Mode'.padEnd(MODE_PAD)} ${'Timestamp'.padEnd(TIMESTAMP_PAD)} ${'Chars'.padStart(CHARS_PAD)} ${'Cost'.padStart(COST_PAD)} Slug`;
|
|
14
|
+
return dim(header, isRich(rich));
|
|
15
|
+
}
|
|
16
|
+
export function formatSessionTableRow(meta, options) {
|
|
17
|
+
const rich = isRich(options?.rich);
|
|
18
|
+
const status = colorStatus(meta.status ?? 'unknown', rich);
|
|
19
|
+
const modelLabel = (meta.model ?? 'n/a').padEnd(MODEL_PAD);
|
|
20
|
+
const model = rich ? chalk.white(modelLabel) : modelLabel;
|
|
21
|
+
const modeLabel = (meta.mode ?? meta.options?.mode ?? 'api').padEnd(MODE_PAD);
|
|
22
|
+
const mode = rich ? chalk.gray(modeLabel) : modeLabel;
|
|
23
|
+
const timestampLabel = formatTimestampAligned(meta.createdAt).padEnd(TIMESTAMP_PAD);
|
|
24
|
+
const timestamp = rich ? chalk.gray(timestampLabel) : timestampLabel;
|
|
25
|
+
const charsValue = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
|
|
26
|
+
const charsRaw = charsValue > 0 ? String(charsValue).padStart(CHARS_PAD) : `${''.padStart(CHARS_PAD - 1)}-`;
|
|
27
|
+
const chars = rich ? chalk.gray(charsRaw) : charsRaw;
|
|
28
|
+
const costValue = resolveSessionCost(meta);
|
|
29
|
+
const costRaw = costValue != null ? formatCostTable(costValue) : `${''.padStart(COST_PAD - 1)}-`;
|
|
30
|
+
const cost = rich ? chalk.gray(costRaw) : costRaw;
|
|
31
|
+
const slug = rich ? chalk.cyan(meta.id) : meta.id;
|
|
32
|
+
return `${status} ${model} ${mode} ${timestamp} ${chars} ${cost} ${slug}`;
|
|
33
|
+
}
|
|
34
|
+
export function resolveSessionCost(meta) {
|
|
35
|
+
const mode = meta.mode ?? meta.options?.mode;
|
|
36
|
+
if (mode === 'browser') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (meta.usage?.cost != null) {
|
|
40
|
+
return meta.usage.cost;
|
|
41
|
+
}
|
|
42
|
+
if (!meta.model || !meta.usage) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const pricing = MODEL_CONFIGS[meta.model]?.pricing;
|
|
46
|
+
if (!pricing) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const input = meta.usage.inputTokens ?? 0;
|
|
50
|
+
const output = meta.usage.outputTokens ?? 0;
|
|
51
|
+
const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
|
|
52
|
+
return cost > 0 ? cost : null;
|
|
53
|
+
}
|
|
54
|
+
export function formatTimestampAligned(iso) {
|
|
55
|
+
const date = new Date(iso);
|
|
56
|
+
const locale = 'en-US';
|
|
57
|
+
const opts = {
|
|
58
|
+
year: 'numeric',
|
|
59
|
+
month: '2-digit',
|
|
60
|
+
day: '2-digit',
|
|
61
|
+
hour: 'numeric',
|
|
62
|
+
minute: '2-digit',
|
|
63
|
+
second: undefined,
|
|
64
|
+
hour12: true,
|
|
65
|
+
};
|
|
66
|
+
let formatted = date.toLocaleString(locale, opts);
|
|
67
|
+
formatted = formatted.replace(', ', ' ');
|
|
68
|
+
return formatted.replace(/(\s)(\d:)/, '$1 $2');
|
|
69
|
+
}
|
|
70
|
+
function formatCostTable(cost) {
|
|
71
|
+
return `$${cost.toFixed(3)}`.padStart(COST_PAD);
|
|
72
|
+
}
|
|
73
|
+
function colorStatus(status, rich) {
|
|
74
|
+
const padded = status.padEnd(STATUS_PAD);
|
|
75
|
+
if (!rich) {
|
|
76
|
+
return padded;
|
|
77
|
+
}
|
|
78
|
+
switch (status) {
|
|
79
|
+
case 'completed':
|
|
80
|
+
return chalk.green(padded);
|
|
81
|
+
case 'error':
|
|
82
|
+
return chalk.red(padded);
|
|
83
|
+
case 'running':
|
|
84
|
+
return chalk.yellow(padded);
|
|
85
|
+
default:
|
|
86
|
+
return padded;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -9,6 +9,7 @@ import { renderMarkdownAnsi } from '../markdownRenderer.js';
|
|
|
9
9
|
import { sessionStore, pruneOldSessions } from '../../sessionStore.js';
|
|
10
10
|
import { performSessionRun } from '../sessionRunner.js';
|
|
11
11
|
import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from '../sessionDisplay.js';
|
|
12
|
+
import { formatSessionTableHeader, formatSessionTableRow } from '../sessionTable.js';
|
|
12
13
|
import { buildBrowserConfig, resolveBrowserModelLabel } from '../browserConfig.js';
|
|
13
14
|
import { resolveNotificationSettings } from '../notifier.js';
|
|
14
15
|
import { loadUserConfig } from '../../config.js';
|
|
@@ -17,12 +18,6 @@ const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
|
|
|
17
18
|
const dim = (text) => (isTty() ? kleur.dim(text) : text);
|
|
18
19
|
const RECENT_WINDOW_HOURS = 24;
|
|
19
20
|
const PAGE_SIZE = 10;
|
|
20
|
-
const STATUS_PAD = 9;
|
|
21
|
-
const MODEL_PAD = 13;
|
|
22
|
-
const MODE_PAD = 7;
|
|
23
|
-
const TIMESTAMP_PAD = 19;
|
|
24
|
-
const CHARS_PAD = 5;
|
|
25
|
-
const COST_PAD = 7;
|
|
26
21
|
export async function launchTui({ version, printIntro = true }) {
|
|
27
22
|
const userConfig = (await loadUserConfig()).config;
|
|
28
23
|
const rich = isTty();
|
|
@@ -39,7 +34,7 @@ export async function launchTui({ version, printIntro = true }) {
|
|
|
39
34
|
for (;;) {
|
|
40
35
|
const { recent, older, olderTotal } = await fetchSessionBuckets();
|
|
41
36
|
const choices = [];
|
|
42
|
-
const headerLabel =
|
|
37
|
+
const headerLabel = formatSessionTableHeader(isTty());
|
|
43
38
|
// Start with a selectable row so focus never lands on a separator
|
|
44
39
|
choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
|
|
45
40
|
if (!showingOlder) {
|
|
@@ -71,7 +66,7 @@ export async function launchTui({ version, printIntro = true }) {
|
|
|
71
66
|
const prompt = inquirer.prompt([
|
|
72
67
|
{
|
|
73
68
|
name: 'selection',
|
|
74
|
-
type: '
|
|
69
|
+
type: 'select',
|
|
75
70
|
message: 'Select a session or action',
|
|
76
71
|
choices,
|
|
77
72
|
pageSize: 16,
|
|
@@ -85,6 +80,9 @@ export async function launchTui({ version, printIntro = true }) {
|
|
|
85
80
|
resolve('__reset__');
|
|
86
81
|
});
|
|
87
82
|
});
|
|
83
|
+
if (process.env.ORACLE_DEBUG_TUI === '1') {
|
|
84
|
+
console.error(`[tui] selection=${JSON.stringify(selection)}`);
|
|
85
|
+
}
|
|
88
86
|
if (selection === '__exit__') {
|
|
89
87
|
console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
|
|
90
88
|
return;
|
|
@@ -119,72 +117,10 @@ async function fetchSessionBuckets() {
|
|
|
119
117
|
}
|
|
120
118
|
function toSessionChoice(meta) {
|
|
121
119
|
return {
|
|
122
|
-
name:
|
|
120
|
+
name: formatSessionTableRow(meta, { rich: isTty() }),
|
|
123
121
|
value: meta.id,
|
|
124
122
|
};
|
|
125
123
|
}
|
|
126
|
-
function formatSessionLabel(meta) {
|
|
127
|
-
const status = colorStatus(meta.status ?? 'unknown');
|
|
128
|
-
const created = formatTimestampAligned(meta.createdAt);
|
|
129
|
-
const model = meta.model ?? 'n/a';
|
|
130
|
-
const mode = meta.mode ?? meta.options?.mode ?? 'api';
|
|
131
|
-
const slug = meta.id;
|
|
132
|
-
const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
|
|
133
|
-
const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(CHARS_PAD)) : chalk.gray(`${''.padStart(CHARS_PAD - 1)}-`);
|
|
134
|
-
const cost = mode === 'browser' ? null : resolveCost(meta);
|
|
135
|
-
const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(`${''.padStart(COST_PAD - 1)}-`);
|
|
136
|
-
return `${status} ${chalk.white(model.padEnd(MODEL_PAD))} ${chalk.gray(mode.padEnd(MODE_PAD))} ${chalk.gray(created.padEnd(TIMESTAMP_PAD))} ${charLabel} ${costLabel} ${chalk.cyan(slug)}`;
|
|
137
|
-
}
|
|
138
|
-
function resolveCost(meta) {
|
|
139
|
-
if (meta.usage?.cost != null) {
|
|
140
|
-
return meta.usage.cost;
|
|
141
|
-
}
|
|
142
|
-
if (!meta.model || !meta.usage) {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
const pricing = MODEL_CONFIGS[meta.model]?.pricing;
|
|
146
|
-
if (!pricing)
|
|
147
|
-
return null;
|
|
148
|
-
const input = meta.usage.inputTokens ?? 0;
|
|
149
|
-
const output = meta.usage.outputTokens ?? 0;
|
|
150
|
-
const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
|
|
151
|
-
return cost > 0 ? cost : null;
|
|
152
|
-
}
|
|
153
|
-
function formatCostTable(cost) {
|
|
154
|
-
return `$${cost.toFixed(3)}`.padStart(COST_PAD);
|
|
155
|
-
}
|
|
156
|
-
function formatTimestampAligned(iso) {
|
|
157
|
-
const date = new Date(iso);
|
|
158
|
-
const locale = 'en-US';
|
|
159
|
-
const opts = {
|
|
160
|
-
year: 'numeric',
|
|
161
|
-
month: '2-digit',
|
|
162
|
-
day: '2-digit',
|
|
163
|
-
hour: 'numeric',
|
|
164
|
-
minute: '2-digit',
|
|
165
|
-
second: undefined,
|
|
166
|
-
hour12: true,
|
|
167
|
-
};
|
|
168
|
-
let formatted = date.toLocaleString(locale, opts);
|
|
169
|
-
// Drop the comma and use double-space between date and time for alignment.
|
|
170
|
-
formatted = formatted.replace(', ', ' ');
|
|
171
|
-
// Insert a leading space when hour is a single digit to align AM/PM column.
|
|
172
|
-
// Example: "11/18/2025 1:07 AM" -> "11/18/2025 1:07 AM"
|
|
173
|
-
return formatted.replace(/(\s)(\d:)/, '$1 $2');
|
|
174
|
-
}
|
|
175
|
-
function colorStatus(status) {
|
|
176
|
-
const padded = status.padEnd(9);
|
|
177
|
-
switch (status) {
|
|
178
|
-
case 'completed':
|
|
179
|
-
return chalk.green(padded);
|
|
180
|
-
case 'error':
|
|
181
|
-
return chalk.red(padded);
|
|
182
|
-
case 'running':
|
|
183
|
-
return chalk.yellow(padded);
|
|
184
|
-
default:
|
|
185
|
-
return padded;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
124
|
async function showSessionDetail(sessionId) {
|
|
189
125
|
for (;;) {
|
|
190
126
|
const meta = await readSessionMetadataSafe(sessionId);
|
|
@@ -223,7 +159,7 @@ async function showSessionDetail(sessionId) {
|
|
|
223
159
|
const { next } = await inquirer.prompt([
|
|
224
160
|
{
|
|
225
161
|
name: 'next',
|
|
226
|
-
type: '
|
|
162
|
+
type: 'select',
|
|
227
163
|
message: 'Actions',
|
|
228
164
|
choices: actions,
|
|
229
165
|
},
|
|
@@ -302,7 +238,7 @@ async function askOracleFlow(version, userConfig) {
|
|
|
302
238
|
const hasApiKey = Boolean(process.env.OPENAI_API_KEY);
|
|
303
239
|
const initialMode = hasApiKey ? 'api' : 'browser';
|
|
304
240
|
const preferredMode = userConfig.engine ?? initialMode;
|
|
305
|
-
const
|
|
241
|
+
const wizardQuestions = [
|
|
306
242
|
{
|
|
307
243
|
name: 'promptInput',
|
|
308
244
|
type: 'input',
|
|
@@ -312,7 +248,7 @@ async function askOracleFlow(version, userConfig) {
|
|
|
312
248
|
? [
|
|
313
249
|
{
|
|
314
250
|
name: 'mode',
|
|
315
|
-
type: '
|
|
251
|
+
type: 'select',
|
|
316
252
|
message: 'Engine',
|
|
317
253
|
default: preferredMode,
|
|
318
254
|
choices: [
|
|
@@ -324,7 +260,7 @@ async function askOracleFlow(version, userConfig) {
|
|
|
324
260
|
: [
|
|
325
261
|
{
|
|
326
262
|
name: 'mode',
|
|
327
|
-
type: '
|
|
263
|
+
type: 'select',
|
|
328
264
|
message: 'Engine',
|
|
329
265
|
default: preferredMode,
|
|
330
266
|
choices: [{ name: 'Browser', value: 'browser' }],
|
|
@@ -337,7 +273,7 @@ async function askOracleFlow(version, userConfig) {
|
|
|
337
273
|
},
|
|
338
274
|
{
|
|
339
275
|
name: 'model',
|
|
340
|
-
type: '
|
|
276
|
+
type: 'select',
|
|
341
277
|
message: 'Model',
|
|
342
278
|
default: DEFAULT_MODEL,
|
|
343
279
|
choices: modelChoices,
|
|
@@ -390,7 +326,8 @@ async function askOracleFlow(version, userConfig) {
|
|
|
390
326
|
default: false,
|
|
391
327
|
when: (ans) => ans.mode === 'browser',
|
|
392
328
|
},
|
|
393
|
-
]
|
|
329
|
+
];
|
|
330
|
+
const answers = await inquirer.prompt(wizardQuestions);
|
|
394
331
|
const mode = (answers.mode ?? initialMode);
|
|
395
332
|
const prompt = await resolvePromptInput(answers.promptInput);
|
|
396
333
|
if (!prompt.trim()) {
|
|
@@ -514,4 +451,4 @@ async function readStoredPrompt(sessionId) {
|
|
|
514
451
|
}
|
|
515
452
|
// Exported for testing
|
|
516
453
|
export { askOracleFlow, showSessionDetail };
|
|
517
|
-
export { resolveCost };
|
|
454
|
+
export { resolveSessionCost as resolveCost } from '../sessionTable.js';
|
|
@@ -17,9 +17,8 @@ export function createDefaultClientFactory() {
|
|
|
17
17
|
return createClaudeClient(key, options.model, options.resolvedModelId, options.baseUrl);
|
|
18
18
|
}
|
|
19
19
|
let instance;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
: undefined;
|
|
20
|
+
const openRouter = isOpenRouterBaseUrl(options?.baseUrl);
|
|
21
|
+
const defaultHeaders = openRouter ? buildOpenRouterHeaders() : undefined;
|
|
23
22
|
if (options?.azure?.endpoint) {
|
|
24
23
|
instance = new AzureOpenAI({
|
|
25
24
|
apiKey: key,
|
|
@@ -37,6 +36,9 @@ export function createDefaultClientFactory() {
|
|
|
37
36
|
defaultHeaders,
|
|
38
37
|
});
|
|
39
38
|
}
|
|
39
|
+
if (openRouter) {
|
|
40
|
+
return buildOpenRouterCompletionClient(instance);
|
|
41
|
+
}
|
|
40
42
|
return {
|
|
41
43
|
responses: {
|
|
42
44
|
stream: (body) => instance.responses.stream(body),
|
|
@@ -102,3 +104,91 @@ function loadCustomClientFactory() {
|
|
|
102
104
|
}
|
|
103
105
|
// Exposed for tests
|
|
104
106
|
export { loadCustomClientFactory as __loadCustomClientFactory };
|
|
107
|
+
function buildOpenRouterCompletionClient(instance) {
|
|
108
|
+
const adaptRequest = (body) => {
|
|
109
|
+
const messages = [];
|
|
110
|
+
if (body.instructions) {
|
|
111
|
+
messages.push({ role: 'system', content: body.instructions });
|
|
112
|
+
}
|
|
113
|
+
for (const entry of body.input) {
|
|
114
|
+
const textParts = entry.content
|
|
115
|
+
.map((c) => (c.type === 'input_text' ? c.text : ''))
|
|
116
|
+
.filter((t) => t)
|
|
117
|
+
.join('\n\n');
|
|
118
|
+
messages.push({ role: entry.role ?? 'user', content: textParts });
|
|
119
|
+
}
|
|
120
|
+
const base = {
|
|
121
|
+
model: body.model,
|
|
122
|
+
messages,
|
|
123
|
+
max_tokens: body.max_output_tokens,
|
|
124
|
+
};
|
|
125
|
+
const streaming = { ...base, stream: true };
|
|
126
|
+
const nonStreaming = { ...base, stream: false };
|
|
127
|
+
return { streaming, nonStreaming };
|
|
128
|
+
};
|
|
129
|
+
const adaptResponse = (response) => {
|
|
130
|
+
const text = response.choices?.[0]?.message?.content ?? '';
|
|
131
|
+
const usage = {
|
|
132
|
+
input_tokens: response.usage?.prompt_tokens ?? 0,
|
|
133
|
+
output_tokens: response.usage?.completion_tokens ?? 0,
|
|
134
|
+
total_tokens: response.usage?.total_tokens ?? 0,
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
id: response.id ?? `openrouter-${Date.now()}`,
|
|
138
|
+
status: 'completed',
|
|
139
|
+
output_text: [text],
|
|
140
|
+
output: [{ type: 'text', text }],
|
|
141
|
+
usage,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
const stream = async (body) => {
|
|
145
|
+
const { streaming } = adaptRequest(body);
|
|
146
|
+
let finalUsage;
|
|
147
|
+
let finalId;
|
|
148
|
+
let aggregated = '';
|
|
149
|
+
async function* iterator() {
|
|
150
|
+
const completion = await instance.chat.completions.create(streaming);
|
|
151
|
+
for await (const chunk of completion) {
|
|
152
|
+
finalId = chunk.id ?? finalId;
|
|
153
|
+
const delta = chunk.choices?.[0]?.delta?.content ?? '';
|
|
154
|
+
if (delta) {
|
|
155
|
+
aggregated += delta;
|
|
156
|
+
yield { type: 'chunk', delta };
|
|
157
|
+
}
|
|
158
|
+
if (chunk.usage) {
|
|
159
|
+
finalUsage = chunk.usage;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const gen = iterator();
|
|
164
|
+
return {
|
|
165
|
+
[Symbol.asyncIterator]() {
|
|
166
|
+
return gen;
|
|
167
|
+
},
|
|
168
|
+
async finalResponse() {
|
|
169
|
+
return adaptResponse({
|
|
170
|
+
id: finalId ?? `openrouter-${Date.now()}`,
|
|
171
|
+
choices: [{ message: { role: 'assistant', content: aggregated } }],
|
|
172
|
+
usage: finalUsage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
173
|
+
created: Math.floor(Date.now() / 1000),
|
|
174
|
+
model: '',
|
|
175
|
+
object: 'chat.completion',
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const create = async (body) => {
|
|
181
|
+
const { nonStreaming } = adaptRequest(body);
|
|
182
|
+
const response = (await instance.chat.completions.create(nonStreaming));
|
|
183
|
+
return adaptResponse(response);
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
responses: {
|
|
187
|
+
stream,
|
|
188
|
+
create,
|
|
189
|
+
retrieve: async () => {
|
|
190
|
+
throw new Error('retrieve is not supported for OpenRouter chat/completions fallback.');
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
package/dist/src/oracle/run.js
CHANGED
|
@@ -133,9 +133,10 @@ export async function runOracle(options, deps = {}) {
|
|
|
133
133
|
if (isProTierModel && !Number.isNaN(minPromptLength) && promptLength < minPromptLength) {
|
|
134
134
|
throw new PromptValidationError(`Prompt is too short (<${minPromptLength} chars). This was likely accidental; please provide more detail.`, { minPromptLength, promptLength });
|
|
135
135
|
}
|
|
136
|
+
const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? openRouterApiKey ?? apiKey : undefined;
|
|
136
137
|
const modelConfig = await resolveModelConfig(options.model, {
|
|
137
138
|
baseUrl,
|
|
138
|
-
openRouterApiKey:
|
|
139
|
+
openRouterApiKey: resolverOpenRouterApiKey,
|
|
139
140
|
});
|
|
140
141
|
const isLongRunningModel = isProTierModel;
|
|
141
142
|
const supportsBackground = modelConfig.supportsBackground !== false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steipete/oracle",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "CLI wrapper around OpenAI Responses API with GPT-5.1 Pro, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/bin/oracle-cli.js",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"homepage": "https://github.com/steipete/oracle#readme",
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
|
48
|
-
"@google/genai": "^1.
|
|
48
|
+
"@google/genai": "^1.31.0",
|
|
49
49
|
"@google/generative-ai": "^0.24.1",
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"chrome-cookies-secure": "3.0.0",
|
|
53
53
|
"chrome-launcher": "^1.2.1",
|
|
@@ -57,33 +57,32 @@
|
|
|
57
57
|
"dotenv": "^17.2.3",
|
|
58
58
|
"fast-glob": "^3.3.3",
|
|
59
59
|
"gpt-tokenizer": "^3.4.0",
|
|
60
|
-
"inquirer": "
|
|
60
|
+
"inquirer": "13.0.2",
|
|
61
61
|
"json5": "^2.2.3",
|
|
62
62
|
"keytar": "^7.9.0",
|
|
63
63
|
"kleur": "^4.1.5",
|
|
64
64
|
"markdansi": "^0.1.3",
|
|
65
|
-
"openai": "^6.
|
|
66
|
-
"shiki": "^3.
|
|
65
|
+
"openai": "^6.10.0",
|
|
66
|
+
"shiki": "^3.19.0",
|
|
67
67
|
"sqlite3": "^5.1.7",
|
|
68
68
|
"toasted-notifier": "^10.1.0",
|
|
69
|
-
"zod": "
|
|
69
|
+
"zod": "^4.1.13"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
|
73
|
-
"@biomejs/biome": "^2.3.
|
|
73
|
+
"@biomejs/biome": "^2.3.8",
|
|
74
74
|
"@cdktf/node-pty-prebuilt-multiarch": "0.10.2",
|
|
75
|
-
"@types/chrome-remote-interface": "^0.
|
|
75
|
+
"@types/chrome-remote-interface": "^0.33.0",
|
|
76
76
|
"@types/inquirer": "^9.0.9",
|
|
77
|
-
"@types/json5": "^2.2.0",
|
|
78
77
|
"@types/node": "^24.10.1",
|
|
79
|
-
"@vitest/coverage-v8": "4.0.
|
|
80
|
-
"devtools-protocol": "^0.0.
|
|
78
|
+
"@vitest/coverage-v8": "4.0.15",
|
|
79
|
+
"devtools-protocol": "^0.0.1551306",
|
|
81
80
|
"es-toolkit": "^1.42.0",
|
|
82
|
-
"esbuild": "^0.27.
|
|
83
|
-
"puppeteer-core": "^24.
|
|
84
|
-
"tsx": "^4.
|
|
81
|
+
"esbuild": "^0.27.1",
|
|
82
|
+
"puppeteer-core": "^24.32.0",
|
|
83
|
+
"tsx": "^4.21.0",
|
|
85
84
|
"typescript": "^5.9.3",
|
|
86
|
-
"vitest": "^4.0.
|
|
85
|
+
"vitest": "^4.0.15"
|
|
87
86
|
},
|
|
88
87
|
"optionalDependencies": {
|
|
89
88
|
"win-dpapi": "npm:@primno/dpapi@2.0.1"
|