@steipete/oracle 1.1.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 (69) hide show
  1. package/README.md +40 -7
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/.DS_Store +0 -0
  4. package/dist/bin/oracle-cli.js +315 -47
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/src/browser/actions/modelSelection.js +117 -29
  7. package/dist/src/browser/config.js +6 -0
  8. package/dist/src/browser/cookies.js +50 -12
  9. package/dist/src/browser/index.js +19 -5
  10. package/dist/src/browser/prompt.js +6 -5
  11. package/dist/src/browser/sessionRunner.js +14 -3
  12. package/dist/src/cli/browserConfig.js +109 -2
  13. package/dist/src/cli/detach.js +12 -0
  14. package/dist/src/cli/dryRun.js +60 -8
  15. package/dist/src/cli/engine.js +7 -0
  16. package/dist/src/cli/help.js +3 -1
  17. package/dist/src/cli/hiddenAliases.js +17 -0
  18. package/dist/src/cli/markdownRenderer.js +79 -0
  19. package/dist/src/cli/notifier.js +223 -0
  20. package/dist/src/cli/options.js +22 -0
  21. package/dist/src/cli/promptRequirement.js +3 -0
  22. package/dist/src/cli/runOptions.js +43 -0
  23. package/dist/src/cli/sessionCommand.js +1 -1
  24. package/dist/src/cli/sessionDisplay.js +94 -7
  25. package/dist/src/cli/sessionRunner.js +32 -2
  26. package/dist/src/cli/tui/index.js +457 -0
  27. package/dist/src/config.js +27 -0
  28. package/dist/src/mcp/server.js +36 -0
  29. package/dist/src/mcp/tools/consult.js +158 -0
  30. package/dist/src/mcp/tools/sessionResources.js +64 -0
  31. package/dist/src/mcp/tools/sessions.js +106 -0
  32. package/dist/src/mcp/types.js +17 -0
  33. package/dist/src/mcp/utils.js +24 -0
  34. package/dist/src/oracle/client.js +24 -6
  35. package/dist/src/oracle/config.js +10 -0
  36. package/dist/src/oracle/files.js +151 -8
  37. package/dist/src/oracle/format.js +2 -7
  38. package/dist/src/oracle/fsAdapter.js +4 -1
  39. package/dist/src/oracle/gemini.js +161 -0
  40. package/dist/src/oracle/logging.js +36 -0
  41. package/dist/src/oracle/oscProgress.js +7 -1
  42. package/dist/src/oracle/run.js +148 -64
  43. package/dist/src/oracle/tokenEstimate.js +34 -0
  44. package/dist/src/oracle.js +1 -0
  45. package/dist/src/sessionManager.js +50 -3
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  48. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  49. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  50. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  51. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  52. package/dist/vendor/oracle-notifier/README.md +24 -0
  53. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  54. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  55. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  56. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  57. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  58. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  59. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  60. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  61. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  62. package/package.json +22 -6
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  68. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  69. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -1,10 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
- import { filterSessionsByRange, listSessionsMetadata, readSessionLog, readSessionMetadata, SESSIONS_DIR, wait, } from '../sessionManager.js';
3
+ import { filterSessionsByRange, listSessionsMetadata, readSessionLog, readSessionMetadata, readSessionRequest, SESSIONS_DIR, wait, } from '../sessionManager.js';
4
4
  import { renderMarkdownAnsi } from './markdownRenderer.js';
5
+ import { formatElapsed, formatUSD } from '../oracle/format.js';
6
+ import { MODEL_CONFIGS } from '../oracle.js';
5
7
  const isTty = () => Boolean(process.stdout.isTTY);
