@steipete/oracle 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +29 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +37 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +301 -21
  18. package/dist/src/browser/prompt.js +3 -1
  19. package/dist/src/browser/reattach.js +59 -0
  20. package/dist/src/browser/sessionRunner.js +15 -1
  21. package/dist/src/browser/windowsCookies.js +2 -1
  22. package/dist/src/cli/browserConfig.js +11 -0
  23. package/dist/src/cli/browserDefaults.js +41 -0
  24. package/dist/src/cli/detach.js +2 -2
  25. package/dist/src/cli/dryRun.js +4 -2
  26. package/dist/src/cli/engine.js +2 -2
  27. package/dist/src/cli/help.js +2 -2
  28. package/dist/src/cli/options.js +2 -1
  29. package/dist/src/cli/runOptions.js +1 -1
  30. package/dist/src/cli/sessionDisplay.js +98 -5
  31. package/dist/src/cli/sessionRunner.js +39 -6
  32. package/dist/src/cli/tui/index.js +15 -18
  33. package/dist/src/heartbeat.js +2 -2
  34. package/dist/src/oracle/background.js +10 -2
  35. package/dist/src/oracle/client.js +17 -0
  36. package/dist/src/oracle/config.js +10 -2
  37. package/dist/src/oracle/errors.js +24 -4
  38. package/dist/src/oracle/modelResolver.js +144 -0
  39. package/dist/src/oracle/oscProgress.js +1 -1
  40. package/dist/src/oracle/run.js +82 -34
  41. package/dist/src/oracle/runUtils.js +12 -8
  42. package/dist/src/remote/server.js +214 -23
  43. package/dist/src/sessionManager.js +5 -2
  44. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  45. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  46. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  47. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  49. package/package.json +47 -46
  50. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  51. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  52. package/vendor/oracle-notifier/README.md +0 -24
@@ -0,0 +1,41 @@
1
+ import { normalizeChatgptUrl, CHATGPT_URL } from '../browserMode.js';
2
+ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
3
+ const browser = config.browser;
4
+ if (!browser)
5
+ return;
6
+ const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
7
+ const cliChatgptSet = options.chatgptUrl !== undefined || options.browserUrl !== undefined;
8
+ if ((getSource('chatgptUrl') === 'default' || getSource('chatgptUrl') === undefined) && !cliChatgptSet && configuredChatgptUrl !== undefined) {
9
+ options.chatgptUrl = normalizeChatgptUrl(configuredChatgptUrl ?? '', CHATGPT_URL);
10
+ }
11
+ if (getSource('browserChromeProfile') === 'default' && browser.chromeProfile !== undefined) {
12
+ options.browserChromeProfile = browser.chromeProfile ?? undefined;
13
+ }
14
+ if (getSource('browserChromePath') === 'default' && browser.chromePath !== undefined) {
15
+ options.browserChromePath = browser.chromePath ?? undefined;
16
+ }
17
+ if (getSource('browserCookiePath') === 'default' && browser.chromeCookiePath !== undefined) {
18
+ options.browserCookiePath = browser.chromeCookiePath ?? undefined;
19
+ }
20
+ if ((getSource('browserUrl') === 'default' || getSource('browserUrl') === undefined) && options.browserUrl === undefined && browser.url !== undefined) {
21
+ options.browserUrl = browser.url;
22
+ }
23
+ if (getSource('browserTimeout') === 'default' && typeof browser.timeoutMs === 'number') {
24
+ options.browserTimeout = String(browser.timeoutMs);
25
+ }
26
+ if (getSource('browserPort') === 'default' && typeof browser.debugPort === 'number') {
27
+ options.browserPort = browser.debugPort;
28
+ }
29
+ if (getSource('browserInputTimeout') === 'default' && typeof browser.inputTimeoutMs === 'number') {
30
+ options.browserInputTimeout = String(browser.inputTimeoutMs);
31
+ }
32
+ if (getSource('browserHeadless') === 'default' && browser.headless !== undefined) {
33
+ options.browserHeadless = browser.headless;
34
+ }
35
+ if (getSource('browserHideWindow') === 'default' && browser.hideWindow !== undefined) {
36
+ options.browserHideWindow = browser.hideWindow;
37
+ }
38
+ if (getSource('browserKeepBrowser') === 'default' && browser.keepBrowser !== undefined) {
39
+ options.browserKeepBrowser = browser.keepBrowser;
40
+ }
41
+ }
@@ -1,11 +1,11 @@
1
- import { PRO_MODELS } from '../oracle.js';
1
+ import { isProModel } from '../oracle/modelResolver.js';
2
2
  export function shouldDetachSession({
3
3
  // Params kept for future policy tweaks; currently only model/disableDetachEnv matter.
4
4
  engine, model, waitPreference: _waitPreference, disableDetachEnv, }) {
5
5
  if (disableDetachEnv)
6
6
  return false;
7
7
  // Only Pro-tier API runs should start detached by default; browser runs stay inline so failures surface.
8
- if (PRO_MODELS.has(model) && engine === 'api')
8
+ if (isProModel(model) && engine === 'api')
9
9
  return true;
10
10
  return false;
11
11
  }
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { MODEL_CONFIGS, TOKENIZER_OPTIONS, DEFAULT_SYSTEM_PROMPT, buildPrompt, readFiles, getFileTokenStats, printFileTokenStats, } from '../oracle.js';
3
+ import { isKnownModel } from '../oracle/modelResolver.js';
3
4
  import { assembleBrowserPrompt } from '../browser/prompt.js';
