@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 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.click();
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.click();
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.click();
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
- // Some menus ignore synthetic click events.
73
- const down = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
74
- const up = new PointerEvent('pointerup', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
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.click();
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
- dispatchPointer(node);
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
- (button as HTMLElement).click();
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
- 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.`);
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
- const config = resolveBrowserConfig(options.config);
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 ((getSource('chatgptUrl') === 'default' || getSource('chatgptUrl') === undefined) && !cliChatgptSet && configuredChatgptUrl !== undefined) {
12
+ if (isUnset('chatgptUrl') && !cliChatgptSet && configuredChatgptUrl !== undefined) {
9
13
  options.chatgptUrl = normalizeChatgptUrl(configuredChatgptUrl ?? '', CHATGPT_URL);
10
14
  }
11
- if (getSource('browserChromeProfile') === 'default' && browser.chromeProfile !== undefined) {
15
+ if (isUnset('browserChromeProfile') && browser.chromeProfile !== undefined) {
12
16
  options.browserChromeProfile = browser.chromeProfile ?? undefined;
13
17
  }
14
- if (getSource('browserChromePath') === 'default' && browser.chromePath !== undefined) {
18
+ if (isUnset('browserChromePath') && browser.chromePath !== undefined) {
15
19
  options.browserChromePath = browser.chromePath ?? undefined;
16
20
  }
17
- if (getSource('browserCookiePath') === 'default' && browser.chromeCookiePath !== undefined) {
21
+ if (isUnset('browserCookiePath') && browser.chromeCookiePath !== undefined) {
18
22
  options.browserCookiePath = browser.chromeCookiePath ?? undefined;
19
23
  }
20
- if ((getSource('browserUrl') === 'default' || getSource('browserUrl') === undefined) && options.browserUrl === undefined && browser.url !== undefined) {
24
+ if (isUnset('browserUrl') && options.browserUrl === undefined && browser.url !== undefined) {
21
25
  options.browserUrl = browser.url;
22
26
  }
23
- if (getSource('browserTimeout') === 'default' && typeof browser.timeoutMs === 'number') {
27
+ if (isUnset('browserTimeout') && typeof browser.timeoutMs === 'number') {
24
28
  options.browserTimeout = String(browser.timeoutMs);
25
29
  }
26
- if (getSource('browserPort') === 'default' && typeof browser.debugPort === 'number') {
30
+ if (isUnset('browserPort') && typeof browser.debugPort === 'number') {
27
31
  options.browserPort = browser.debugPort;
28
32
  }
29
- if (getSource('browserInputTimeout') === 'default' && typeof browser.inputTimeoutMs === 'number') {
33
+ if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
30
34
  options.browserInputTimeout = String(browser.inputTimeoutMs);
31
35
  }
32
- if (getSource('browserHeadless') === 'default' && browser.headless !== undefined) {
36
+ if (isUnset('browserHeadless') && browser.headless !== undefined) {
33
37
  options.browserHeadless = browser.headless;
34
38
  }
35
- if (getSource('browserHideWindow') === 'default' && browser.hideWindow !== undefined) {
39
+ if (isUnset('browserHideWindow') && browser.hideWindow !== undefined) {
36
40
  options.browserHideWindow = browser.hideWindow;
37
41
  }
38
- if (getSource('browserKeepBrowser') === 'default' && browser.keepBrowser !== undefined) {
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(chalk.dim('Timestamp Chars Cost Status Models ID'));
38
+ console.log(formatSessionTableHeader(richTty));
40
39
  for (const entry of filteredEntries) {
41
- const statusRaw = (entry.status || 'unknown').padEnd(9);
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 = metadata.mode === 'browser' ? null : resolveCost(metadata);
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 = dim(`${'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`);
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: 'list',
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: formatSessionLabel(meta),
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: 'list',
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 answers = await inquirer.prompt([
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: 'list',
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: 'list',
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: 'list',
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 defaultHeaders = isOpenRouterBaseUrl(options?.baseUrl)
21
- ? buildOpenRouterHeaders()
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
+ }
@@ -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: openRouterApiKey ?? (isOpenRouterBaseUrl(baseUrl) ? apiKey : undefined),
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.0",
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.30.0",
48
+ "@google/genai": "^1.31.0",
49
49
  "@google/generative-ai": "^0.24.1",
50
- "@modelcontextprotocol/sdk": "^1.22.0",
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": "9.3.8",
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.9.1",
66
- "shiki": "^3.15.0",
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": "3.24.1"
69
+ "zod": "^4.1.13"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@anthropic-ai/tokenizer": "^0.0.4",
73
- "@biomejs/biome": "^2.3.7",
73
+ "@biomejs/biome": "^2.3.8",
74
74
  "@cdktf/node-pty-prebuilt-multiarch": "0.10.2",
75
- "@types/chrome-remote-interface": "^0.31.14",
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.13",
80
- "devtools-protocol": "^0.0.1548823",
78
+ "@vitest/coverage-v8": "4.0.15",
79
+ "devtools-protocol": "^0.0.1551306",
81
80
  "es-toolkit": "^1.42.0",
82
- "esbuild": "^0.27.0",
83
- "puppeteer-core": "^24.31.0",
84
- "tsx": "^4.20.6",
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.13"
85
+ "vitest": "^4.0.15"
87
86
  },
88
87
  "optionalDependencies": {
89
88
  "win-dpapi": "npm:@primno/dpapi@2.0.1"