6
8
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
7
- const MAX_RENDER_BYTES = 200_000;
9
+ export const MAX_RENDER_BYTES = 200_000;
8
10
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
9
11
  export async function showStatus({ hours, includeAll, limit, showExamples = false }) {
10
12
  const metas = await listSessionsMetadata();
@@ -18,12 +20,17 @@ export async function showStatus({ hours, includeAll, limit, showExamples = fals
18
20
  return;
19
21
  }
20
22
  console.log(chalk.bold('Recent Sessions'));
23
+ console.log(chalk.dim('Timestamp Chars Cost Status Model ID'));
21
24
  for (const entry of entries) {
22
25
  const statusRaw = (entry.status || 'unknown').padEnd(9);
23
26
  const status = richTty ? colorStatus(entry.status ?? 'unknown', statusRaw) : statusRaw;
24
27
  const model = (entry.model || 'n/a').padEnd(9);
25
- const created = entry.createdAt.replace('T', ' ').replace('Z', '');
26
- console.log(`${created} | ${status} | ${model} | ${entry.id}`);
28
+ const created = formatTimestamp(entry.createdAt);
29
+ const chars = entry.options?.prompt?.length ?? entry.promptPreview?.length ?? 0;
30
+ const charLabel = chars > 0 ? String(chars).padStart(5) : ' -';
31
+ const costValue = resolveCost(entry);
32
+ const costLabel = costValue != null ? formatCostTable(costValue) : ' -';
33
+ console.log(`${created} | ${charLabel} | ${costLabel} | ${status} | ${model} | ${entry.id}`);
27
34
  }
28
35
  if (truncated) {
29
36
  console.log(chalk.yellow(`Showing ${entries.length} of ${total} sessions from the requested range. Run "oracle session --clear" or delete entries in ${SESSIONS_DIR} to free space, or rerun with --status-limit/--status-all.`));
@@ -76,6 +83,14 @@ export async function attachSession(sessionId, options) {
76
83
  }
77
84
  }
78
85
  const shouldTrimIntro = initialStatus === 'completed' || initialStatus === 'error';
86
+ if (options?.renderPrompt !== false) {
87
+ const prompt = await readStoredPrompt(sessionId);
88
+ if (prompt) {
89
+ console.log(chalk.bold('Prompt:'));
90
+ console.log(renderMarkdownAnsi(prompt));
91
+ console.log(dim('---'));
92
+ }
93
+ }
79
94
  if (shouldTrimIntro) {
80
95
  const fullLog = await readSessionLog(sessionId);
81
96
  const trimmed = trimBeforeFirstAnswer(fullLog);
@@ -104,6 +119,10 @@ export async function attachSession(sessionId, options) {
104
119
  else {
105
120
  process.stdout.write(trimmed);
106
121
  }
122
+ const summary = formatCompletionSummary(metadata, { includeSlug: true });
123
+ if (summary) {
124
+ console.log(`\n${chalk.green.bold(summary)}`);
125
+ }
107
126
  return;
108
127
  }
109
128
  if (wantsRender) {
@@ -187,9 +206,15 @@ export async function attachSession(sessionId, options) {
187
206
  console.log('\nResult:');
188
207
  console.log(`Session failed: ${latest.errorMessage}`);
189
208
  }
190
- if (latest.usage && initialStatus === 'running') {
191
- const usage = latest.usage;
192
- console.log(`\nFinished (tok i/o/r/t: ${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens})`);
209
+ if (latest.status === 'completed' && latest.usage) {
210
+ const summary = formatCompletionSummary(latest, { includeSlug: true });
211
+ if (summary) {
212
+ console.log(`\n${chalk.green.bold(summary)}`);
213
+ }
214
+ else {
215
+ const usage = latest.usage;
216
+ console.log(`\nFinished (tok i/o/r/t: ${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens})`);
217
+ }
193
218
  }
194
219
  }
195
220
  break;
@@ -359,3 +384,65 @@ function extractRenderableChunks(text, state) {
359
384
  }
360
385
  return { chunks, remainder: buffer };
361
386
  }
387
+ function formatTimestamp(iso) {
388
+ const date = new Date(iso);
389
+ const locale = 'en-US';
390
+ const opts = {
391
+ year: 'numeric',
392
+ month: '2-digit',
393
+ day: '2-digit',
394
+ hour: 'numeric',
395
+ minute: '2-digit',
396
+ second: undefined,
397
+ hour12: true,
398
+ };
399
+ const formatted = date.toLocaleString(locale, opts);
400
+ return formatted.replace(/(, )(\d:)/, '$1 $2');
401
+ }
402
+ export function formatCompletionSummary(metadata, options = {}) {
403
+ if (!metadata.usage || metadata.elapsedMs == null) {
404
+ return null;
405
+ }
406
+ const modeLabel = metadata.mode === 'browser' ? `${metadata.model ?? 'n/a'}[browser]` : metadata.model ?? 'n/a';
407
+ const usage = metadata.usage;
408
+ const cost = metadata.mode === 'browser' ? null : resolveCost(metadata);
409
+ const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
410
+ const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
411
+ const filesCount = metadata.options?.file?.length ?? 0;
412
+ const filesPart = filesCount > 0 ? ` | files=${filesCount}` : '';
413
+ const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
414
+ return `Finished in ${formatElapsed(metadata.elapsedMs)} (${modeLabel}${costPart} | tok(i/o/r/t)=${tokensDisplay}${filesPart}${slugPart})`;
415
+ }
416
+ function resolveCost(metadata) {
417
+ if (metadata.mode === 'browser') {
418
+ return null;
419
+ }
420
+ if (metadata.usage?.cost != null) {
421
+ return metadata.usage.cost;
422
+ }
423
+ if (!metadata.model || !metadata.usage) {
424
+ return null;
425
+ }
426
+ const pricing = MODEL_CONFIGS[metadata.model]?.pricing;
427
+ if (!pricing) {
428
+ return null;
429
+ }
430
+ const input = metadata.usage.inputTokens ?? 0;
431
+ const output = metadata.usage.outputTokens ?? 0;
432
+ const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
433
+ return cost > 0 ? cost : null;
434
+ }
435
+ function formatCostTable(cost) {
436
+ return `$${cost.toFixed(3)}`.padStart(7);
437
+ }
438
+ async function readStoredPrompt(sessionId) {
439
+ const request = await readSessionRequest(sessionId);
440
+ if (request?.prompt && request.prompt.trim().length > 0) {
441
+ return request.prompt;
442
+ }
443
+ const meta = await readSessionMetadata(sessionId);
444
+ if (meta?.options?.prompt && meta.options.prompt.trim().length > 0) {
445
+ return meta.options.prompt;
446
+ }
447
+ return null;
448
+ }
@@ -1,20 +1,28 @@
1
1
  import kleur from 'kleur';
2
2
  import { updateSessionMetadata } from '../sessionManager.js';
3
- import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, } from '../oracle.js';
3
+ import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from '../oracle.js';
4
4
  import { runBrowserSessionExecution } from '../browser/sessionRunner.js';
5
5
  import { formatResponseMetadata, formatTransportMetadata } from './sessionDisplay.js';
6
6
  import { markErrorLogged } from './errorUtils.js';
7
+ import { sendSessionNotification, deriveNotificationSettingsFromMetadata, } from './notifier.js';
7
8
  const isTty = process.stdout.isTTY;
8
9
  const dim = (text) => (isTty ? kleur.dim(text) : text);
9
- export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, }) {
10
+ export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, }) {
10
11
  await updateSessionMetadata(sessionMeta.id, {
11
12
  status: 'running',
12
13
  startedAt: new Date().toISOString(),
13
14
  mode,
14
15
  ...(browserConfig ? { browser: { config: browserConfig } } : {}),
15
16
  });
17
+ const notificationSettings = notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env);
16
18
  try {
17
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
+ }
18
26
  if (!browserConfig) {
19
27
  throw new Error('Missing browser configuration for session.');
20
28
  }
