@steipete/oracle 1.2.0 → 1.3.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 (34) hide show
  1. package/README.md +14 -6
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +161 -44
  4. package/dist/src/browser/config.js +6 -0
  5. package/dist/src/browser/cookies.js +49 -11
  6. package/dist/src/browser/index.js +18 -5
  7. package/dist/src/browser/sessionRunner.js +10 -1
  8. package/dist/src/cli/browserConfig.js +109 -2
  9. package/dist/src/cli/detach.js +12 -0
  10. package/dist/src/cli/dryRun.js +19 -3
  11. package/dist/src/cli/help.js +2 -0
  12. package/dist/src/cli/options.js +22 -0
  13. package/dist/src/cli/runOptions.js +16 -2
  14. package/dist/src/cli/sessionRunner.js +11 -0
  15. package/dist/src/cli/tui/index.js +68 -47
  16. package/dist/src/oracle/client.js +24 -6
  17. package/dist/src/oracle/config.js +10 -0
  18. package/dist/src/oracle/files.js +8 -2
  19. package/dist/src/oracle/format.js +2 -7
  20. package/dist/src/oracle/fsAdapter.js +4 -1
  21. package/dist/src/oracle/gemini.js +161 -0
  22. package/dist/src/oracle/logging.js +36 -0
  23. package/dist/src/oracle/oscProgress.js +7 -1
  24. package/dist/src/oracle/run.js +111 -48
  25. package/dist/src/oracle.js +1 -0
  26. package/dist/src/sessionManager.js +2 -0
  27. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  28. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  29. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  31. package/package.json +16 -26
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  34. package/vendor/oracle-notifier/README.md +0 -24
@@ -1,16 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
1
4
  import { DEFAULT_MODEL_TARGET, parseDuration } from '../browserMode.js';
2
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 900_000;
3
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
4
7
  const DEFAULT_CHROME_PROFILE = 'Default';
5
8
  const BROWSER_MODEL_LABELS = {
6
9
  'gpt-5-pro': 'GPT-5 Pro',
7
- 'gpt-5.1': 'ChatGPT 5.1',
10
+ 'gpt-5.1': 'GPT-5.1',
11
+ 'gemini-3-pro': 'Gemini 3 Pro',
8
12
  };
