@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.
- package/README.md +14 -6
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +161 -44
- package/dist/src/browser/config.js +6 -0
- package/dist/src/browser/cookies.js +49 -11
- package/dist/src/browser/index.js +18 -5
- package/dist/src/browser/sessionRunner.js +10 -1
- package/dist/src/cli/browserConfig.js +109 -2
- package/dist/src/cli/detach.js +12 -0
- package/dist/src/cli/dryRun.js +19 -3
- package/dist/src/cli/help.js +2 -0
- package/dist/src/cli/options.js +22 -0
- package/dist/src/cli/runOptions.js +16 -2
- package/dist/src/cli/sessionRunner.js +11 -0
- package/dist/src/cli/tui/index.js +68 -47
- package/dist/src/oracle/client.js +24 -6
- package/dist/src/oracle/config.js +10 -0
- package/dist/src/oracle/files.js +8 -2
- package/dist/src/oracle/format.js +2 -7
- package/dist/src/oracle/fsAdapter.js +4 -1
- package/dist/src/oracle/gemini.js +161 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/oscProgress.js +7 -1
- package/dist/src/oracle/run.js +111 -48
- package/dist/src/oracle.js +1 -0
- package/dist/src/sessionManager.js +2 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
- package/package.json +16 -26
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/build-notifier.sh +0 -0
- 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': '
|
|
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
|
+
}
|
package/dist/src/cli/dryRun.js
CHANGED
|
@@ -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:`;
|
package/dist/src/cli/help.js
CHANGED
|
@@ -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.`,
|
package/dist/src/cli/options.js
CHANGED
|
@@ -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
|
|
29
|
+
let showingOlder = false;
|
|
24
30
|
for (;;) {
|
|
25
|
-
const { recent, older,
|
|
31
|
+
const { recent, older, olderTotal } = await fetchSessionBuckets();
|
|
26
32
|
const choices = [];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
38
|
-
choices.push(new inquirer.Separator());
|
|
39
|
-
choices.push(
|
|
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 (
|
|
46
|
-
choices.push({ name: '
|
|
55
|
+
if (!showingOlder && olderTotal > 0) {
|
|
56
|
+
choices.push({ name: 'Older page', value: '__older__' });
|
|
47
57
|
}
|
|
48
|
-
|
|
49
|
-
choices.push({ name: '
|
|
58
|
+
else {
|
|
59
|
+
choices.push({ name: 'Newer (recent)', value: '__reset__' });
|
|
50
60
|
}
|
|
51
61
|
choices.push({ name: 'Exit', value: '__exit__' });
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 === '
|
|
66
|
-
|
|
84
|
+
if (selection === '__ask__') {
|
|
85
|
+
await askOracleFlow(version, userConfig);
|
|
67
86
|
continue;
|
|
68
87
|
}
|
|
69
|
-
if (selection === '
|
|
70
|
-
|
|
88
|
+
if (selection === '__older__') {
|
|
89
|
+
showingOlder = true;
|
|
71
90
|
continue;
|
|
72
91
|
}
|
|
73
|
-
if (selection === '
|
|
74
|
-
|
|
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(
|
|
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(
|
|
86
|
-
const hasMoreOlder = olderAll.length >
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
144
|
-
return formatted.replace(/(
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.',
|
package/dist/src/oracle/files.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
}
|