@@ -32,6 +40,14 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
32
40
  transport: undefined,
33
41
  error: undefined,
34
42
  });
43
+ await sendSessionNotification({
44
+ sessionId: sessionMeta.id,
45
+ sessionName: sessionMeta.options?.slug ?? sessionMeta.id,
46
+ mode,
47
+ model: sessionMeta.model,
48
+ usage: result.usage,
49
+ characters: result.answerText?.length,
50
+ }, notificationSettings, log, result.answerText?.slice(0, 140));
35
51
  return;
36
52
  }
37
53
  const result = await runOracle(runOptions, {
@@ -51,6 +67,15 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
51
67
  transport: undefined,
52
68
  error: undefined,
53
69
  });
70
+ const answerText = extractTextOutput(result.response);
71
+ await sendSessionNotification({
72
+ sessionId: sessionMeta.id,
73
+ sessionName: sessionMeta.options?.slug ?? sessionMeta.id,
74
+ mode,
75
+ model: sessionMeta.model ?? runOptions.model,
76
+ usage: result.usage,
77
+ characters: answerText.length,
78
+ }, notificationSettings, log, answerText.slice(0, 140));
54
79
  }
55
80
  catch (error) {
56
81
  const message = formatError(error);
@@ -86,6 +111,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
86
111
  }
