@steipete/oracle 1.0.7 → 1.1.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 (59) hide show
  1. package/README.md +3 -0
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +9 -3
  4. package/dist/markdansi/types/index.js +4 -0
  5. package/dist/oracle/bin/oracle-cli.js +472 -0
  6. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  7. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  8. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  9. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  10. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  11. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  12. package/dist/oracle/src/browser/config.js +33 -0
  13. package/dist/oracle/src/browser/constants.js +40 -0
  14. package/dist/oracle/src/browser/cookies.js +210 -0
  15. package/dist/oracle/src/browser/domDebug.js +36 -0
  16. package/dist/oracle/src/browser/index.js +331 -0
  17. package/dist/oracle/src/browser/pageActions.js +5 -0
  18. package/dist/oracle/src/browser/prompt.js +88 -0
  19. package/dist/oracle/src/browser/promptSummary.js +20 -0
  20. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  21. package/dist/oracle/src/browser/types.js +1 -0
  22. package/dist/oracle/src/browser/utils.js +62 -0
  23. package/dist/oracle/src/browserMode.js +1 -0
  24. package/dist/oracle/src/cli/browserConfig.js +44 -0
  25. package/dist/oracle/src/cli/dryRun.js +59 -0
  26. package/dist/oracle/src/cli/engine.js +17 -0
  27. package/dist/oracle/src/cli/errorUtils.js +9 -0
  28. package/dist/oracle/src/cli/help.js +70 -0
  29. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  30. package/dist/oracle/src/cli/options.js +103 -0
  31. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  32. package/dist/oracle/src/cli/rootAlias.js +30 -0
  33. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  34. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  35. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  36. package/dist/oracle/src/heartbeat.js +43 -0
  37. package/dist/oracle/src/oracle/client.js +48 -0
  38. package/dist/oracle/src/oracle/config.js +29 -0
  39. package/dist/oracle/src/oracle/errors.js +101 -0
  40. package/dist/oracle/src/oracle/files.js +220 -0
  41. package/dist/oracle/src/oracle/format.js +33 -0
  42. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  43. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  44. package/dist/oracle/src/oracle/request.js +48 -0
  45. package/dist/oracle/src/oracle/run.js +444 -0
  46. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  47. package/dist/oracle/src/oracle/types.js +1 -0
  48. package/dist/oracle/src/oracle.js +9 -0
  49. package/dist/oracle/src/sessionManager.js +205 -0
  50. package/dist/oracle/src/version.js +39 -0
  51. package/dist/src/cli/help.js +1 -0
  52. package/dist/src/cli/markdownRenderer.js +18 -0
  53. package/dist/src/cli/rootAlias.js +14 -0
  54. package/dist/src/cli/sessionCommand.js +60 -2
  55. package/dist/src/cli/sessionDisplay.js +129 -4
  56. package/dist/src/oracle/oscProgress.js +60 -0
  57. package/dist/src/oracle/run.js +63 -51
  58. package/dist/src/sessionManager.js +17 -0
  59. package/package.json +14 -22