9
- export function buildBrowserConfig(options) {
13
+ export async function buildBrowserConfig(options) {
10
14
  const desiredModelOverride = options.browserModelLabel?.trim();
11
15
  const normalizedOverride = desiredModelOverride?.toLowerCase() ?? '';
12
16
  const baseModel = options.model.toLowerCase();
13
17
  const shouldUseOverride = normalizedOverride.length > 0 && normalizedOverride !== baseModel;
18
+ const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES);
19
+ const inline = await resolveInlineCookies({
20
+ inlineArg: options.browserInlineCookies,
21
+ inlineFileArg: options.browserInlineCookiesFile,
22
+ envPayload: process.env.ORACLE_BROWSER_COOKIES_JSON,
23
+ envFile: process.env.ORACLE_BROWSER_COOKIES_FILE,
24
+ cwd: process.cwd(),
25
+ });
14
26
  return {
15
27
  chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
16
28
  chromePath: options.browserChromePath ?? null,
@@ -20,6 +32,9 @@ export function buildBrowserConfig(options) {
20
32
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
21
33
  : undefined,
22
34
  cookieSync: options.browserNoCookieSync ? false : undefined,
35
+ cookieNames,
36
+ inlineCookies: inline?.cookies,
37
+ inlineCookiesSource: inline?.source ?? null,
23
38
  headless: options.browserHeadless ? true : undefined,
24
39
  keepBrowser: options.browserKeepBrowser ? true : undefined,
25
40
  hideWindow: options.browserHideWindow ? true : undefined,
@@ -42,3 +57,95 @@ export function resolveBrowserModelLabel(input, model) {
42
57
  }
43
58
  return trimmed;
44
59
  }
60
+ function parseCookieNames(raw) {
61
+ if (!raw)
62
+ return undefined;
63
+ const names = raw
64
+ .split(',')
65
+ .map((entry) => entry.trim())
66
+ .filter(Boolean);
67
+ return names.length ? names : undefined;
68
+ }
69
+ async function resolveInlineCookies({ inlineArg, inlineFileArg, envPayload, envFile, cwd, }) {
70
+ const tryLoad = async (source, allowPathResolution) => {
71
+ if (!source)
72
+ return undefined;
73
+ const trimmed = source.trim();
74
+ if (!trimmed)
75
+ return undefined;
76
+ if (allowPathResolution) {
77
+ const resolved = path.isAbsolute(trimmed) ? trimmed : path.join(cwd, trimmed);
78
+ try {
79
+ const stat = await fs.stat(resolved);
80
+ if (stat.isFile()) {
81
+ const fileContent = await fs.readFile(resolved, 'utf8');
82
+ const parsed = parseInlineCookiesPayload(fileContent);
83
+ if (parsed)
84
+ return parsed;
85
+ }
86
+ }
87
+ catch {
88
+ // not a file; treat as payload below
89
+ }
90
+ }
91
+ return parseInlineCookiesPayload(trimmed);
92
+ };
93
+ const sources = [
94
+ { value: inlineFileArg, allowPath: true, source: 'inline-file' },
95
+ { value: inlineArg, allowPath: true, source: 'inline-arg' },
96
+ { value: envFile, allowPath: true, source: 'env-file' },
97
+ { value: envPayload, allowPath: false, source: 'env-payload' },
98
+ ];
99
+ for (const { value, allowPath, source } of sources) {
100
+ const parsed = await tryLoad(value, allowPath);
101
+ if (parsed)
102
+ return { cookies: parsed, source };
103
+ }
104
+ // fallback: ~/.oracle/cookies.{json,base64}
105
+ const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
106
+ const candidates = ['cookies.json', 'cookies.base64'];
107
+ for (const file of candidates) {
108
+ const fullPath = path.join(oracleHome, file);
109
+ try {
110
+ const stat = await fs.stat(fullPath);
111
+ if (!stat.isFile())
112
+ continue;
113
+ const content = await fs.readFile(fullPath, 'utf8');
114
+ const parsed = parseInlineCookiesPayload(content);
115
+ if (parsed)
116
+ return { cookies: parsed, source: `home:${file}` };
117
+ }
118
+ catch {
119
+ // ignore missing/invalid
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+ function parseInlineCookiesPayload(raw) {
125
+ if (!raw)
126
+ return undefined;
127
+ const text = raw.trim();
128
+ if (!text)
129
+ return undefined;
130
+ let jsonPayload = text;
131
+ // Attempt base64 decode first; fall back to raw text on failure.
132
+ try {
133
+ const decoded = Buffer.from(text, 'base64').toString('utf8');
134
+ if (decoded.trim().startsWith('[')) {
135
+ jsonPayload = decoded;
136
+ }
137
+ }
138
+ catch {
139
+ // not base64; continue with raw text
140
+ }
141
+ try {
142
+ const parsed = JSON.parse(jsonPayload);
143
+ if (Array.isArray(parsed)) {
144
+ return parsed;
145
+ }
146
+ }
147
+ catch {
148
+ // invalid json; skip silently to keep this hidden flag non-fatal
149
+ }
150
+ return undefined;
151
+ }
@@ -0,0 +1,12 @@
1
+ export function shouldDetachSession({
2
+ // Params kept for future policy tweaks; currently only model/disableDetachEnv matter.
3
+ engine: _engine, model, waitPreference: _waitPreference, disableDetachEnv, }) {
4
+ if (disableDetachEnv)
5
+ return false;
6
+ // Gemini runs must stay inline: forcing detachment can launch the background session runner,
7
+ // which previously led to silent hangs when Gemini picked the browser path. Keep it simple: no detach.
8
+ if (model.startsWith('gemini'))
9
+ return false;
10
+ // For other models, keep legacy behavior (detach if allowed, then reattach when waitPreference=true).
11
+ return true;
12
+ }
@@ -2,9 +2,9 @@ import chalk from 'chalk';
2
2
  import { MODEL_CONFIGS, TOKENIZER_OPTIONS, DEFAULT_SYSTEM_PROMPT, buildPrompt, readFiles, getFileTokenStats, printFileTokenStats, } from '../oracle.js';
3
3
  import { assembleBrowserPrompt } from '../browser/prompt.js';
4
4
  import { buildTokenEstimateSuffix, formatAttachmentLabel } from '../browser/promptSummary.js';
5
- export async function runDryRunSummary({ engine, runOptions, cwd, version, log, }, deps = {}) {
5
+ export async function runDryRunSummary({ engine, runOptions, cwd, version, log, browserConfig, }, deps = {}) {
6
6
  if (engine === 'browser') {
7
- await runBrowserDryRun({ runOptions, cwd, version, log }, deps);
7
+ await runBrowserDryRun({ runOptions, cwd, version, log, browserConfig }, deps);
8
8
  return;
9
9
  }
10
10
  await runApiDryRun({ runOptions, cwd, version, log }, deps);
@@ -34,14 +34,30 @@ async function runApiDryRun({ runOptions, cwd, version, log, }, deps) {
34
34
  });
35
35
  printFileTokenStats(stats, { inputTokenBudget: inputBudget, log });
36
36
  }
37
- async function runBrowserDryRun({ runOptions, cwd, version, log, }, deps) {
37
+ async function runBrowserDryRun({ runOptions, cwd, version, log, browserConfig, }, deps) {
38
38
  const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt;
39
39
  const artifacts = await assemblePromptImpl(runOptions, { cwd });
40
40
  const suffix = buildTokenEstimateSuffix(artifacts);
41
41
  const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
42
42
  log(chalk.cyan(headerLine));
43
+ logBrowserCookieStrategy(browserConfig, log, 'dry-run');
43
44
  logBrowserFileSummary(artifacts, log, 'dry-run');
44
45
  }
46
+ function logBrowserCookieStrategy(browserConfig, log, label) {
47
+ if (!browserConfig)
48
+ return;
49
+ if (browserConfig.inlineCookies && browserConfig.inlineCookies.length > 0) {
50
+ const source = browserConfig.inlineCookiesSource ?? 'inline';
51
+ log(chalk.bold(`[${label}] Cookies: inline payload (${browserConfig.inlineCookies.length}) via ${source}.`));
52
+ return;
53
+ }
54
+ if (browserConfig.cookieSync === false) {
55
+ log(chalk.bold(`[${label}] Cookies: sync disabled (--browser-no-cookie-sync).`));
56
+ return;
57
+ }
58
+ const allowlist = browserConfig.cookieNames?.length ? browserConfig.cookieNames.join(', ') : 'all from Chrome profile';
59
+ log(chalk.bold(`[${label}] Cookies: copy from Chrome (${allowlist}).`));
60
+ }
45
61
  function logBrowserFileSummary(artifacts, log, label) {
46
62
  if (artifacts.attachments.length > 0) {
47
63
  const prefix = artifacts.bundled ? `[${label}] Bundled upload:` : `[${label}] Attachments to upload:`;
@@ -43,8 +43,10 @@ function renderHelpBanner(version, colors) {
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
47
  `${colors.bullet('•')} Attach lots of source (whole directories beat single files) and keep total input under ~196k tokens.`,
47
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
+ `${colors.bullet('•')} Best results: 6–30 sentences plus key source files; very short prompts often yield generic answers.`,
48
50
  `${colors.bullet('•')} Oracle is one-shot: it does not remember prior runs, so start fresh each time with full context.`,
49
51
  `${colors.bullet('•')} Run ${colors.accent('--files-report')} to inspect token spend before hitting the API.`,
50
52
  `${colors.bullet('•')} Non-preview runs spawn detached sessions (especially gpt-5-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent('oracle session <slug>')} to resume/inspect the existing run.`,
@@ -75,11 +75,30 @@ export function parseSearchOption(value) {
75
75
  export function normalizeModelOption(value) {
76
76
  return (value ?? '').trim();
77
77
  }
78
+ export function normalizeBaseUrl(value) {
79
+ const trimmed = value?.trim();
80
+ return trimmed?.length ? trimmed : undefined;
81
+ }
82
+ export function parseTimeoutOption(value) {
83
+ if (value == null)
84
+ return undefined;
85
+ const normalized = value.trim().toLowerCase();
86
+ if (normalized === 'auto')
87
+ return 'auto';
88
+ const parsed = Number.parseFloat(normalized);
89
+ if (Number.isNaN(parsed) || parsed <= 0) {
90
+ throw new InvalidArgumentError('Timeout must be a positive number of seconds or "auto".');
91
+ }
92
+ return parsed;
93
+ }
78
94
  export function resolveApiModel(modelValue) {
79
95
  const normalized = normalizeModelOption(modelValue).toLowerCase();
80
96
  if (normalized in MODEL_CONFIGS) {
81
97
  return normalized;
82
98
  }
99
+ if (normalized.includes('gemini')) {
100
+ return 'gemini-3-pro';
101
+ }
83
102
  throw new InvalidArgumentError(`Unsupported model "${modelValue}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`);
84
103
  }
85
104
  export function inferModelFromLabel(modelValue) {
@@ -90,6 +109,9 @@ export function inferModelFromLabel(modelValue) {
90
109
  if (normalized in MODEL_CONFIGS) {
91
110
  return normalized;
92
111
  }
112
+ if (normalized.includes('gemini')) {
113
+ return 'gemini-3-pro';
114
+ }
93
115
  if (normalized.includes('pro')) {
94
116
  return 'gpt-5-pro';
95
117
  }
@@ -1,14 +1,26 @@
1
1
  import { resolveEngine } from './engine.js';
2
- import { normalizeModelOption, inferModelFromLabel, resolveApiModel } from './options.js';
2
+ import { normalizeModelOption, inferModelFromLabel, resolveApiModel, normalizeBaseUrl } from './options.js';
3
+ import { resolveGeminiModelId } from '../oracle/gemini.js';
3
4
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, engine, userConfig, env = process.env, }) {
4
5
  const resolvedEngine = resolveEngineWithConfig({ engine, configEngine: userConfig?.engine, env });
6
+ const browserRequested = engine === 'browser';
5
7
  const cliModelArg = normalizeModelOption(model ?? userConfig?.model) || 'gpt-5-pro';
6
8
  const resolvedModel = resolvedEngine === 'browser' ? inferModelFromLabel(cliModelArg) : resolveApiModel(cliModelArg);
9
+ const isGemini = resolvedModel.startsWith('gemini');
10
+ // Keep the resolved model id alongside the canonical model name so we can log
11
+ // and dispatch the exact identifier (useful for Gemini preview aliases).
12
+ const effectiveModelId = isGemini ? resolveGeminiModelId(resolvedModel) : resolvedModel;
13
+ if (isGemini && browserRequested) {
14
+ throw new Error('Gemini is only supported via API. Use --engine api.');
15
+ }
16
+ // When Gemini is selected, always force API engine (overrides config/env auto browser).
17
+ const fixedEngine = isGemini ? 'api' : resolvedEngine;
7
18
  const promptWithSuffix = userConfig?.promptSuffix && userConfig.promptSuffix.trim().length > 0
8
19
  ? `${prompt.trim()}\n${userConfig.promptSuffix}`
9
20
  : prompt;
10
21
  const search = userConfig?.search !== 'off';
11
22
  const heartbeatIntervalMs = userConfig?.heartbeatSeconds !== undefined ? userConfig.heartbeatSeconds * 1000 : 30_000;
23
+ const baseUrl = normalizeBaseUrl(userConfig?.apiBaseUrl ?? env.OPENAI_BASE_URL);
12
24
  const runOptions = {
13
25
  prompt: promptWithSuffix,
14
26
  model: resolvedModel,
@@ -17,8 +29,10 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, engine,
17
29
  heartbeatIntervalMs,
18
30
  filesReport: userConfig?.filesReport,
19
31
  background: userConfig?.background,
32
+ baseUrl,
33
+ effectiveModelId,
20
34
  };
21
- return { runOptions, resolvedEngine };
35
+ return { runOptions, resolvedEngine: fixedEngine };
22
36
  }
23
37
  function resolveEngineWithConfig({ engine, configEngine, env, }) {
24
38
  if (engine)
@@ -17,6 +17,12 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
17
17
  const notificationSettings = notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env);
18
18
  try {
19
19
  if (mode === 'browser') {
20
+ if (runOptions.model.startsWith('gemini')) {
21
+ throw new Error('Gemini models are not available in browser mode. Re-run with --engine api.');
22
+ }
23
+ if (process.platform !== 'darwin') {
24
+ throw new Error('Browser engine is only supported on macOS today. Use --engine api instead, or run on macOS.');
25
+ }
20
26
  if (!browserConfig) {
21
27
  throw new Error('Missing browser configuration for session.');
22
28
  }
@@ -105,6 +111,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
105
111
  }
106
112
  : undefined,
107
113
  });
114
+ if (mode === 'browser') {
115
+ log(dim('Browser fallback:')); // guides users when automation breaks
116
+ log(dim('- Use --engine api to run the same prompt without Chrome.'));
117
+ log(dim('- Add --browser-bundle-files to bundle attachments into a single text file you can drag into ChatGPT.'));
118
+ }
108
119
  throw error;
109
120
  }
110
121
  }
@@ -16,79 +16,98 @@ const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
16
16
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
17
17
  const RECENT_WINDOW_HOURS = 24;
18
18
  const PAGE_SIZE = 10;
19
+ const STATUS_PAD = 9;
20
+ const MODEL_PAD = 13;
21
+ const MODE_PAD = 7;
22
+ const TIMESTAMP_PAD = 19;
23
+ const CHARS_PAD = 5;
24
+ const COST_PAD = 7;
19
25
  export async function launchTui({ version }) {
20
26
  const userConfig = (await loadUserConfig()).config;
21
27
  console.log(chalk.bold(`🧿 oracle v${version}`), dim('— Whispering your tokens to the silicon sage'));
22
28
  console.log('');
23
- let olderOffset = 0;
29
+ let showingOlder = false;
24
30
  for (;;) {
25
- const { recent, older, hasMoreOlder } = await fetchSessionBuckets(olderOffset);
31
+ const { recent, older, olderTotal } = await fetchSessionBuckets();
26
32
  const choices = [];
27
- if (recent.length > 0) {
28
- choices.push(new inquirer.Separator());
29
- choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
30
- choices.push(...recent.map(toSessionChoice));
31
- }
32
- else if (older.length > 0 && olderOffset === 0) {
33
- choices.push(new inquirer.Separator());
34
- choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
35
- choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
33
+ 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`);
34
+ // Start with a selectable row so focus never lands on a separator
35
+ choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
36
+ choices.push(new inquirer.Separator());
37
+ if (!showingOlder) {
38
+ if (recent.length > 0) {
39
+ choices.push(new inquirer.Separator(headerLabel));
40
+ choices.push(...recent.map(toSessionChoice));
41
+ }
42
+ else if (older.length > 0) {
43
+ // No recent entries; show first page of older.
44
+ choices.push(new inquirer.Separator(headerLabel));
45
+ choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
46
+ }
36
47
  }
37
- if (older.length > 0 && olderOffset > 0) {
38
- choices.push(new inquirer.Separator());
39
- choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
40
- choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
48
+ else if (older.length > 0) {
49
+ choices.push(new inquirer.Separator(headerLabel));
50
+ choices.push(...older.map(toSessionChoice));
41
51
  }
42
52
  choices.push(new inquirer.Separator());
43
53
  choices.push(new inquirer.Separator('Actions'));
44
54
  choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
45
- if (hasMoreOlder) {
46
- choices.push({ name: 'Load older', value: '__more__' });
55
+ if (!showingOlder && olderTotal > 0) {
56
+ choices.push({ name: 'Older page', value: '__older__' });
47
57
  }
48
- if (olderOffset > 0) {
49
- choices.push({ name: 'Back to recent', value: '__reset__' });
58
+ else {
59
+ choices.push({ name: 'Newer (recent)', value: '__reset__' });
50
60
  }
51
61
  choices.push({ name: 'Exit', value: '__exit__' });
52
- const { selection } = await inquirer.prompt([
53
- {
54
- name: 'selection',
55
- type: 'list',
56
- message: 'Select a session or action',
57
- choices,
58
- pageSize: 16,
59
- },
60
- ]);
62
+ const selection = await new Promise((resolve) => {
63
+ const prompt = inquirer.prompt([
64
+ {
65
+ name: 'selection',
66
+ type: 'list',
67
+ message: 'Select a session or action',
68
+ choices,
69
+ pageSize: 16,
70
+ loop: false,
71
+ },
72
+ ]);
73
+ prompt
74
+ .then(({ selection: answer }) => resolve(answer))
75
+ .catch((error) => {
76
+ console.error(chalk.red('Paging failed; returning to recent list.'), error instanceof Error ? error.message : error);
77
+ resolve('__reset__');
78
+ });
79
+ });
61
80
  if (selection === '__exit__') {
62
81
  console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
63
82
  return;
64
83
  }
65
- if (selection === '__more__') {
66
- olderOffset += PAGE_SIZE;
84
+ if (selection === '__ask__') {
85
+ await askOracleFlow(version, userConfig);
67
86
  continue;
68
87
  }
69
- if (selection === '__reset__') {
70
- olderOffset = 0;
88
+ if (selection === '__older__') {
89
+ showingOlder = true;
71
90
  continue;
72
91
  }
73
- if (selection === '__ask__') {
74
- await askOracleFlow(version, userConfig);
92
+ if (selection === '__reset__') {
93
+ showingOlder = false;
75
94
  continue;
76
95
  }
77
96
  await showSessionDetail(selection);
78
97
  }
79
98
  }
80
- async function fetchSessionBuckets(olderOffset) {
99
+ async function fetchSessionBuckets() {
81
100
  const all = await listSessionsMetadata();
82
101
  const cutoff = Date.now() - RECENT_WINDOW_HOURS * 60 * 60 * 1000;
83
102
  const recent = all.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff).slice(0, PAGE_SIZE);
84
103
  const olderAll = all.filter((meta) => new Date(meta.createdAt).getTime() < cutoff);
85
- const older = olderAll.slice(olderOffset, olderOffset + PAGE_SIZE);
86
- const hasMoreOlder = olderAll.length > olderOffset + PAGE_SIZE;
104
+ const older = olderAll.slice(0, PAGE_SIZE);
105
+ const hasMoreOlder = olderAll.length > PAGE_SIZE;
87
106
  if (recent.length === 0 && older.length === 0 && olderAll.length > 0) {
88
107
  // No recent entries; fall back to top 10 overall.
89
- return { recent: olderAll.slice(0, PAGE_SIZE), older: [], hasMoreOlder: olderAll.length > PAGE_SIZE };
108
+ return { recent: olderAll.slice(0, PAGE_SIZE), older: [], hasMoreOlder: olderAll.length > PAGE_SIZE, olderTotal: olderAll.length };
90
109
  }
91
- return { recent, older, hasMoreOlder };
110
+ return { recent, older, hasMoreOlder, olderTotal: olderAll.length };
92
111
  }
93
112
  function toSessionChoice(meta) {
94
113
  return {
@@ -103,10 +122,10 @@ function formatSessionLabel(meta) {
103
122
  const mode = meta.mode ?? meta.options?.mode ?? 'api';
104
123
  const slug = meta.id;
105
124
  const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
106
- const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(5)) : chalk.gray(' -');
125
+ const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(CHARS_PAD)) : chalk.gray(`${''.padStart(CHARS_PAD - 1)}-`);
107
126
  const cost = mode === 'browser' ? null : resolveCost(meta);
108
- const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(' -');
109
- return `${status} ${chalk.white(model.padEnd(10))} ${chalk.gray(mode.padEnd(7))} ${chalk.gray(created)} ${charLabel} ${costLabel} ${chalk.cyan(slug)}`;
127
+ const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(`${''.padStart(COST_PAD - 1)}-`);
128
+ 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)}`;
110
129
  }
111
130
  function resolveCost(meta) {
112
131
  if (meta.usage?.cost != null) {
@@ -124,7 +143,7 @@ function resolveCost(meta) {
124
143
  return cost > 0 ? cost : null;
125
144
  }
126
145
  function formatCostTable(cost) {
127
- return `$${cost.toFixed(3)}`.padStart(7);
146
+ return `$${cost.toFixed(3)}`.padStart(COST_PAD);
128
147
  }
129
148
  function formatTimestampAligned(iso) {
130
149
  const date = new Date(iso);
@@ -138,10 +157,12 @@ function formatTimestampAligned(iso) {
138
157
  second: undefined,
139
158
  hour12: true,
140
159
  };
141
- const formatted = date.toLocaleString(locale, opts);
160
+ let formatted = date.toLocaleString(locale, opts);
161
+ // Drop the comma and use double-space between date and time for alignment.
162
+ formatted = formatted.replace(', ', ' ');
142
163
  // Insert a leading space when hour is a single digit to align AM/PM column.
143
- // Example: "11/18/2025, 1:07:05 AM" -> "11/18/2025, 1:07:05 AM"
144
- return formatted.replace(/(, )(\d:)/, '$1 $2');
164
+ // Example: "11/18/2025 1:07 AM" -> "11/18/2025 1:07 AM"
165
+ return formatted.replace(/(\s)(\d:)/, '$1 $2');
145
166
  }
146
167
  function colorStatus(status) {
147
168
  const padded = status.padEnd(9);
@@ -346,7 +367,7 @@ async function askOracleFlow(version, userConfig) {
346
367
  background: undefined,
347
368
  };
348
369
  const browserConfig = mode === 'browser'
349
- ? buildBrowserConfig({
370
+ ? await buildBrowserConfig({
350
371
  browserChromeProfile: answers.chromeProfile,
351
372
  browserHeadless: answers.headless,
352
373
  browserHideWindow: answers.hideWindow,
@@ -1,16 +1,34 @@
1
- import OpenAI from 'openai';
1
+ import OpenAI, { AzureOpenAI } from 'openai';
2
2
  import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
+ import { createGeminiClient } from './gemini.js';
4
5
  const CUSTOM_CLIENT_FACTORY = loadCustomClientFactory();
5
6
  export function createDefaultClientFactory() {
6
7
  if (CUSTOM_CLIENT_FACTORY) {
7
8
  return CUSTOM_CLIENT_FACTORY;
8
9
  }
9
- return (key) => {
10
- const instance = new OpenAI({
11
- apiKey: key,
12
- timeout: 20 * 60 * 1000,
13
- });
10
+ return (key, options) => {
11
+ if (options?.model?.startsWith('gemini')) {
12
+ // Gemini client uses its own SDK; allow passing the already-resolved id for transparency/logging.
13
+ return createGeminiClient(key, options.model, options.resolvedModelId);
14
+ }
15
+ let instance;
16
+ if (options?.azure?.endpoint) {
17
+ instance = new AzureOpenAI({
18
+ apiKey: key,
19
+ endpoint: options.azure.endpoint,
20
+ apiVersion: options.azure.apiVersion,
21
+ deployment: options.azure.deployment,
22
+ timeout: 20 * 60 * 1000,
23
+ });
24
+ }
25
+ else {
26
+ instance = new OpenAI({
27
+ apiKey: key,
28
+ timeout: 20 * 60 * 1000,
29
+ baseURL: options?.baseUrl,
30
+ });
31
+ }
14
32
  return {
15
33
  responses: {
16
34
  stream: (body) => instance.responses.stream(body),
@@ -21,6 +21,16 @@ export const MODEL_CONFIGS = {
21
21
  },
22
22
  reasoning: { effort: 'high' },
23
23
  },
24
+ 'gemini-3-pro': {
25
+ model: 'gemini-3-pro',
26
+ tokenizer: countTokensGpt5Pro,
27
+ inputLimit: 200000,
28
+ pricing: {
29
+ inputPerToken: 2 / 1_000_000,
30
+ outputPerToken: 12 / 1_000_000,
31
+ },
32
+ reasoning: null,
33
+ },
24
34
  };
25
35
  export const DEFAULT_SYSTEM_PROMPT = [
26
36
  'You are Oracle, a focused one-shot problem solver.',
@@ -10,7 +10,7 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
10
10
  return [];
11
11
  }
12
12
  const partitioned = await partitionFileInputs(filePaths, cwd, fsModule);
13
- const useNativeFilesystem = fsModule === DEFAULT_FS;
13
+ const useNativeFilesystem = fsModule === DEFAULT_FS || isNativeFsModule(fsModule);
14
14
  let candidatePaths = [];
15
15
  if (useNativeFilesystem) {
16
16
  candidatePaths = await expandWithNativeGlob(partitioned, cwd);
@@ -292,6 +292,12 @@ function makeDirectoryPattern(relative) {
292
292
  }
293
293
  return `${stripTrailingSlashes(relative)}/**/*`;
294
294
  }
295
+ function isNativeFsModule(fsModule) {
296
+ return ((fsModule.__nativeFs === true ||
297
+ (fsModule.readFile === DEFAULT_FS.readFile &&
298
+ fsModule.stat === DEFAULT_FS.stat &&
299
+ fsModule.readdir === DEFAULT_FS.readdir)));
300
+ }
295
301
  function normalizeGlob(pattern, cwd) {
296
302
  if (!pattern) {
297
303
  return '';
@@ -339,7 +345,7 @@ function relativePath(targetPath, cwd) {
339
345
  }
340
346
  export function createFileSections(files, cwd = process.cwd()) {
341
347
  return files.map((file, index) => {
342
- const relative = path.relative(cwd, file.path) || file.path;
348
+ const relative = toPosix(path.relative(cwd, file.path) || file.path);
343
349
  const sectionText = [
344
350
  `### File ${index + 1}: ${relative}`,
345
351
  '```',
@@ -2,13 +2,8 @@ export function formatUSD(value) {
2
2
  if (!Number.isFinite(value)) {
3
3
  return 'n/a';
4
4
  }
5
- if (value >= 0.1) {
6
- return `$${value.toFixed(2)}`;
7
- }
8
- if (value >= 0.01) {
9
- return `$${value.toFixed(3)}`;
10
- }
11
- return `$${value.toFixed(6)}`;
5
+ // Display with 4 decimal places, rounding to $0.0001 minimum granularity.
6
+ return `$${value.toFixed(4)}`;
12
7
  }
13
8
  export function formatNumber(value, { estimated = false } = {}) {
14
9
  if (value == null) {
@@ -1,7 +1,10 @@
1
1
  export function createFsAdapter(fsModule) {
2
- return {
2
+ const adapter = {
3
3
  stat: (targetPath) => fsModule.stat(targetPath),
4
4
  readdir: (targetPath) => fsModule.readdir(targetPath),
5
5
  readFile: (targetPath, encoding) => fsModule.readFile(targetPath, encoding),
6
6
  };
7
+ // Mark adapters so downstream callers can treat them as native filesystem access.
8
+ adapter.__nativeFs = true;
9
+ return adapter;
7
10
  }