87
112
  : undefined,
88
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
+ }
89
119
  throw error;
90
120
  }
91
121
  }
@@ -0,0 +1,457 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import kleur from 'kleur';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs/promises';
7
+ import { MODEL_CONFIGS } from '../../oracle.js';
8
+ import { renderMarkdownAnsi } from '../markdownRenderer.js';
9
+ import { createSessionLogWriter, getSessionPaths, initializeSession, listSessionsMetadata, readSessionLog, readSessionMetadata, readSessionRequest, ensureSessionStorage, } from '../../sessionManager.js';
10
+ import { performSessionRun } from '../sessionRunner.js';
11
+ import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from '../sessionDisplay.js';
12
+ import { buildBrowserConfig, resolveBrowserModelLabel } from '../browserConfig.js';
13
+ import { resolveNotificationSettings } from '../notifier.js';
14
+ import { loadUserConfig } from '../../config.js';
15
+ const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
16
+ const dim = (text) => (isTty() ? kleur.dim(text) : text);
17
+ const RECENT_WINDOW_HOURS = 24;
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;
25
+ export async function launchTui({ version }) {
26
+ const userConfig = (await loadUserConfig()).config;
27
+ console.log(chalk.bold(`🧿 oracle v${version}`), dim('— Whispering your tokens to the silicon sage'));
28
+ console.log('');
29
+ let showingOlder = false;
30
+ for (;;) {
31
+ const { recent, older, olderTotal } = await fetchSessionBuckets();
32
+ const choices = [];
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
+ }
47
+ }
48
+ else if (older.length > 0) {
49
+ choices.push(new inquirer.Separator(headerLabel));
50
+ choices.push(...older.map(toSessionChoice));
51
+ }
52
+ choices.push(new inquirer.Separator());
53
+ choices.push(new inquirer.Separator('Actions'));
54
+ choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
55
+ if (!showingOlder && olderTotal > 0) {
56
+ choices.push({ name: 'Older page', value: '__older__' });
57
+ }
58
+ else {
59
+ choices.push({ name: 'Newer (recent)', value: '__reset__' });
60
+ }
61
+ choices.push({ name: 'Exit', value: '__exit__' });
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
+ });
80
+ if (selection === '__exit__') {
81
+ console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
82
+ return;
83
+ }
84
+ if (selection === '__ask__') {
85
+ await askOracleFlow(version, userConfig);
86
+ continue;
87
+ }
88
+ if (selection === '__older__') {
89
+ showingOlder = true;
90
+ continue;
91
+ }
92
+ if (selection === '__reset__') {
93
+ showingOlder = false;
94
+ continue;
95
+ }
96
+ await showSessionDetail(selection);
97
+ }
98
+ }
99
+ async function fetchSessionBuckets() {
100
+ const all = await listSessionsMetadata();
101
+ const cutoff = Date.now() - RECENT_WINDOW_HOURS * 60 * 60 * 1000;
102
+ const recent = all.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff).slice(0, PAGE_SIZE);
103
+ const olderAll = all.filter((meta) => new Date(meta.createdAt).getTime() < cutoff);
104
+ const older = olderAll.slice(0, PAGE_SIZE);
105
+ const hasMoreOlder = olderAll.length > PAGE_SIZE;
106
+ if (recent.length === 0 && older.length === 0 && olderAll.length > 0) {
107
+ // No recent entries; fall back to top 10 overall.
108
+ return { recent: olderAll.slice(0, PAGE_SIZE), older: [], hasMoreOlder: olderAll.length > PAGE_SIZE, olderTotal: olderAll.length };
109
+ }
110
+ return { recent, older, hasMoreOlder, olderTotal: olderAll.length };
111
+ }
112
+ function toSessionChoice(meta) {
113
+ return {
114
+ name: formatSessionLabel(meta),
115
+ value: meta.id,
116
+ };
117
+ }
118
+ function formatSessionLabel(meta) {
119
+ const status = colorStatus(meta.status ?? 'unknown');
120
+ const created = formatTimestampAligned(meta.createdAt);
121
+ const model = meta.model ?? 'n/a';
122
+ const mode = meta.mode ?? meta.options?.mode ?? 'api';
123
+ const slug = meta.id;
124
+ const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
125
+ const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(CHARS_PAD)) : chalk.gray(`${''.padStart(CHARS_PAD - 1)}-`);
126
+ const cost = mode === 'browser' ? null : resolveCost(meta);
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)}`;
129
+ }
130
+ function resolveCost(meta) {
131
+ if (meta.usage?.cost != null) {
132
+ return meta.usage.cost;
133
+ }
134
+ if (!meta.model || !meta.usage) {
135
+ return null;
136
+ }
137
+ const pricing = MODEL_CONFIGS[meta.model]?.pricing;
138
+ if (!pricing)
139
+ return null;
140
+ const input = meta.usage.inputTokens ?? 0;
141
+ const output = meta.usage.outputTokens ?? 0;
142
+ const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
143
+ return cost > 0 ? cost : null;
144
+ }
145
+ function formatCostTable(cost) {
146
+ return `$${cost.toFixed(3)}`.padStart(COST_PAD);
147
+ }
148
+ function formatTimestampAligned(iso) {
149
+ const date = new Date(iso);
150
+ const locale = 'en-US';
151
+ const opts = {
152
+ year: 'numeric',
153
+ month: '2-digit',
154
+ day: '2-digit',
155
+ hour: 'numeric',
156
+ minute: '2-digit',
157
+ second: undefined,
158
+ hour12: true,
159
+ };
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(', ', ' ');
163
+ // Insert a leading space when hour is a single digit to align AM/PM column.
164
+ // Example: "11/18/2025 1:07 AM" -> "11/18/2025 1:07 AM"
165
+ return formatted.replace(/(\s)(\d:)/, '$1 $2');
166
+ }
167
+ function colorStatus(status) {
168
+ const padded = status.padEnd(9);
169
+ switch (status) {
170
+ case 'completed':
171
+ return chalk.green(padded);
172
+ case 'error':
173
+ return chalk.red(padded);
174
+ case 'running':
175
+ return chalk.yellow(padded);
176
+ default:
177
+ return padded;
178
+ }
179
+ }
180
+ async function showSessionDetail(sessionId) {
181
+ for (;;) {
182
+ const meta = await readSessionMetadataSafe(sessionId);
183
+ if (!meta) {
184
+ console.log(chalk.red(`No session found with ID ${sessionId}`));
185
+ return;
186
+ }
187
+ console.clear();
188
+ printSessionHeader(meta);
189
+ const prompt = await readStoredPrompt(sessionId);
190
+ if (prompt) {
191
+ console.log(chalk.bold('Prompt:'));
192
+ console.log(renderMarkdownAnsi(prompt));
193
+ console.log(dim('---'));
194
+ }
195
+ const logPath = await getSessionLogPath(sessionId);
196
+ if (logPath) {
197
+ console.log(dim(`Log file: ${logPath}`));
198
+ }
199
+ console.log('');
200
+ await renderSessionLog(sessionId);
201
+ const isRunning = meta.status === 'running';
202
+ const actions = [
203
+ ...(isRunning ? [{ name: 'Refresh', value: 'refresh' }] : []),
204
+ { name: 'Back', value: 'back' },
205
+ ];
206
+ const { next } = await inquirer.prompt([
207
+ {
208
+ name: 'next',
209
+ type: 'list',
210
+ message: 'Actions',
211
+ choices: actions,
212
+ },
213
+ ]);
214
+ if (next === 'back') {
215
+ return;
216
+ }
217
+ // refresh loops
218
+ }
219
+ }
220
+ async function renderSessionLog(sessionId) {
221
+ const raw = await readSessionLog(sessionId);
222
+ const text = trimBeforeFirstAnswer(raw);
223
+ const size = Buffer.byteLength(text, 'utf8');
224
+ if (size > MAX_RENDER_BYTES) {
225
+ console.log(chalk.yellow(`Log is large (${size.toLocaleString()} bytes). Rendering raw text; open the log file for full context.`));
226
+ process.stdout.write(text);
227
+ console.log('');
228
+ return;
229
+ }
230
+ process.stdout.write(renderMarkdownAnsi(text));
231
+ console.log('');
232
+ }
233
+ async function getSessionLogPath(sessionId) {
234
+ try {
235
+ const paths = await getSessionPaths(sessionId);
236
+ return paths.log;
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ function printSessionHeader(meta) {
243
+ console.log(chalk.bold(`Session ${chalk.cyan(meta.id)}`));
244
+ console.log(`${chalk.white('Status:')} ${meta.status}`);
245
+ console.log(`${chalk.white('Created:')} ${meta.createdAt}`);
246
+ if (meta.model) {
247
+ console.log(`${chalk.white('Model:')} ${meta.model}`);
248
+ }
249
+ const mode = meta.mode ?? meta.options?.mode;
250
+ if (mode) {
251
+ console.log(`${chalk.white('Mode:')} ${mode}`);
252
+ }
253
+ if (meta.errorMessage) {
254
+ console.log(chalk.red(`Error: ${meta.errorMessage}`));
255
+ }
256
+ }
257
+ async function askOracleFlow(version, userConfig) {
258
+ const modelChoices = Object.keys(MODEL_CONFIGS);
259
+ const hasApiKey = Boolean(process.env.OPENAI_API_KEY);
260
+ const initialMode = hasApiKey ? 'api' : 'browser';
261
+ const preferredMode = userConfig.engine ?? initialMode;
262
+ const answers = await inquirer.prompt([
263
+ {
264
+ name: 'promptInput',
265
+ type: 'input',
266
+ message: 'Paste your prompt text or a path to a file (leave blank to cancel):',
267
+ },
268
+ ...(hasApiKey
269
+ ? [
270
+ {
271
+ name: 'mode',
272
+ type: 'list',
273
+ message: 'Engine',
274
+ default: preferredMode,
275
+ choices: [
276
+ { name: 'API', value: 'api' },
277
+ { name: 'Browser', value: 'browser' },
278
+ ],
279
+ },
280
+ ]
281
+ : [
282
+ {
283
+ name: 'mode',
284
+ type: 'list',
285
+ message: 'Engine',
286
+ default: preferredMode,
287
+ choices: [{ name: 'Browser', value: 'browser' }],
288
+ },
289
+ ]),
290
+ {
291
+ name: 'slug',
292
+ type: 'input',
293
+ message: 'Optional slug (3–5 words, leave blank for auto):',
294
+ },
295
+ {
296
+ name: 'model',
297
+ type: 'list',
298
+ message: 'Model',
299
+ default: 'gpt-5-pro',
300
+ choices: modelChoices,
301
+ },
302
+ {
303
+ name: 'files',
304
+ type: 'input',
305
+ message: 'Files or globs to attach (comma-separated, optional):',
306
+ filter: (value) => value
307
+ .split(',')
308
+ .map((entry) => entry.trim())
309
+ .filter(Boolean),
310
+ },
311
+ {
312
+ name: 'chromeProfile',
313
+ type: 'input',
314
+ message: 'Chrome profile to reuse cookies from:',
315
+ default: 'Default',
316
+ when: (ans) => ans.mode === 'browser',
317
+ },
318
+ {
319
+ name: 'headless',
320
+ type: 'confirm',
321
+ message: 'Run Chrome headless?',
322
+ default: false,
323
+ when: (ans) => ans.mode === 'browser',
324
+ },
325
+ {
326
+ name: 'hideWindow',
327
+ type: 'confirm',
328
+ message: 'Hide Chrome window (macOS headful only)?',
329
+ default: false,
330
+ when: (ans) => ans.mode === 'browser',
331
+ },
332
+ {
333
+ name: 'keepBrowser',
334
+ type: 'confirm',
335
+ message: 'Keep browser open after completion?',
336
+ default: false,
337
+ when: (ans) => ans.mode === 'browser',
338
+ },
339
+ ]);
340
+ const mode = (answers.mode ?? initialMode);
341
+ const prompt = await resolvePromptInput(answers.promptInput);
342
+ if (!prompt.trim()) {
343
+ console.log(chalk.yellow('Cancelled.'));
344
+ return;
345
+ }
346
+ const promptWithSuffix = userConfig.promptSuffix ? `${prompt.trim()}\n${userConfig.promptSuffix}` : prompt;
347
+ await ensureSessionStorage();
348
+ const runOptions = {
349
+ prompt: promptWithSuffix,
350
+ model: answers.model,
351
+ file: answers.files,
352
+ slug: answers.slug,
353
+ filesReport: false,
354
+ maxInput: undefined,
355
+ maxOutput: undefined,
356
+ system: undefined,
357
+ silent: false,
358
+ search: undefined,
359
+ preview: false,
360
+ previewMode: undefined,
361
+ apiKey: undefined,
362
+ sessionId: undefined,
363
+ verbose: false,
364
+ heartbeatIntervalMs: undefined,
365
+ browserInlineFiles: false,
366
+ browserBundleFiles: false,
367
+ background: undefined,
368
+ };
369
+ const browserConfig = mode === 'browser'
370
+ ? await buildBrowserConfig({
371
+ browserChromeProfile: answers.chromeProfile,
372
+ browserHeadless: answers.headless,
373
+ browserHideWindow: answers.hideWindow,
374
+ browserKeepBrowser: answers.keepBrowser,
375
+ browserModelLabel: resolveBrowserModelLabel(undefined, answers.model),
376
+ model: answers.model,
377
+ })
378
+ : undefined;
379
+ const notifications = resolveNotificationSettings({
380
+ cliNotify: undefined,
381
+ cliNotifySound: undefined,
382
+ env: process.env,
383
+ config: userConfig.notify,
384
+ });
385
+ const sessionMeta = await initializeSession({
386
+ ...runOptions,
387
+ mode,
388
+ browserConfig,
389
+ }, process.cwd(), notifications);
390
+ const { logLine, writeChunk, stream } = createSessionLogWriter(sessionMeta.id);
391
+ const combinedLog = (message) => {
392
+ if (message) {
393
+ console.log(message);
394
+ logLine(message);
395
+ }
396
+ };
397
+ const combinedWrite = (chunk) => {
398
+ writeChunk(chunk);
399
+ return process.stdout.write(chunk);
400
+ };
401
+ console.log(chalk.bold(`Session ${sessionMeta.id} starting...`));
402
+ console.log(dim(`Log path: ${path.join(os.homedir(), '.oracle', 'sessions', sessionMeta.id, 'output.log')}`));
403
+ try {
404
+ await performSessionRun({
405
+ sessionMeta,
406
+ runOptions: { ...runOptions, sessionId: sessionMeta.id },
407
+ mode,
408
+ browserConfig,
409
+ cwd: process.cwd(),
410
+ log: combinedLog,
411
+ write: combinedWrite,
412
+ version,
413
+ notifications,
414
+ });
415
+ console.log(chalk.green(`Session ${sessionMeta.id} completed.`));
416
+ }
417
+ catch (error) {
418
+ const message = error instanceof Error ? error.message : String(error);
419
+ console.log(chalk.red(`Session ${sessionMeta.id} failed: ${message}`));
420
+ }
421
+ finally {
422
+ stream.end();
423
+ }
424
+ }
425
+ const readSessionMetadataSafe = (sessionId) => readSessionMetadata(sessionId);
426
+ async function resolvePromptInput(rawInput) {
427
+ const trimmed = rawInput.trim();
428
+ if (!trimmed) {
429
+ return trimmed;
430
+ }
431
+ const asPath = path.resolve(process.cwd(), trimmed);
432
+ try {
433
+ const stats = await fs.stat(asPath);
434
+ if (stats.isFile()) {
435
+ const contents = await fs.readFile(asPath, 'utf8');
436
+ return contents;
437
+ }
438
+ }
439
+ catch {
440
+ // not a file; fall through
441
+ }
442
+ return trimmed;
443
+ }
444
+ async function readStoredPrompt(sessionId) {
445
+ const request = await readSessionRequest(sessionId);
446
+ if (request?.prompt && request.prompt.trim().length > 0) {
447
+ return request.prompt;
448
+ }
449
+ const meta = await readSessionMetadata(sessionId);
450
+ if (meta?.options?.prompt && meta.options.prompt.trim().length > 0) {
451
+ return meta.options.prompt;
452
+ }
453
+ return null;
454
+ }
455
+ // Exported for testing
456
+ export { askOracleFlow, showSessionDetail };
457
+ export { resolveCost };