@@ -0,0 +1,20 @@
1
+ import { formatBytes } from './utils.js';
2
+ export function buildTokenEstimateSuffix(artifacts) {
3
+ if (artifacts.tokenEstimateIncludesInlineFiles && artifacts.inlineFileCount > 0) {
4
+ const count = artifacts.inlineFileCount;
5
+ const plural = count === 1 ? '' : 's';
6
+ return ` (includes ${count} inline file${plural})`;
7
+ }
8
+ if (artifacts.attachments.length > 0) {
9
+ const count = artifacts.attachments.length;
10
+ const plural = count === 1 ? '' : 's';
11
+ return ` (prompt only; ${count} attachment${plural} excluded)`;
12
+ }
13
+ return '';
14
+ }
15
+ export function formatAttachmentLabel(attachment) {
16
+ if (typeof attachment.sizeBytes !== 'number' || Number.isNaN(attachment.sizeBytes)) {
17
+ return attachment.displayPath;
18
+ }
19
+ return `${attachment.displayPath} (${formatBytes(attachment.sizeBytes)})`;
20
+ }
@@ -0,0 +1,80 @@
1
+ import chalk from 'chalk';
2
+ import { formatElapsed } from '../oracle.js';
3
+ import { runBrowserMode } from '../browserMode.js';
4
+ import { assembleBrowserPrompt } from './prompt.js';
5
+ import { BrowserAutomationError } from '../oracle/errors.js';
6
+ export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log, cliVersion }, deps = {}) {
7
+ const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
8
+ const executeBrowser = deps.executeBrowser ?? runBrowserMode;
9
+ const promptArtifacts = await assemblePrompt(runOptions, { cwd });
10
+ if (runOptions.verbose) {
11
+ log(chalk.dim(`[verbose] Browser config: ${JSON.stringify({
12
+ ...browserConfig,
13
+ })}`));
14
+ log(chalk.dim(`[verbose] Browser prompt length: ${promptArtifacts.composerText.length} chars`));
15
+ if (promptArtifacts.attachments.length > 0) {
16
+ const attachmentList = promptArtifacts.attachments.map((attachment) => attachment.displayPath).join(', ');
17
+ log(chalk.dim(`[verbose] Browser attachments: ${attachmentList}`));
18
+ if (promptArtifacts.bundled) {
19
+ log(chalk.yellow(`[browser] More than 10 files provided; bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath} to satisfy ChatGPT upload limits.`));
20
+ }
21
+ }
22
+ else if (runOptions.file && runOptions.file.length > 0 && runOptions.browserInlineFiles) {
23
+ log(chalk.dim('[verbose] Browser inline file fallback enabled (pasting file contents).'));
24
+ }
25
+ }
26
+ const headerLine = `Oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
27
+ const automationLogger = ((message) => {
28
+ if (typeof message === 'string') {
29
+ log(message);
30
+ }
31
+ });
32
+ automationLogger.verbose = Boolean(runOptions.verbose);
33
+ automationLogger.sessionLog = log;
34
+ log(headerLine);
35
+ log(chalk.dim('Chrome automation does not stream output; this may take a minute...'));
36
+ let browserResult;
37
+ try {
38
+ browserResult = await executeBrowser({
39
+ prompt: promptArtifacts.composerText,
40
+ attachments: promptArtifacts.attachments,
41
+ config: browserConfig,
42
+ log: automationLogger,
43
+ heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
44
+ verbose: runOptions.verbose,
45
+ });
46
+ }
47
+ catch (error) {
48
+ if (error instanceof BrowserAutomationError) {
49
+ throw error;
50
+ }
51
+ const message = error instanceof Error ? error.message : 'Browser automation failed.';
52
+ throw new BrowserAutomationError(message, { stage: 'execute-browser' }, error);
53
+ }
54
+ if (!runOptions.silent) {
55
+ log(chalk.bold('Answer:'));
56
+ log(browserResult.answerMarkdown || browserResult.answerText || chalk.dim('(no text output)'));
57
+ log('');
58
+ }
59
+ const usage = {
60
+ inputTokens: promptArtifacts.estimatedInputTokens,
61
+ outputTokens: browserResult.answerTokens,
62
+ reasoningTokens: 0,
63
+ totalTokens: promptArtifacts.estimatedInputTokens + browserResult.answerTokens,
64
+ };
65
+ const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
66
+ const statsParts = [`${runOptions.model}[browser]`, `tok(i/o/r/t)=${tokensDisplay}`];
67
+ if (runOptions.file && runOptions.file.length > 0) {
68
+ statsParts.push(`files=${runOptions.file.length}`);
69
+ }
70
+ log(chalk.blue(`Finished in ${formatElapsed(browserResult.tookMs)} (${statsParts.join(' | ')})`));
71
+ return {
72
+ usage,
73
+ elapsedMs: browserResult.tookMs,
74
+ runtime: {
75
+ chromePid: browserResult.chromePid,
76
+ chromePort: browserResult.chromePort,
77
+ userDataDir: browserResult.userDataDir,
78
+ },
79
+ };
80
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ export function parseDuration(input, fallback) {
2
+ if (!input) {
3
+ return fallback;
4
+ }
5
+ const match = /^([0-9]+)(ms|s|m)?$/i.exec(input.trim());
6
+ if (!match) {
7
+ return fallback;
8
+ }
9
+ const value = Number(match[1]);
10
+ const unit = match[2]?.toLowerCase();
11
+ if (!unit || unit === 'ms') {
12
+ return value;
13
+ }
14
+ if (unit === 's') {
15
+ return value * 1000;
16
+ }
17
+ if (unit === 'm') {
18
+ return value * 60_000;
19
+ }
20
+ return fallback;
21
+ }
22
+ export function delay(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+ export function estimateTokenCount(text) {
26
+ if (!text) {
27
+ return 0;
28
+ }
29
+ const words = text.trim().split(/\s+/).filter(Boolean);
30
+ const estimate = Math.max(words.length * 0.75, text.length / 4);
31
+ return Math.max(1, Math.round(estimate));
32
+ }
33
+ export async function withRetries(task, options = {}) {
34
+ const { retries = 2, delayMs = 250, onRetry } = options;
35
+ let attempt = 0;
36
+ while (attempt <= retries) {
37
+ try {
38
+ return await task();
39
+ }
40
+ catch (error) {
41
+ if (attempt === retries) {
42
+ throw error;
43
+ }
44
+ attempt += 1;
45
+ onRetry?.(attempt, error);
46
+ await delay(delayMs * attempt);
47
+ }
48
+ }
49
+ throw new Error('withRetries exhausted without result');
50
+ }
51
+ export function formatBytes(size) {
52
+ if (!Number.isFinite(size) || size < 0) {
53
+ return 'n/a';
54
+ }
55
+ if (size < 1024) {
56
+ return `${size} B`;
57
+ }
58
+ if (size < 1024 * 1024) {
59
+ return `${(size / 1024).toFixed(1)} KB`;
60
+ }
61
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
62
+ }
@@ -0,0 +1 @@
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, } from './browser/index.js';
@@ -0,0 +1,44 @@
1
+ import { DEFAULT_MODEL_TARGET, parseDuration } from '../browserMode.js';
2
+ const DEFAULT_BROWSER_TIMEOUT_MS = 900_000;
3
+ const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
4
+ const DEFAULT_CHROME_PROFILE = 'Default';
5
+ const BROWSER_MODEL_LABELS = {
6
+ 'gpt-5-pro': 'GPT-5 Pro',
7
+ 'gpt-5.1': 'ChatGPT 5.1',
8
+ };
9
+ export function buildBrowserConfig(options) {
10
+ const desiredModelOverride = options.browserModelLabel?.trim();
11
+ const normalizedOverride = desiredModelOverride?.toLowerCase() ?? '';
12
+ const baseModel = options.model.toLowerCase();
13
+ const shouldUseOverride = normalizedOverride.length > 0 && normalizedOverride !== baseModel;
14
+ return {
15
+ chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
16
+ chromePath: options.browserChromePath ?? null,
17
+ url: options.browserUrl,
18
+ timeoutMs: options.browserTimeout ? parseDuration(options.browserTimeout, DEFAULT_BROWSER_TIMEOUT_MS) : undefined,
19
+ inputTimeoutMs: options.browserInputTimeout
20
+ ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
21
+ : undefined,
22
+ cookieSync: options.browserNoCookieSync ? false : undefined,
23
+ headless: options.browserHeadless ? true : undefined,
24
+ keepBrowser: options.browserKeepBrowser ? true : undefined,
25
+ hideWindow: options.browserHideWindow ? true : undefined,
26
+ desiredModel: shouldUseOverride ? desiredModelOverride : mapModelToBrowserLabel(options.model),
27
+ debug: options.verbose ? true : undefined,
28
+ allowCookieErrors: options.browserAllowCookieErrors ? true : undefined,
29
+ };
30
+ }
31
+ export function mapModelToBrowserLabel(model) {
32
+ return BROWSER_MODEL_LABELS[model] ?? DEFAULT_MODEL_TARGET;
33
+ }
34
+ export function resolveBrowserModelLabel(input, model) {
35
+ const trimmed = input?.trim?.() ?? '';
36
+ if (!trimmed) {
37
+ return mapModelToBrowserLabel(model);
38
+ }
39
+ const normalizedInput = trimmed.toLowerCase();
40
+ if (normalizedInput === model.toLowerCase()) {
41
+ return mapModelToBrowserLabel(model);
42
+ }
43
+ return trimmed;
44
+ }
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ import { MODEL_CONFIGS, TOKENIZER_OPTIONS, DEFAULT_SYSTEM_PROMPT, buildPrompt, readFiles, getFileTokenStats, printFileTokenStats, } from '../oracle.js';
3
+ import { assembleBrowserPrompt } from '../browser/prompt.js';
4
+ import { buildTokenEstimateSuffix, formatAttachmentLabel } from '../browser/promptSummary.js';
5
+ export async function runDryRunSummary({ engine, runOptions, cwd, version, log, }, deps = {}) {
6
+ if (engine === 'browser') {
7
+ await runBrowserDryRun({ runOptions, cwd, version, log }, deps);
8
+ return;
9
+ }
10
+ await runApiDryRun({ runOptions, cwd, version, log }, deps);
11
+ }
12
+ async function runApiDryRun({ runOptions, cwd, version, log, }, deps) {
13
+ const readFilesImpl = deps.readFilesImpl ?? readFiles;
14
+ const files = await readFilesImpl(runOptions.file ?? [], { cwd });
15
+ const systemPrompt = runOptions.system?.trim() || DEFAULT_SYSTEM_PROMPT;
16
+ const combinedPrompt = buildPrompt(runOptions.prompt ?? '', files, cwd);
17
+ const tokenizer = MODEL_CONFIGS[runOptions.model].tokenizer;
18
+ const estimatedInputTokens = tokenizer([
19
+ { role: 'system', content: systemPrompt },
20
+ { role: 'user', content: combinedPrompt },
21
+ ], TOKENIZER_OPTIONS);
22
+ const headerLine = `[dry-run] Oracle (${version}) would call ${runOptions.model} with ~${estimatedInputTokens.toLocaleString()} tokens and ${files.length} files.`;
23
+ log(chalk.cyan(headerLine));
24
+ if (files.length === 0) {
25
+ log(chalk.dim('[dry-run] No files matched the provided --file patterns.'));
26
+ return;
27
+ }
28
+ const inputBudget = runOptions.maxInput ?? MODEL_CONFIGS[runOptions.model].inputLimit;
29
+ const stats = getFileTokenStats(files, {
30
+ cwd,
31
+ tokenizer,
32
+ tokenizerOptions: TOKENIZER_OPTIONS,
33
+ inputTokenBudget: inputBudget,
34
+ });
35
+ printFileTokenStats(stats, { inputTokenBudget: inputBudget, log });
36
+ }
37
+ async function runBrowserDryRun({ runOptions, cwd, version, log, }, deps) {
38
+ const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt;
39
+ const artifacts = await assemblePromptImpl(runOptions, { cwd });
40
+ const suffix = buildTokenEstimateSuffix(artifacts);
41
+ const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
42
+ log(chalk.cyan(headerLine));
43
+ logBrowserFileSummary(artifacts, log);
44
+ }
45
+ function logBrowserFileSummary(artifacts, log) {
46
+ if (artifacts.attachments.length > 0) {
47
+ log(chalk.bold('[dry-run] Attachments to upload:'));
48
+ artifacts.attachments.forEach((attachment) => {
49
+ log(` • ${formatAttachmentLabel(attachment)}`);
50
+ });
51
+ return;
52
+ }
53
+ if (artifacts.inlineFileCount > 0) {
54
+ log(chalk.bold('[dry-run] Inline file content:'));
55
+ log(` • ${artifacts.inlineFileCount} file${artifacts.inlineFileCount === 1 ? '' : 's'} pasted directly into the composer.`);
56
+ return;
57
+ }
58
+ log(chalk.dim('[dry-run] No files attached.'));
59
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Determine which engine to use based on CLI flags and the environment.
3
+ *
4
+ * Precedence:
5
+ * 1) Legacy --browser flag forces browser.
6
+ * 2) Explicit --engine value.
7
+ * 3) OPENAI_API_KEY decides: api when set, otherwise browser.
8
+ */
9
+ export function resolveEngine({ engine, browserFlag, env, }) {
10
+ if (browserFlag) {
11
+ return 'browser';
12
+ }
13
+ if (engine) {
14
+ return engine;
15
+ }
16
+ return env.OPENAI_API_KEY ? 'api' : 'browser';
17
+ }
@@ -0,0 +1,9 @@
1
+ const LOGGED_SYMBOL = Symbol('oracle.alreadyLogged');
2
+ export function markErrorLogged(error) {
3
+ if (error instanceof Error) {
4
+ error[LOGGED_SYMBOL] = true;
5
+ }
6
+ }
7
+ export function isErrorLogged(error) {
8
+ return Boolean(error instanceof Error && error[LOGGED_SYMBOL]);
9
+ }
@@ -0,0 +1,70 @@
1
+ import kleur from 'kleur';
2
+ const createColorWrapper = (isTty) => (styler) => (text) => isTty ? styler(text) : text;
3
+ export function applyHelpStyling(program, version, isTty) {
4
+ const wrap = createColorWrapper(isTty);
5
+ const colors = {
6
+ banner: wrap((text) => kleur.bold().blue(text)),
7
+ subtitle: wrap((text) => kleur.dim(text)),
8
+ section: wrap((text) => kleur.bold().white(text)),
9
+ bullet: wrap((text) => kleur.blue(text)),
10
+ command: wrap((text) => kleur.bold().blue(text)),
11
+ option: wrap((text) => kleur.cyan(text)),
12
+ argument: wrap((text) => kleur.magenta(text)),
13
+ description: wrap((text) => kleur.white(text)),
14
+ muted: wrap((text) => kleur.gray(text)),
15
+ accent: wrap((text) => kleur.cyan(text)),
16
+ };
17
+ program.configureHelp({
18
+ styleTitle(title) {
19
+ return colors.section(title);
20
+ },
21
+ styleDescriptionText(text) {
22
+ return colors.description(text);
23
+ },
24
+ styleCommandText(text) {
25
+ return colors.command(text);
26
+ },
27
+ styleSubcommandText(text) {
28
+ return colors.command(text);
29
+ },
30
+ styleOptionText(text) {
31
+ return colors.option(text);
32
+ },
33
+ styleArgumentText(text) {
34
+ return colors.argument(text);
35
+ },
36
+ });
37
+ program.addHelpText('beforeAll', () => renderHelpBanner(version, colors));
38
+ program.addHelpText('after', () => renderHelpFooter(program, colors));
39
+ }
40
+ function renderHelpBanner(version, colors) {
41
+ const subtitle = 'GPT-5 Pro/GPT-5.1 for tough questions with code/file context.';
42
+ return `${colors.banner(`Oracle CLI v${version}`)} ${colors.subtitle(`— ${subtitle}`)}\n`;
43
+ }
44
+ function renderHelpFooter(program, colors) {
45
+ const tips = [
46
+ `${colors.bullet('•')} Attach lots of source (whole directories beat single files) and keep total input under ~196k tokens.`,
47
+ `${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.`,
48
+ `${colors.bullet('•')} Oracle is one-shot: it does not remember prior runs, so start fresh each time with full context.`,
49
+ `${colors.bullet('•')} Run ${colors.accent('--files-report')} to inspect token spend before hitting the API.`,
50
+ `${colors.bullet('•')} Non-preview runs spawn detached sessions so they keep streaming even if your terminal closes — reattach anytime via ${colors.accent('pnpm oracle session <slug>')}.`,
51
+ `${colors.bullet('•')} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
52
+ `${colors.bullet('•')} Finished sessions auto-hide preamble logs when reattached; raw timestamps remain in the saved log file.`,
53
+ `${colors.bullet('•')} Need hidden flags? Run ${colors.accent(`${program.name()} --help --verbose`)} to list search/token/browser overrides.`,
54
+ ].join('\n');
55
+ const formatExample = (command, description) => `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
56
+ const examples = [
57
+ formatExample(`${program.name()} --prompt "Summarize risks" --file docs/risk.md --files-report --preview`, 'Inspect tokens + files without calling the API.'),
58
+ formatExample(`${program.name()} --prompt "Explain bug" --file src/,docs/crash.log --files-report`, 'Attach src/ plus docs/crash.log, launch a background session, and capture the Session ID.'),
59
+ formatExample(`${program.name()} status --hours 72 --limit 50`, 'Show sessions from the last 72h (capped at 50 entries).'),
60
+ formatExample(`${program.name()} session <sessionId>`, 'Attach to a running/completed session and stream the saved transcript.'),
61
+ formatExample(`${program.name()} --prompt "Ship review" --slug "release-readiness-audit"`, 'Encourage the model to hand you a 3–5 word slug and pass it along with --slug.'),
62
+ ].join('\n\n');
63
+ return `
64
+ ${colors.section('Tips')}
65
+ ${tips}
66
+
67
+ ${colors.section('Examples')}
68
+ ${examples}
69
+ `;
70
+ }
@@ -0,0 +1,15 @@
1
+ import { render as renderMarkdown } from 'markdansi';
2
+ export function renderMarkdownAnsi(markdown) {
3
+ try {
4
+ return renderMarkdown(markdown, {
5
+ color: Boolean(process.stdout.isTTY),
6
+ width: process.stdout.columns,
7
+ wrap: true,
8
+ hyperlinks: false,
9
+ });
10
+ }
11
+ catch {
12
+ // Last-resort fallback: return the raw markdown so we never crash.
13
+ return markdown;
14
+ }
15
+ }
@@ -0,0 +1,103 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { MODEL_CONFIGS } from '../oracle.js';
3
+ export function collectPaths(value, previous = []) {
4
+ if (!value) {
5
+ return previous;
6
+ }
7
+ const nextValues = Array.isArray(value) ? value : [value];
8
+ return previous.concat(nextValues.flatMap((entry) => entry.split(',')).map((entry) => entry.trim()).filter(Boolean));
9
+ }
10
+ export function parseFloatOption(value) {
11
+ const parsed = Number.parseFloat(value);
12
+ if (Number.isNaN(parsed)) {
13
+ throw new InvalidArgumentError('Value must be a number.');
14
+ }
15
+ return parsed;
16
+ }
17
+ export function parseIntOption(value) {
18
+ if (value == null) {
19
+ return undefined;
20
+ }
21
+ const parsed = Number.parseInt(value, 10);
22
+ if (Number.isNaN(parsed)) {
23
+ throw new InvalidArgumentError('Value must be an integer.');
24
+ }
25
+ return parsed;
26
+ }
27
+ export function parseHeartbeatOption(value) {
28
+ if (value == null) {
29
+ return 30;
30
+ }
31
+ if (typeof value === 'number') {
32
+ if (Number.isNaN(value) || value < 0) {
33
+ throw new InvalidArgumentError('Heartbeat interval must be zero or a positive number.');
34
+ }
35
+ return value;
36
+ }
37
+ const normalized = value.trim().toLowerCase();
38
+ if (normalized.length === 0) {
39
+ return 30;
40
+ }
41
+ if (normalized === 'false' || normalized === 'off') {
42
+ return 0;
43
+ }
44
+ const parsed = Number.parseFloat(normalized);
45
+ if (Number.isNaN(parsed) || parsed < 0) {
46
+ throw new InvalidArgumentError('Heartbeat interval must be zero or a positive number.');
47
+ }
48
+ return parsed;
49
+ }
50
+ export function usesDefaultStatusFilters(cmd) {
51
+ const hoursSource = cmd.getOptionValueSource?.('hours') ?? 'default';
52
+ const limitSource = cmd.getOptionValueSource?.('limit') ?? 'default';
53
+ const allSource = cmd.getOptionValueSource?.('all') ?? 'default';
54
+ return hoursSource === 'default' && limitSource === 'default' && allSource === 'default';
55
+ }
56
+ export function resolvePreviewMode(value) {
57
+ if (typeof value === 'string' && value.length > 0) {
58
+ return value;
59
+ }
60
+ if (value === true) {
61
+ return 'summary';
62
+ }
63
+ return undefined;
64
+ }
65
+ export function parseSearchOption(value) {
66
+ const normalized = value.trim().toLowerCase();
67
+ if (['on', 'true', '1', 'yes'].includes(normalized)) {
68
+ return true;
69
+ }
70
+ if (['off', 'false', '0', 'no'].includes(normalized)) {
71
+ return false;
72
+ }
73
+ throw new InvalidArgumentError('Search mode must be "on" or "off".');
74
+ }
75
+ export function normalizeModelOption(value) {
76
+ return (value ?? '').trim();
77
+ }
78
+ export function resolveApiModel(modelValue) {
79
+ const normalized = normalizeModelOption(modelValue).toLowerCase();
80
+ if (normalized in MODEL_CONFIGS) {
81
+ return normalized;
82
+ }
83
+ throw new InvalidArgumentError(`Unsupported model "${modelValue}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`);
84
+ }
85
+ export function inferModelFromLabel(modelValue) {
86
+ const normalized = normalizeModelOption(modelValue).toLowerCase();
87
+ if (!normalized) {
88
+ return 'gpt-5-pro';
89
+ }
90
+ if (normalized in MODEL_CONFIGS) {
91
+ return normalized;
92
+ }
93
+ if (normalized.includes('pro')) {
94
+ return 'gpt-5-pro';
95
+ }
96
+ if (normalized.includes('5.1') || normalized.includes('5_1')) {
97
+ return 'gpt-5.1';
98
+ }
99
+ if (normalized.includes('instant') || normalized.includes('thinking') || normalized.includes('fast')) {
100
+ return 'gpt-5.1';
101
+ }
102
+ return 'gpt-5.1';
103
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Determine whether the CLI should enforce a prompt requirement based on raw args and options.
3
+ */
4
+ export function shouldRequirePrompt(rawArgs, options) {
5
+ const firstArg = rawArgs[0];
6
+ const bypassPrompt = Boolean(options.session ||
7
+ options.execSession ||
8
+ options.status ||
9
+ options.debugHelp ||
10
+ firstArg === 'status' ||
11
+ firstArg === 'session');
12
+ const requiresPrompt = options.renderMarkdown || Boolean(options.preview) || Boolean(options.dryRun) || !bypassPrompt;
13
+ return requiresPrompt && !options.prompt;
14
+ }
@@ -0,0 +1,30 @@
1
+ import { attachSession, showStatus } from './sessionDisplay.js';
2
+ const defaultDeps = {
3
+ attachSession,
4
+ showStatus,
5
+ };
6
+ export async function handleStatusFlag(options, deps = defaultDeps) {
7
+ if (!options.status) {
8
+ return false;
9
+ }
10
+ if (options.session) {
11
+ await deps.attachSession(options.session);
12
+ return true;
13
+ }
14
+ await deps.showStatus({ hours: 24, includeAll: false, limit: 100, showExamples: true });
15
+ return true;
16
+ }
17
+ const defaultSessionDeps = {
18
+ attachSession,
19
+ };
20
+ /**
21
+ * Hidden root-level alias to attach to a stored session (`--session <id>`).
22
+ * Returns true when the alias was handled so callers can short-circuit.
23
+ */
24
+ export async function handleSessionAlias(options, deps = defaultSessionDeps) {
25
+ if (!options.session) {
26
+ return false;
27
+ }
28
+ await deps.attachSession(options.session);
29
+ return true;
30
+ }
@@ -0,0 +1,77 @@
1
+ import { usesDefaultStatusFilters } from './options.js';
2
+ import { attachSession, showStatus } from './sessionDisplay.js';
3
+ import { deleteSessionsOlderThan } from '../sessionManager.js';
4
+ const defaultDependencies = {
5
+ showStatus,
6
+ attachSession,
7
+ usesDefaultStatusFilters,
8
+ deleteSessionsOlderThan,
9
+ };
10
+ const SESSION_OPTION_KEYS = new Set(['hours', 'limit', 'all', 'clear', 'clean', 'render', 'renderMarkdown']);
11
+ export async function handleSessionCommand(sessionId, command, deps = defaultDependencies) {
12
+ const sessionOptions = command.opts();
13
+ if (sessionOptions.verboseRender) {
14
+ process.env.ORACLE_VERBOSE_RENDER = '1';
15
+ }
16
+ const clearRequested = Boolean(sessionOptions.clear || sessionOptions.clean);
17
+ if (clearRequested) {
18
+ if (sessionId) {
19
+ console.error('Cannot combine a session ID with --clear. Remove the ID to delete cached sessions.');
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ const hours = sessionOptions.hours;
24
+ const includeAll = sessionOptions.all;
25
+ const result = await deps.deleteSessionsOlderThan({ hours, includeAll });
26
+ const scope = includeAll ? 'all stored sessions' : `sessions older than ${hours}h`;
27
+ console.log(formatSessionCleanupMessage(result, scope));
28
+ return;
29
+ }
30
+ if (sessionId === 'clear' || sessionId === 'clean') {
31
+ console.error('Session cleanup now uses --clear. Run "oracle session --clear --hours <n>" instead.');
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ if (!sessionId) {
36
+ const showExamples = deps.usesDefaultStatusFilters(command);
37
+ await deps.showStatus({
38
+ hours: sessionOptions.all ? Infinity : sessionOptions.hours,
39
+ includeAll: sessionOptions.all,
40
+ limit: sessionOptions.limit,
41
+ showExamples,
42
+ });
43
+ return;
44
+ }
45
+ // Surface any root-level flags that were provided but are ignored when attaching to a session.
46
+ const ignoredFlags = listIgnoredFlags(command);
47
+ if (ignoredFlags.length > 0) {
48
+ console.log(`Ignoring flags on session attach: ${ignoredFlags.join(', ')}`);
49
+ }
50
+ const renderMarkdown = Boolean(sessionOptions.render || sessionOptions.renderMarkdown);
51
+ await deps.attachSession(sessionId, { renderMarkdown });
52
+ }
53
+ export function formatSessionCleanupMessage(result, scope) {
54
+ const deletedLabel = `${result.deleted} ${result.deleted === 1 ? 'session' : 'sessions'}`;
55
+ const remainingLabel = `${result.remaining} ${result.remaining === 1 ? 'session' : 'sessions'} remain`;
56
+ const hint = 'Run "oracle session --clear --all" to delete everything.';
57
+ return `Deleted ${deletedLabel} (${scope}). ${remainingLabel}.\n${hint}`;
58
+ }
59
+ function listIgnoredFlags(command) {
60
+ const opts = command.optsWithGlobals();
61
+ const ignored = [];
62
+ for (const key of Object.keys(opts)) {
63
+ if (SESSION_OPTION_KEYS.has(key)) {
64
+ continue;
65
+ }
66
+ const source = command.getOptionValueSource?.(key);
67
+ if (source !== 'cli' && source !== 'env') {
68
+ continue;
69
+ }
70
+ const value = opts[key];
71
+ if (value === undefined || value === false || value === null) {
72
+ continue;
73
+ }
74
+ ignored.push(key);
75
+ }
76
+ return ignored;
77
+ }