4
5
  import { buildTokenEstimateSuffix, formatAttachmentLabel } from '../browser/promptSummary.js';
5
6
  import { buildCookiePlan } from '../browser/policies.js';
@@ -15,7 +16,8 @@ async function runApiDryRun({ runOptions, cwd, version, log, }, deps) {
15
16
  const files = await readFilesImpl(runOptions.file ?? [], { cwd });
16
17
  const systemPrompt = runOptions.system?.trim() || DEFAULT_SYSTEM_PROMPT;
17
18
  const combinedPrompt = buildPrompt(runOptions.prompt ?? '', files, cwd);
18
- const tokenizer = MODEL_CONFIGS[runOptions.model].tokenizer;
19
+ const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
20
+ const tokenizer = modelConfig.tokenizer;
19
21
  const estimatedInputTokens = tokenizer([
20
22
  { role: 'system', content: systemPrompt },
21
23
  { role: 'user', content: combinedPrompt },
@@ -26,7 +28,7 @@ async function runApiDryRun({ runOptions, cwd, version, log, }, deps) {
26
28
  log(chalk.dim('[dry-run] No files matched the provided --file patterns.'));
27
29
  return;
28
30
  }
29
- const inputBudget = runOptions.maxInput ?? MODEL_CONFIGS[runOptions.model].inputLimit;
31
+ const inputBudget = runOptions.maxInput ?? modelConfig.inputLimit;
30
32
  const stats = getFileTokenStats(files, {
31
33
  cwd,
32
34
  tokenizer,
@@ -1,7 +1,7 @@
1
- import { PRO_MODELS } from '../oracle.js';
1
+ import { isProModel } from '../oracle/modelResolver.js';
2
2
  export function defaultWaitPreference(model, engine) {
3
3
  // Pro-class API runs can take a long time; prefer non-blocking unless explicitly overridden.
4
- if (engine === 'api' && PRO_MODELS.has(model)) {
4
+ if (engine === 'api' && isProModel(model)) {
5
5
  return false;
6
6
  }
7
7
  return true; // browser or non-pro models are fast enough to block by default
@@ -38,12 +38,12 @@ export function applyHelpStyling(program, version, isTty) {
38
38
  program.addHelpText('after', () => renderHelpFooter(program, colors));
39
39
  }
40
40
  function renderHelpBanner(version, colors) {
41
- const subtitle = 'GPT-5.1 Pro/GPT-5.1 for tough questions with code/file context.';
41
+ const subtitle = 'Prompt + files required — GPT-5.1 Pro/GPT-5.1 for tough questions with code/file context.';
42
42
  return `${colors.banner(`Oracle CLI v${version}`)} ${colors.subtitle(`— ${subtitle}`)}\n`;
43
43
  }
44
44
  function renderHelpFooter(program, colors) {
45
45
  const tips = [
46
- `${colors.bullet('•')} Oracle cannot see your project unless you pass ${colors.accent('--file …')} attach the files/dirs you want it to read.`,
46
+ `${colors.bullet('•')} Required: always pass a prompt AND ${colors.accent('--file …')} (directories/globs are fine); Oracle cannot see your project otherwise.`,
47
47
  `${colors.bullet('•')} Attach lots of source (whole directories beat single files) and keep total input under ~196k tokens.`,
48
48
  `${colors.bullet('•')} Oracle starts empty—open with a short project briefing (stack, services, build steps), spell out the question and prior attempts, and why it matters; the more explanation and context you provide, the better the response will be.`,
49
49
  `${colors.bullet('•')} Spell out the project + platform + version requirements (repo name, target OS/toolchain versions, API dependencies) so Oracle doesn’t guess defaults.`,
@@ -149,7 +149,8 @@ export function resolveApiModel(modelValue) {
149
149
  if (normalized.includes('gemini')) {
150
150
  return 'gemini-3-pro';
151
151
  }
152
- throw new InvalidArgumentError(`Unsupported model "${modelValue}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`);
152
+ // Passthrough for custom/OpenRouter model IDs.
153
+ return normalized;
153
154
  }
154
155
  export function inferModelFromLabel(modelValue) {
155
156
  const normalized = normalizeModelOption(modelValue).toLowerCase();
@@ -64,7 +64,7 @@ function resolveEngineWithConfig({ engine, configEngine, env, }) {
64
64
  return resolveEngine({ engine: undefined, env });
65
65
  }
66
66
  function resolveEffectiveModelId(model) {
67
- if (model.startsWith('gemini')) {
67
+ if (typeof model === 'string' && model.startsWith('gemini')) {
68
68
  return resolveGeminiModelId(model);
69
69
  }
70
70
  const config = MODEL_CONFIGS[model];
@@ -4,10 +4,24 @@ import { renderMarkdownAnsi } from './markdownRenderer.js';
4
4
  import { formatElapsed, formatUSD } from '../oracle/format.js';
5
5
  import { MODEL_CONFIGS } from '../oracle.js';
6
6
  import { sessionStore, wait } from '../sessionStore.js';
7
+ import { formatTokenCount, formatTokenValue } from '../oracle/runUtils.js';
8
+ import { resumeBrowserSession } from '../browser/reattach.js';
9
+ import { estimateTokenCount } from '../browser/utils.js';
7
10
  const isTty = () => Boolean(process.stdout.isTTY);
8
11
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
9
12
  export const MAX_RENDER_BYTES = 200_000;
10
13
  const MODEL_COLUMN_WIDTH = 18;
14
+ function isProcessAlive(pid) {
15
+ if (!pid)
16
+ return false;
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ return !(error instanceof Error && error.code === 'ESRCH');
23
+ }
24
+ }
11
25
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
12
26
  export async function showStatus({ hours, includeAll, limit, showExamples = false, modelFilter, }) {
13
27
  const metas = await sessionStore.listSessions();
@@ -55,7 +69,7 @@ function colorStatus(status, padded) {
55
69
  }
56
70
  }
57
71
  export async function attachSession(sessionId, options) {
58
- const metadata = await sessionStore.readSession(sessionId);
72
+ let metadata = await sessionStore.readSession(sessionId);
59
73
  if (!metadata) {
60
74
  console.error(chalk.red(`No session found with ID ${sessionId}`));
61
75
  process.exitCode = 1;
@@ -74,6 +88,65 @@ export async function attachSession(sessionId, options) {
74
88
  const initialStatus = metadata.status;
75
89
  const wantsRender = Boolean(options?.renderMarkdown);
76
90
  const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
91
+ const runtime = metadata.browser?.runtime;
92
+ const controllerAlive = isProcessAlive(runtime?.controllerPid);
93
+ const canReattach = metadata.status === 'running' &&
94
+ metadata.mode === 'browser' &&
95
+ runtime?.chromePort &&
96
+ (metadata.response?.incompleteReason === 'chrome-disconnected' || (runtime.controllerPid && !controllerAlive));
97
+ if (canReattach) {
98
+ const portInfo = runtime?.chromePort ? `port ${runtime.chromePort}` : 'unknown port';
99
+ const urlInfo = runtime?.tabUrl ? `url=${runtime.tabUrl}` : 'url=unknown';
100
+ console.log(chalk.yellow(`Attempting to reattach to the existing Chrome session (${portInfo}, ${urlInfo})...`));
101
+ try {
102
+ const result = await resumeBrowserSession(runtime, metadata.browser?.config, Object.assign(((message) => {
103
+ if (message) {
104
+ console.log(dim(message));
105
+ }
106
+ }), { verbose: true }));
107
+ const outputTokens = estimateTokenCount(result.answerMarkdown);
108
+ const logWriter = sessionStore.createLogWriter(sessionId);
109
+ logWriter.logLine('[reattach] captured assistant response from existing Chrome tab');
110
+ logWriter.logLine('Answer:');
111
+ logWriter.logLine(result.answerMarkdown || result.answerText);
112
+ logWriter.stream.end();
113
+ if (metadata.model) {
114
+ await sessionStore.updateModelRun(metadata.id, metadata.model, {
115
+ status: 'completed',
116
+ usage: {
117
+ inputTokens: 0,
118
+ outputTokens,
119
+ reasoningTokens: 0,
120
+ totalTokens: outputTokens,
121
+ },
122
+ completedAt: new Date().toISOString(),
123
+ });
124
+ }
125
+ await sessionStore.updateSession(sessionId, {
126
+ status: 'completed',
127
+ completedAt: new Date().toISOString(),
128
+ usage: {
129
+ inputTokens: 0,
130
+ outputTokens,
131
+ reasoningTokens: 0,
132
+ totalTokens: outputTokens,
133
+ },
134
+ browser: {
135
+ config: metadata.browser?.config,
136
+ runtime,
137
+ },
138
+ response: { status: 'completed' },
139
+ error: undefined,
140
+ transport: undefined,
141
+ });
142
+ console.log(chalk.green('Reattach succeeded; session marked completed.'));
143
+ metadata = (await sessionStore.readSession(sessionId)) ?? metadata;
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ console.log(chalk.red(`Reattach failed: ${message}`));
148
+ }
149
+ }
77
150
  if (!options?.suppressMetadata) {
78
151
  const reattachLine = buildReattachLine(metadata);
79
152
  if (reattachLine) {
@@ -85,7 +158,7 @@ export async function attachSession(sessionId, options) {
85
158
  console.log('Models:');
86
159
  for (const run of metadata.models) {
87
160
  const usage = run.usage
88
- ? ` tok=${run.usage.outputTokens?.toLocaleString() ?? 0}/${run.usage.totalTokens?.toLocaleString() ?? 0}`
161
+ ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}`
89
162
  : '';
90
163
  console.log(`- ${chalk.cyan(run.model)} — ${run.status}${usage}`);
91
164
  }
@@ -424,17 +497,25 @@ async function buildSessionLogForDisplay(sessionId, fallbackMeta, modelFilter) {
424
497
  }
425
498
  return await sessionStore.readLog(sessionId);
426
499
  }
427
- const candidates = normalizedFilter != null
500
+ const candidates = normalizedFilter
428
501
  ? models.filter((model) => model.model.toLowerCase() === normalizedFilter)
429
502
  : models;
430
503
  if (candidates.length === 0) {
431
504
  return '';
432
505
  }
433
506
  const sections = [];
507
+ let hasContent = false;
434
508
  for (const model of candidates) {
435
- const body = await sessionStore.readModelLog(sessionId, model.model);
509
+ const body = (await sessionStore.readModelLog(sessionId, model.model)) ?? '';
510
+ if (body.trim().length > 0) {
511
+ hasContent = true;
512
+ }
436
513
  sections.push(`=== ${model.model} ===\n${body}`.trimEnd());
437
514
  }
515
+ if (!hasContent) {
516
+ // Fallback for runs that recorded output only in the session log (e.g., browser runs without per-model logs).
517
+ return await sessionStore.readLog(sessionId);
518
+ }
438
519
  return sections.join('\n\n');
439
520
  }
440
521
  function extractRenderableChunks(text, state) {
@@ -499,7 +580,19 @@ export function formatCompletionSummary(metadata, options = {}) {
499
580
  const usage = metadata.usage;
500
581
  const cost = metadata.mode === 'browser' ? null : resolveCost(metadata);
501
582
  const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
502
- const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
583
+ const tokensDisplay = [
584
+ usage.inputTokens ?? 0,
585
+ usage.outputTokens ?? 0,
586
+ usage.reasoningTokens ?? 0,
587
+ usage.totalTokens ?? 0,
588
+ ]
589
+ .map((value, index) => formatTokenValue(value, {
590
+ input_tokens: usage.inputTokens,
591
+ output_tokens: usage.outputTokens,
592
+ reasoning_tokens: usage.reasoningTokens,
593
+ total_tokens: usage.totalTokens,
594
+ }, index))
595
+ .join('/');
503
596
  const filesCount = metadata.options?.file?.length ?? 0;
504
597
  const filesPart = filesCount > 0 ? ` | files=${filesCount}` : '';
505
598
  const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
@@ -10,6 +10,8 @@ import { sendSessionNotification, deriveNotificationSettingsFromMetadata, } from
10
10
  import { sessionStore } from '../sessionStore.js';
11
11
  import { runMultiModelApiSession } from '../oracle/multiModelRunner.js';
12
12
  import { MODEL_CONFIGS, DEFAULT_SYSTEM_PROMPT } from '../oracle/config.js';
13
+ import { isKnownModel } from '../oracle/modelResolver.js';
14
+ import { resolveModelConfig } from '../oracle/modelResolver.js';
13
15
  import { buildPrompt, buildRequestBody } from '../oracle/request.js';
14
16
  import { estimateRequestTokens } from '../oracle/tokenEstimate.js';
15
17
  import { formatTokenEstimate, formatTokenValue } from '../oracle/runUtils.js';
@@ -49,7 +51,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
49
51
  startedAt: new Date().toISOString(),
50
52
  });
51
53
  }
52
- const result = await runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, browserDeps);
54
+ const runnerDeps = {
55
+ ...browserDeps,
56
+ persistRuntimeHint: async (runtime) => {
57
+ await sessionStore.updateSession(sessionMeta.id, {
58
+ status: 'running',
59
+ browser: { config: browserConfig, runtime },
60
+ });
61
+ },
62
+ };
63
+ const result = await runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, runnerDeps);
53
64
  if (modelForStatus) {
54
65
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
55
66
  status: 'completed',
@@ -87,10 +98,10 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
87
98
  if (!primaryModel) {
88
99
  throw new Error('Missing model name for multi-model run.');
89
100
  }
90
- const modelConfig = MODEL_CONFIGS[primaryModel];
91
- if (!modelConfig) {
92
- throw new Error(`Unsupported model "${primaryModel}".`);
93
- }
101
+ const modelConfig = await resolveModelConfig(primaryModel, {
102
+ baseUrl: runOptions.baseUrl,
103
+ openRouterApiKey: process.env.OPENROUTER_API_KEY,
104
+ });
94
105
  const files = await readFiles(runOptions.file ?? [], { cwd });
95
106
  const promptWithFiles = buildPrompt(runOptions.prompt, files, cwd);
96
107
  const requestBody = buildRequestBody({
@@ -119,7 +130,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
119
130
  log(dim(tip));
120
131
  }
121
132
  // Surface long-running model expectations up front so users know why a response might lag.
122
- const longRunningModels = multiModels.filter((model) => MODEL_CONFIGS[model]?.reasoning?.effort === 'high');
133
+ const longRunningModels = multiModels.filter((model) => isKnownModel(model) && MODEL_CONFIGS[model]?.reasoning?.effort === 'high');
123
134
  if (longRunningModels.length > 0) {
124
135
  for (const model of longRunningModels) {
125
136
  log('');
@@ -299,6 +310,28 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
299
310
  log(`ERROR: ${message}`);
300
311
  markErrorLogged(error);
301
312
  const userError = asOracleUserError(error);
313
+ const connectionLost = userError?.category === 'browser-automation' && userError.details?.stage === 'connection-lost';
314
+ if (connectionLost && mode === 'browser') {
315
+ const runtime = userError.details?.runtime;
316
+ log(dim('Chrome disconnected before completion; keeping session running for reattach.'));
317
+ if (modelForStatus) {
318
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
319
+ status: 'running',
320
+ completedAt: undefined,
321
+ });
322
+ }
323
+ await sessionStore.updateSession(sessionMeta.id, {
324
+ status: 'running',
325
+ errorMessage: message,
326
+ mode,
327
+ browser: {
328
+ config: browserConfig,
329
+ runtime: runtime ?? sessionMeta.browser?.runtime,
330
+ },
331
+ response: { status: 'running', incompleteReason: 'chrome-disconnected' },
332
+ });
333
+ return;
334
+ }
302
335
  if (userError) {
303
336
  log(dim(`User error (${userError.category}): ${userError.message}`));
304
337
  }
@@ -12,13 +12,9 @@ import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from '../sessionDisplay.js';
12
12
  import { buildBrowserConfig, resolveBrowserModelLabel } from '../browserConfig.js';
13
13
  import { resolveNotificationSettings } from '../notifier.js';
14
14
  import { loadUserConfig } from '../../config.js';
15
+ import { formatTokenCount } from '../../oracle/runUtils.js';
15
16
  const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
16
17
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
17
- const disabledChoice = (label) => ({
18
- name: label,
19
- value: '__disabled__',
20
- disabled: true,
21
- });
22
18
  const RECENT_WINDOW_HOURS = 24;
23
19
  const PAGE_SIZE = 10;
24
20
  const STATUS_PAD = 9;
@@ -27,14 +23,16 @@ const MODE_PAD = 7;
27
23
  const TIMESTAMP_PAD = 19;
28
24
  const CHARS_PAD = 5;
29
25
  const COST_PAD = 7;
30
- export async function launchTui({ version }) {
26
+ export async function launchTui({ version, printIntro = true }) {
31
27
  const userConfig = (await loadUserConfig()).config;
32
28
  const rich = isTty();
33
- if (rich) {
34
- console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
35
- }
36
- else {
37
- console.log(`🧿 oracle ${version} — Whispering your tokens to the silicon sage`);
29
+ if (printIntro) {
30
+ if (rich) {
31
+ console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
32
+ }
33
+ else {
34
+ console.log(`🧿 oracle ${version} — Whispering your tokens to the silicon sage`);
35
+ }
38
36
  }
39
37
  console.log('');
40
38
  let showingOlder = false;
@@ -44,24 +42,23 @@ export async function launchTui({ version }) {
44
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`);
45
43
  // Start with a selectable row so focus never lands on a separator
46
44
  choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
47
- choices.push(disabledChoice(''));
48
45
  if (!showingOlder) {
49
46
  if (recent.length > 0) {
50
- choices.push(disabledChoice(headerLabel));
47
+ choices.push(new inquirer.Separator(headerLabel));
51
48
  choices.push(...recent.map(toSessionChoice));
52
49
  }
53
50
  else if (older.length > 0) {
54
51
  // No recent entries; show first page of older.
55
- choices.push(disabledChoice(headerLabel));
52
+ choices.push(new inquirer.Separator(headerLabel));
56
53
  choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
57
54
  }
58
55
  }
59
56
  else if (older.length > 0) {
60
- choices.push(disabledChoice(headerLabel));
57
+ choices.push(new inquirer.Separator(headerLabel));
61
58
  choices.push(...older.map(toSessionChoice));
62
59
  }
63
- choices.push(disabledChoice(''));
64
- choices.push(disabledChoice('Actions'));
60
+ choices.push(new inquirer.Separator(' '));
61
+ choices.push(new inquirer.Separator('Actions'));
65
62
  choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
66
63
  if (!showingOlder && olderTotal > 0) {
67
64
  choices.push({ name: 'Older page', value: '__older__' });
@@ -294,7 +291,7 @@ function printModelSummaries(models) {
294
291
  console.log(chalk.bold('Models:'));
295
292
  for (const run of models) {
296
293
  const usage = run.usage
297
- ? ` tok=${run.usage.outputTokens?.toLocaleString() ?? 0}/${run.usage.totalTokens?.toLocaleString() ?? 0}`
294
+ ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}`
298
295
  : '';
299
296
  console.log(` - ${chalk.cyan(run.model)} — ${run.status}${usage}`);
300
297
  }
@@ -7,7 +7,7 @@ export function startHeartbeat(config) {
7
7
  let pending = false;
8
8
  const start = Date.now();
9
9
  const timer = setInterval(async () => {
10
- // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
10
+ // stop flag flips asynchronously
11
11
  if (stopped || pending) {
12
12
  return;
13
13
  }
@@ -32,7 +32,7 @@ export function startHeartbeat(config) {
32
32
  }, intervalMs);
33
33
  timer.unref?.();
34
34
  const stop = () => {
35
- // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
35
+ // multiple callers may race to stop
36
36
  if (stopped) {
37
37
  return;
38
38
  }
@@ -9,7 +9,15 @@ const BACKGROUND_RETRY_BASE_MS = 3000;
9
9
  const BACKGROUND_RETRY_MAX_MS = 15000;
10
10
  export async function executeBackgroundResponse(params) {
11
11
  const { client, requestBody, log, wait, heartbeatIntervalMs, now, maxWaitMs } = params;
12
- const initialResponse = await client.responses.create(requestBody);
12
+ let initialResponse;
13
+ try {
14
+ initialResponse = await client.responses.create(requestBody);
15
+ }
16
+ catch (error) {
17
+ const transportError = toTransportError(error, requestBody.model);
18
+ log(chalk.yellow(describeTransportError(transportError, maxWaitMs)));
19
+ throw transportError;
20
+ }
13
21
  if (!initialResponse || !initialResponse.id) {
14
22
  throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
15
23
  }
@@ -60,7 +68,7 @@ async function pollBackgroundResponse(params) {
60
68
  // biome-ignore lint/nursery/noUnnecessaryConditions: intentional polling loop.
61
69
  while (true) {
62
70
  const status = response.status ?? 'completed';
63
- // biome-ignore lint/nursery/noUnnecessaryConditions: firstCycle toggles immediately; keep for clarity in logs.
71
+ // firstCycle toggles immediately; keep for clarity in logs.
64
72
  if (firstCycle) {
65
73
  firstCycle = false;
66
74
  log(chalk.dim(`API background response status=${status}. We'll keep retrying automatically.`));
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { createGeminiClient } from './gemini.js';
5
5
  import { createClaudeClient } from './claude.js';
6
+ import { isOpenRouterBaseUrl } from './modelResolver.js';
6
7
  export function createDefaultClientFactory() {
7
8
  const customFactory = loadCustomClientFactory();
8
9
  if (customFactory)
@@ -16,6 +17,9 @@ export function createDefaultClientFactory() {
16
17
  return createClaudeClient(key, options.model, options.resolvedModelId, options.baseUrl);
17
18
  }
18
19
  let instance;
20
+ const defaultHeaders = isOpenRouterBaseUrl(options?.baseUrl)
21
+ ? buildOpenRouterHeaders()
22
+ : undefined;
19
23
  if (options?.azure?.endpoint) {
20
24
  instance = new AzureOpenAI({
21
25
  apiKey: key,
@@ -30,6 +34,7 @@ export function createDefaultClientFactory() {
30
34
  apiKey: key,
31
35
  timeout: 20 * 60 * 1000,
32
36
  baseURL: options?.baseUrl,
37
+ defaultHeaders,
33
38
  });
34
39
  }
35
40
  return {
@@ -41,6 +46,18 @@ export function createDefaultClientFactory() {
41
46
  };
42
47
  };
43
48
  }
49
+ function buildOpenRouterHeaders() {
50
+ const headers = {};
51
+ const referer = process.env.OPENROUTER_REFERER ?? process.env.OPENROUTER_HTTP_REFERER ?? 'https://github.com/steipete/oracle';
52
+ const title = process.env.OPENROUTER_TITLE ?? 'Oracle CLI';
53
+ if (referer) {
54
+ headers['HTTP-Referer'] = referer;
55
+ }
56
+ if (title) {
57
+ headers['X-Title'] = title;
58
+ }
59
+ return headers;
60
+ }
44
61
  function loadCustomClientFactory() {
45
62
  const override = process.env.ORACLE_CLIENT_FACTORY;
46
63
  if (!override) {
@@ -3,11 +3,12 @@ import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro
3
3
  import { countTokens as countTokensAnthropicRaw } from '@anthropic-ai/tokenizer';
4
4
  import { stringifyTokenizerInput } from './tokenStringifier.js';
5
5
  export const DEFAULT_MODEL = 'gpt-5.1-pro';
6
- export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'claude-4.1-opus']);
6
+ export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'claude-4.5-sonnet', 'claude-4.1-opus']);
7
7
  const countTokensAnthropic = (input) => countTokensAnthropicRaw(stringifyTokenizerInput(input));
8
8
  export const MODEL_CONFIGS = {
9
9
  'gpt-5.1-pro': {
10
10
  model: 'gpt-5.1-pro',
11
+ provider: 'openai',
11
12
  tokenizer: countTokensGpt5Pro,
12
13
  inputLimit: 196000,
13
14
  pricing: {
@@ -18,6 +19,7 @@ export const MODEL_CONFIGS = {
18
19
  },
19
20
  'gpt-5-pro': {
20
21
  model: 'gpt-5-pro',
22
+ provider: 'openai',
21
23
  tokenizer: countTokensGpt5Pro,
22
24
  inputLimit: 196000,
23
25
  pricing: {
@@ -28,6 +30,7 @@ export const MODEL_CONFIGS = {
28
30
  },
29
31
  'gpt-5.1': {
30
32
  model: 'gpt-5.1',
33
+ provider: 'openai',
31
34
  tokenizer: countTokensGpt5,
32
35
  inputLimit: 196000,
33
36
  pricing: {
@@ -38,6 +41,7 @@ export const MODEL_CONFIGS = {
38
41
  },
39
42
  'gpt-5.1-codex': {
40
43
  model: 'gpt-5.1-codex',
44
+ provider: 'openai',
41
45
  tokenizer: countTokensGpt5,
42
46
  inputLimit: 196000,
43
47
  pricing: {
@@ -48,6 +52,7 @@ export const MODEL_CONFIGS = {
48
52
  },
49
53
  'gemini-3-pro': {
50
54
  model: 'gemini-3-pro',
55
+ provider: 'google',
51
56
  tokenizer: countTokensGpt5Pro,
52
57
  inputLimit: 200000,
53
58
  pricing: {
@@ -61,6 +66,7 @@ export const MODEL_CONFIGS = {
61
66
  'claude-4.5-sonnet': {
62
67
  model: 'claude-4.5-sonnet',
63
68
  apiModel: 'claude-sonnet-4-5',
69
+ provider: 'anthropic',
64
70
  tokenizer: countTokensAnthropic,
65
71
  inputLimit: 200000,
66
72
  pricing: {
@@ -74,6 +80,7 @@ export const MODEL_CONFIGS = {
74
80
  'claude-4.1-opus': {
75
81
  model: 'claude-4.1-opus',
76
82
  apiModel: 'claude-opus-4-1',
83
+ provider: 'anthropic',
77
84
  tokenizer: countTokensAnthropic,
78
85
  inputLimit: 200000,
79
86
  pricing: {
@@ -87,6 +94,7 @@ export const MODEL_CONFIGS = {
87
94
  'grok-4.1': {
88
95
  model: 'grok-4.1',
89
96
  apiModel: 'grok-4-1-fast-reasoning',
97
+ provider: 'xai',
90
98
  tokenizer: countTokensGpt5Pro,
91
99
  inputLimit: 2_000_000,
92
100
  pricing: {
@@ -101,6 +109,6 @@ export const MODEL_CONFIGS = {
101
109
  };
102
110
  export const DEFAULT_SYSTEM_PROMPT = [
103
111
  'You are Oracle, a focused one-shot problem solver.',
104
- 'Emphasize direct answers, cite any files referenced, and clearly note when the search tool was used.',
112
+ 'Emphasize direct answers and cite any files referenced.',
105
113
  ].join(' ');
106
114
  export const TOKENIZER_OPTIONS = { allowedSpecial: 'all' };