@steipete/oracle 0.4.5 → 0.5.1

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 (48) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +67 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +44 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +384 -22
  18. package/dist/src/browser/profileSync.js +141 -0
  19. package/dist/src/browser/prompt.js +3 -1
  20. package/dist/src/browser/reattach.js +59 -0
  21. package/dist/src/browser/sessionRunner.js +15 -1
  22. package/dist/src/browser/windowsCookies.js +2 -1
  23. package/dist/src/cli/browserConfig.js +11 -0
  24. package/dist/src/cli/browserDefaults.js +41 -0
  25. package/dist/src/cli/detach.js +2 -2
  26. package/dist/src/cli/dryRun.js +4 -2
  27. package/dist/src/cli/engine.js +2 -2
  28. package/dist/src/cli/help.js +2 -2
  29. package/dist/src/cli/options.js +2 -1
  30. package/dist/src/cli/runOptions.js +1 -1
  31. package/dist/src/cli/sessionDisplay.js +102 -104
  32. package/dist/src/cli/sessionRunner.js +39 -6
  33. package/dist/src/cli/sessionTable.js +88 -0
  34. package/dist/src/cli/tui/index.js +19 -89
  35. package/dist/src/heartbeat.js +2 -2
  36. package/dist/src/oracle/background.js +10 -2
  37. package/dist/src/oracle/client.js +107 -0
  38. package/dist/src/oracle/config.js +10 -2
  39. package/dist/src/oracle/errors.js +24 -4
  40. package/dist/src/oracle/modelResolver.js +144 -0
  41. package/dist/src/oracle/oscProgress.js +1 -1
  42. package/dist/src/oracle/run.js +83 -34
  43. package/dist/src/oracle/runUtils.js +12 -8
  44. package/dist/src/remote/server.js +214 -23
  45. package/dist/src/sessionManager.js +5 -2
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/package.json +14 -14
@@ -2,12 +2,25 @@ import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
3
  import { renderMarkdownAnsi } from './markdownRenderer.js';
4
4
  import { formatElapsed, formatUSD } from '../oracle/format.js';
5
- import { MODEL_CONFIGS } from '../oracle.js';
6
5
  import { sessionStore, wait } from '../sessionStore.js';
6
+ import { formatTokenCount, formatTokenValue } from '../oracle/runUtils.js';
7
+ import { resumeBrowserSession } from '../browser/reattach.js';
8
+ import { estimateTokenCount } from '../browser/utils.js';
9
+ import { formatSessionTableHeader, formatSessionTableRow, resolveSessionCost } from './sessionTable.js';
7
10
  const isTty = () => Boolean(process.stdout.isTTY);
8
11
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
9
12
  export const MAX_RENDER_BYTES = 200_000;
10
- const MODEL_COLUMN_WIDTH = 18;
13
+ function isProcessAlive(pid) {
14
+ if (!pid)
15
+ return false;
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch (error) {
21
+ return !(error instanceof Error && error.code === 'ESRCH');
22
+ }
23
+ }
11
24
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
12
25
  export async function showStatus({ hours, includeAll, limit, showExamples = false, modelFilter, }) {
13
26
  const metas = await sessionStore.listSessions();
@@ -22,17 +35,9 @@ export async function showStatus({ hours, includeAll, limit, showExamples = fals
22
35
  return;
23
36
  }
24
37
  console.log(chalk.bold('Recent Sessions'));
25
- console.log(chalk.dim('Timestamp Chars Cost Status Models ID'));
38
+ console.log(formatSessionTableHeader(richTty));
26
39
  for (const entry of filteredEntries) {
27
- const statusRaw = (entry.status || 'unknown').padEnd(9);
28
- const status = richTty ? colorStatus(entry.status ?? 'unknown', statusRaw) : statusRaw;
29
- const modelColumn = formatModelColumn(entry, MODEL_COLUMN_WIDTH, richTty);
30
- const created = formatTimestamp(entry.createdAt);
31
- const chars = entry.options?.prompt?.length ?? entry.promptPreview?.length ?? 0;
32
- const charLabel = chars > 0 ? String(chars).padStart(5) : ' -';
33
- const costValue = resolveCost(entry);
34
- const costLabel = costValue != null ? formatCostTable(costValue) : ' -';
35
- console.log(`${created} | ${charLabel} | ${costLabel} | ${status} | ${modelColumn} | ${entry.id}`);
40
+ console.log(formatSessionTableRow(entry, { rich: richTty }));
36
41
  }
37
42
  if (truncated) {
38
43
  const sessionsDir = sessionStore.sessionsDir();
@@ -42,20 +47,8 @@ export async function showStatus({ hours, includeAll, limit, showExamples = fals
42
47
  printStatusExamples();
43
48
  }
44
49
  }
45
- function colorStatus(status, padded) {
46
- switch (status) {
47
- case 'completed':
48
- return chalk.green(padded);
49
- case 'error':
50
- return chalk.red(padded);
51
- case 'running':
52
- return chalk.yellow(padded);
53
- default:
54
- return padded;
55
- }
56
- }
57
50
  export async function attachSession(sessionId, options) {
58
- const metadata = await sessionStore.readSession(sessionId);
51
+ let metadata = await sessionStore.readSession(sessionId);
59
52
  if (!metadata) {
60
53
  console.error(chalk.red(`No session found with ID ${sessionId}`));
61
54
  process.exitCode = 1;
@@ -74,6 +67,65 @@ export async function attachSession(sessionId, options) {
74
67
  const initialStatus = metadata.status;
75
68
  const wantsRender = Boolean(options?.renderMarkdown);
76
69
  const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
70
+ const runtime = metadata.browser?.runtime;
71
+ const controllerAlive = isProcessAlive(runtime?.controllerPid);
72
+ const canReattach = metadata.status === 'running' &&
73
+ metadata.mode === 'browser' &&
74
+ runtime?.chromePort &&
75
+ (metadata.response?.incompleteReason === 'chrome-disconnected' || (runtime.controllerPid && !controllerAlive));
76
+ if (canReattach) {
77
+ const portInfo = runtime?.chromePort ? `port ${runtime.chromePort}` : 'unknown port';
78
+ const urlInfo = runtime?.tabUrl ? `url=${runtime.tabUrl}` : 'url=unknown';
79
+ console.log(chalk.yellow(`Attempting to reattach to the existing Chrome session (${portInfo}, ${urlInfo})...`));
80
+ try {
81
+ const result = await resumeBrowserSession(runtime, metadata.browser?.config, Object.assign(((message) => {
82
+ if (message) {
83
+ console.log(dim(message));
84
+ }
85
+ }), { verbose: true }));
86
+ const outputTokens = estimateTokenCount(result.answerMarkdown);
87
+ const logWriter = sessionStore.createLogWriter(sessionId);
88
+ logWriter.logLine('[reattach] captured assistant response from existing Chrome tab');
89
+ logWriter.logLine('Answer:');
90
+ logWriter.logLine(result.answerMarkdown || result.answerText);
91
+ logWriter.stream.end();
92
+ if (metadata.model) {
93
+ await sessionStore.updateModelRun(metadata.id, metadata.model, {
94
+ status: 'completed',
95
+ usage: {
96
+ inputTokens: 0,
97
+ outputTokens,
98
+ reasoningTokens: 0,
99
+ totalTokens: outputTokens,
100
+ },
101
+ completedAt: new Date().toISOString(),
102
+ });
103
+ }
104
+ await sessionStore.updateSession(sessionId, {
105
+ status: 'completed',
106
+ completedAt: new Date().toISOString(),
107
+ usage: {
108
+ inputTokens: 0,
109
+ outputTokens,
110
+ reasoningTokens: 0,
111
+ totalTokens: outputTokens,
112
+ },
113
+ browser: {
114
+ config: metadata.browser?.config,
115
+ runtime,
116
+ },
117
+ response: { status: 'completed' },
118
+ error: undefined,
119
+ transport: undefined,
120
+ });
121
+ console.log(chalk.green('Reattach succeeded; session marked completed.'));
122
+ metadata = (await sessionStore.readSession(sessionId)) ?? metadata;
123
+ }
124
+ catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ console.log(chalk.red(`Reattach failed: ${message}`));
127
+ }
128
+ }
77
129
  if (!options?.suppressMetadata) {
78
130
  const reattachLine = buildReattachLine(metadata);
79
131
  if (reattachLine) {
@@ -85,7 +137,7 @@ export async function attachSession(sessionId, options) {
85
137
  console.log('Models:');
86
138
  for (const run of metadata.models) {
87
139
  const usage = run.usage
88
- ? ` tok=${run.usage.outputTokens?.toLocaleString() ?? 0}/${run.usage.totalTokens?.toLocaleString() ?? 0}`
140
+ ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}`
89
141
  : '';
90
142
  console.log(`- ${chalk.cyan(run.model)} — ${run.status}${usage}`);
91
143
  }
@@ -377,43 +429,6 @@ function matchesModel(entry, filter) {
377
429
  const models = entry.models?.map((model) => model.model.toLowerCase()) ?? (entry.model ? [entry.model.toLowerCase()] : []);
378
430
  return models.includes(normalized);
379
431
  }
380
- function formatModelColumn(entry, width, richTty) {
381
- const models = entry.models && entry.models.length > 0
382
- ? entry.models
383
- : entry.model
384
- ? [{ model: entry.model, status: entry.status }]
385
- : [];
386
- if (models.length === 0) {
387
- return 'n/a'.padEnd(width);
388
- }
389
- const badges = models.map((model) => formatModelBadge(model, richTty));
390
- const text = badges.join(' ');
391
- if (text.length > width) {
392
- return `${text.slice(0, width - 1)}…`;
393
- }
394
- return text.padEnd(width);
395
- }
396
- function formatModelBadge(model, richTty) {
397
- const glyph = statusGlyph(model.status);
398
- const text = `${model.model}${glyph}`;
399
- return richTty ? chalk.cyan(text) : text;
400
- }
401
- function statusGlyph(status) {
402
- switch (status) {
403
- case 'completed':
404
- return '✓';
405
- case 'running':
406
- return '⌛';
407
- case 'pending':
408
- return '…';
409
- case 'error':
410
- return '✖';
411
- case 'cancelled':
412
- return '⦻';
413
- default:
414
- return '?';
415
- }
416
- }
417
432
  async function buildSessionLogForDisplay(sessionId, fallbackMeta, modelFilter) {
418
433
  const normalizedFilter = modelFilter?.trim().toLowerCase();
419
434
  const freshMetadata = (await sessionStore.readSession(sessionId)) ?? fallbackMeta;
@@ -424,17 +439,25 @@ async function buildSessionLogForDisplay(sessionId, fallbackMeta, modelFilter) {
424
439
  }
425
440
  return await sessionStore.readLog(sessionId);
426
441
  }
427
- const candidates = normalizedFilter != null
442
+ const candidates = normalizedFilter
428
443
  ? models.filter((model) => model.model.toLowerCase() === normalizedFilter)
429
444
  : models;
430
445
  if (candidates.length === 0) {
431
446
  return '';
432
447
  }
433
448
  const sections = [];
449
+ let hasContent = false;
434
450
  for (const model of candidates) {
435
- const body = await sessionStore.readModelLog(sessionId, model.model);
451
+ const body = (await sessionStore.readModelLog(sessionId, model.model)) ?? '';
452
+ if (body.trim().length > 0) {
453
+ hasContent = true;
454
+ }
436
455
  sections.push(`=== ${model.model} ===\n${body}`.trimEnd());
437
456
  }
457
+ if (!hasContent) {
458
+ // Fallback for runs that recorded output only in the session log (e.g., browser runs without per-model logs).
459
+ return await sessionStore.readLog(sessionId);
460
+ }
438
461
  return sections.join('\n\n');
439
462
  }
440
463
  function extractRenderableChunks(text, state) {
@@ -476,57 +499,32 @@ function extractRenderableChunks(text, state) {
476
499
  }
477
500
  return { chunks, remainder: buffer };
478
501
  }
479
- function formatTimestamp(iso) {
480
- const date = new Date(iso);
481
- const locale = 'en-US';
482
- const opts = {
483
- year: 'numeric',
484
- month: '2-digit',
485
- day: '2-digit',
486
- hour: 'numeric',
487
- minute: '2-digit',
488
- second: undefined,
489
- hour12: true,
490
- };
491
- const formatted = date.toLocaleString(locale, opts);
492
- return formatted.replace(/(, )(\d:)/, '$1 $2');
493
- }
494
502
  export function formatCompletionSummary(metadata, options = {}) {
495
503
  if (!metadata.usage || metadata.elapsedMs == null) {
496
504
  return null;
497
505
  }
498
506
  const modeLabel = metadata.mode === 'browser' ? `${metadata.model ?? 'n/a'}[browser]` : metadata.model ?? 'n/a';
499
507
  const usage = metadata.usage;
500
- const cost = metadata.mode === 'browser' ? null : resolveCost(metadata);
508
+ const cost = resolveSessionCost(metadata);
501
509
  const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
502
- const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
510
+ const tokensDisplay = [
511
+ usage.inputTokens ?? 0,
512
+ usage.outputTokens ?? 0,
513
+ usage.reasoningTokens ?? 0,
514
+ usage.totalTokens ?? 0,
515
+ ]
516
+ .map((value, index) => formatTokenValue(value, {
517
+ input_tokens: usage.inputTokens,
518
+ output_tokens: usage.outputTokens,
519
+ reasoning_tokens: usage.reasoningTokens,
520
+ total_tokens: usage.totalTokens,
521
+ }, index))
522
+ .join('/');
503
523
  const filesCount = metadata.options?.file?.length ?? 0;
504
524
  const filesPart = filesCount > 0 ? ` | files=${filesCount}` : '';
505
525
  const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
506
526
  return `Finished in ${formatElapsed(metadata.elapsedMs)} (${modeLabel}${costPart} | tok(i/o/r/t)=${tokensDisplay}${filesPart}${slugPart})`;
507
527
  }
508
- function resolveCost(metadata) {
509
- if (metadata.mode === 'browser') {
510
- return null;
511
- }
512
- if (metadata.usage?.cost != null) {
513
- return metadata.usage.cost;
514
- }
515
- if (!metadata.model || !metadata.usage) {
516
- return null;
517
- }
518
- const pricing = MODEL_CONFIGS[metadata.model]?.pricing;
519
- if (!pricing) {
520
- return null;
521
- }
522
- const input = metadata.usage.inputTokens ?? 0;
523
- const output = metadata.usage.outputTokens ?? 0;
524
- const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
525
- return cost > 0 ? cost : null;
526
- }
527
- function formatCostTable(cost) {
528
- return `$${cost.toFixed(3)}`.padStart(7);
529
- }
530
528
  async function readStoredPrompt(sessionId) {
531
529
  const request = await sessionStore.readRequest(sessionId);
532
530
  if (request?.prompt && request.prompt.trim().length > 0) {
@@ -10,6 +10,8 @@ import { sendSessionNotification, deriveNotificationSettingsFromMetadata, } from
10
10
  import { sessionStore } from '../sessionStore.js';
11
11
  import { runMultiModelApiSession } from '../oracle/multiModelRunner.js';
12
12
  import { MODEL_CONFIGS, DEFAULT_SYSTEM_PROMPT } from '../oracle/config.js';
13
+ import { isKnownModel } from '../oracle/modelResolver.js';
14
+ import { resolveModelConfig } from '../oracle/modelResolver.js';
13
15
  import { buildPrompt, buildRequestBody } from '../oracle/request.js';
14
16
  import { estimateRequestTokens } from '../oracle/tokenEstimate.js';
15
17
  import { formatTokenEstimate, formatTokenValue } from '../oracle/runUtils.js';
@@ -49,7 +51,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
49
51
  startedAt: new Date().toISOString(),
50
52
  });
51
53
  }
52
- const result = await runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, browserDeps);
54
+ const runnerDeps = {
55
+ ...browserDeps,
56
+ persistRuntimeHint: async (runtime) => {
57
+ await sessionStore.updateSession(sessionMeta.id, {
58
+ status: 'running',
59
+ browser: { config: browserConfig, runtime },
60
+ });
61
+ },
62
+ };
63
+ const result = await runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, runnerDeps);
53
64
  if (modelForStatus) {
54
65
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
55
66
  status: 'completed',
@@ -87,10 +98,10 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
87
98
  if (!primaryModel) {
88
99
  throw new Error('Missing model name for multi-model run.');
89
100
  }
90
- const modelConfig = MODEL_CONFIGS[primaryModel];
91
- if (!modelConfig) {
92
- throw new Error(`Unsupported model "${primaryModel}".`);
93
- }
101
+ const modelConfig = await resolveModelConfig(primaryModel, {
102
+ baseUrl: runOptions.baseUrl,
103
+ openRouterApiKey: process.env.OPENROUTER_API_KEY,
104
+ });
94
105
  const files = await readFiles(runOptions.file ?? [], { cwd });
95
106
  const promptWithFiles = buildPrompt(runOptions.prompt, files, cwd);
96
107
  const requestBody = buildRequestBody({
@@ -119,7 +130,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
119
130
  log(dim(tip));
120
131
  }
121
132
  // Surface long-running model expectations up front so users know why a response might lag.
122
- const longRunningModels = multiModels.filter((model) => MODEL_CONFIGS[model]?.reasoning?.effort === 'high');
133
+ const longRunningModels = multiModels.filter((model) => isKnownModel(model) && MODEL_CONFIGS[model]?.reasoning?.effort === 'high');
123
134
  if (longRunningModels.length > 0) {
124
135
  for (const model of longRunningModels) {
125
136
  log('');
@@ -299,6 +310,28 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
299
310
  log(`ERROR: ${message}`);
300
311
  markErrorLogged(error);
301
312
  const userError = asOracleUserError(error);
313
+ const connectionLost = userError?.category === 'browser-automation' && userError.details?.stage === 'connection-lost';
314
+ if (connectionLost && mode === 'browser') {
315
+ const runtime = userError.details?.runtime;
316
+ log(dim('Chrome disconnected before completion; keeping session running for reattach.'));
317
+ if (modelForStatus) {
318
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
319
+ status: 'running',
320
+ completedAt: undefined,
321
+ });
322
+ }
323
+ await sessionStore.updateSession(sessionMeta.id, {
324
+ status: 'running',
325
+ errorMessage: message,
326
+ mode,
327
+ browser: {
328
+ config: browserConfig,
329
+ runtime: runtime ?? sessionMeta.browser?.runtime,
330
+ },
331
+ response: { status: 'running', incompleteReason: 'chrome-disconnected' },
332
+ });
333
+ return;
334
+ }
302
335
  if (userError) {
303
336
  log(dim(`User error (${userError.category}): ${userError.message}`));
304
337
  }
@@ -0,0 +1,88 @@
1
+ import chalk from 'chalk';
2
+ import kleur from 'kleur';
3
+ import { MODEL_CONFIGS } from '../oracle.js';
4
+ const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
5
+ const dim = (text, rich) => (rich ? kleur.dim(text) : text);
6
+ export const STATUS_PAD = 9;
7
+ export const MODEL_PAD = 13;
8
+ export const MODE_PAD = 7;
9
+ export const TIMESTAMP_PAD = 19;
10
+ export const CHARS_PAD = 5;
11
+ export const COST_PAD = 7;
12
+ export function formatSessionTableHeader(rich) {
13
+ const header = `${'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`;
14
+ return dim(header, isRich(rich));
15
+ }
16
+ export function formatSessionTableRow(meta, options) {
17
+ const rich = isRich(options?.rich);
18
+ const status = colorStatus(meta.status ?? 'unknown', rich);
19
+ const modelLabel = (meta.model ?? 'n/a').padEnd(MODEL_PAD);
20
+ const model = rich ? chalk.white(modelLabel) : modelLabel;
21
+ const modeLabel = (meta.mode ?? meta.options?.mode ?? 'api').padEnd(MODE_PAD);
22
+ const mode = rich ? chalk.gray(modeLabel) : modeLabel;
23
+ const timestampLabel = formatTimestampAligned(meta.createdAt).padEnd(TIMESTAMP_PAD);
24
+ const timestamp = rich ? chalk.gray(timestampLabel) : timestampLabel;
25
+ const charsValue = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
26
+ const charsRaw = charsValue > 0 ? String(charsValue).padStart(CHARS_PAD) : `${''.padStart(CHARS_PAD - 1)}-`;
27
+ const chars = rich ? chalk.gray(charsRaw) : charsRaw;
28
+ const costValue = resolveSessionCost(meta);
29
+ const costRaw = costValue != null ? formatCostTable(costValue) : `${''.padStart(COST_PAD - 1)}-`;
30
+ const cost = rich ? chalk.gray(costRaw) : costRaw;
31
+ const slug = rich ? chalk.cyan(meta.id) : meta.id;
32
+ return `${status} ${model} ${mode} ${timestamp} ${chars} ${cost} ${slug}`;
33
+ }
34
+ export function resolveSessionCost(meta) {
35
+ const mode = meta.mode ?? meta.options?.mode;
36
+ if (mode === 'browser') {
37
+ return null;
38
+ }
39
+ if (meta.usage?.cost != null) {
40
+ return meta.usage.cost;
41
+ }
42
+ if (!meta.model || !meta.usage) {
43
+ return null;
44
+ }
45
+ const pricing = MODEL_CONFIGS[meta.model]?.pricing;
46
+ if (!pricing) {
47
+ return null;
48
+ }
49
+ const input = meta.usage.inputTokens ?? 0;
50
+ const output = meta.usage.outputTokens ?? 0;
51
+ const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
52
+ return cost > 0 ? cost : null;
53
+ }
54
+ export function formatTimestampAligned(iso) {
55
+ const date = new Date(iso);
56
+ const locale = 'en-US';
57
+ const opts = {
58
+ year: 'numeric',
59
+ month: '2-digit',
60
+ day: '2-digit',
61
+ hour: 'numeric',
62
+ minute: '2-digit',
63
+ second: undefined,
64
+ hour12: true,
65
+ };
66
+ let formatted = date.toLocaleString(locale, opts);
67
+ formatted = formatted.replace(', ', ' ');
68
+ return formatted.replace(/(\s)(\d:)/, '$1 $2');
69
+ }
70
+ function formatCostTable(cost) {
71
+ return `$${cost.toFixed(3)}`.padStart(COST_PAD);
72
+ }
73
+ function colorStatus(status, rich) {
74
+ const padded = status.padEnd(STATUS_PAD);
75
+ if (!rich) {
76
+ return padded;
77
+ }
78
+ switch (status) {
79
+ case 'completed':
80
+ return chalk.green(padded);
81
+ case 'error':
82
+ return chalk.red(padded);
83
+ case 'running':
84
+ return chalk.yellow(padded);
85
+ default:
86
+ return padded;
87
+ }
88
+ }
@@ -9,59 +9,51 @@ import { renderMarkdownAnsi } from '../markdownRenderer.js';
9
9
  import { sessionStore, pruneOldSessions } from '../../sessionStore.js';
10
10
  import { performSessionRun } from '../sessionRunner.js';
11
11
  import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from '../sessionDisplay.js';
12
+ import { formatSessionTableHeader, formatSessionTableRow } from '../sessionTable.js';
12
13
  import { buildBrowserConfig, resolveBrowserModelLabel } from '../browserConfig.js';
13
14
  import { resolveNotificationSettings } from '../notifier.js';
14
15
  import { loadUserConfig } from '../../config.js';
16
+ import { formatTokenCount } from '../../oracle/runUtils.js';
15
17
  const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
16
18
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
17
- const disabledChoice = (label) => ({
18
- name: label,
19
- value: '__disabled__',
20
- disabled: true,
21
- });
22
19
  const RECENT_WINDOW_HOURS = 24;
23
20
  const PAGE_SIZE = 10;
24
- const STATUS_PAD = 9;
25
- const MODEL_PAD = 13;
26
- const MODE_PAD = 7;
27
- const TIMESTAMP_PAD = 19;
28
- const CHARS_PAD = 5;
29
- const COST_PAD = 7;
30
- export async function launchTui({ version }) {
21
+ export async function launchTui({ version, printIntro = true }) {
31
22
  const userConfig = (await loadUserConfig()).config;
32
23
  const rich = isTty();
33
- if (rich) {
34
- console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
35
- }
36
- else {
37
- console.log(`🧿 oracle ${version} — Whispering your tokens to the silicon sage`);
24
+ if (printIntro) {
25
+ if (rich) {
26
+ console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
27
+ }
28
+ else {
29
+ console.log(`🧿 oracle ${version} — Whispering your tokens to the silicon sage`);
30
+ }
38
31
  }
39
32
  console.log('');
40
33
  let showingOlder = false;
41
34
  for (;;) {
42
35
  const { recent, older, olderTotal } = await fetchSessionBuckets();
43
36
  const choices = [];
44
- 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`);
37
+ const headerLabel = formatSessionTableHeader(isTty());
45
38
  // Start with a selectable row so focus never lands on a separator
46
39
  choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
47
- choices.push(disabledChoice(''));
48
40
  if (!showingOlder) {
49
41
  if (recent.length > 0) {
50
- choices.push(disabledChoice(headerLabel));
42
+ choices.push(new inquirer.Separator(headerLabel));
51
43
  choices.push(...recent.map(toSessionChoice));
52
44
  }
53
45
  else if (older.length > 0) {
54
46
  // No recent entries; show first page of older.
55
- choices.push(disabledChoice(headerLabel));
47
+ choices.push(new inquirer.Separator(headerLabel));
56
48
  choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
57
49
  }
58
50
  }
59
51
  else if (older.length > 0) {
60
- choices.push(disabledChoice(headerLabel));
52
+ choices.push(new inquirer.Separator(headerLabel));
61
53
  choices.push(...older.map(toSessionChoice));
62
54
  }
63
- choices.push(disabledChoice(''));
64
- choices.push(disabledChoice('Actions'));
55
+ choices.push(new inquirer.Separator(' '));
56
+ choices.push(new inquirer.Separator('Actions'));
65
57
  choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
66
58
  if (!showingOlder && olderTotal > 0) {
67
59
  choices.push({ name: 'Older page', value: '__older__' });
@@ -122,72 +114,10 @@ async function fetchSessionBuckets() {
122
114
  }
123
115
  function toSessionChoice(meta) {
124
116
  return {
125
- name: formatSessionLabel(meta),
117
+ name: formatSessionTableRow(meta, { rich: isTty() }),
126
118
  value: meta.id,
127
119
  };
128
120
  }
129
- function formatSessionLabel(meta) {
130
- const status = colorStatus(meta.status ?? 'unknown');
131
- const created = formatTimestampAligned(meta.createdAt);
132
- const model = meta.model ?? 'n/a';
133
- const mode = meta.mode ?? meta.options?.mode ?? 'api';
134
- const slug = meta.id;
135
- const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
136
- const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(CHARS_PAD)) : chalk.gray(`${''.padStart(CHARS_PAD - 1)}-`);
137
- const cost = mode === 'browser' ? null : resolveCost(meta);
138
- const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(`${''.padStart(COST_PAD - 1)}-`);
139
- 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)}`;
140
- }
141
- function resolveCost(meta) {
142
- if (meta.usage?.cost != null) {
143
- return meta.usage.cost;
144
- }
145
- if (!meta.model || !meta.usage) {
146
- return null;
147
- }
148
- const pricing = MODEL_CONFIGS[meta.model]?.pricing;
149
- if (!pricing)
150
- return null;
151
- const input = meta.usage.inputTokens ?? 0;
152
- const output = meta.usage.outputTokens ?? 0;
153
- const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
154
- return cost > 0 ? cost : null;
155
- }
156
- function formatCostTable(cost) {
157
- return `$${cost.toFixed(3)}`.padStart(COST_PAD);
158
- }
159
- function formatTimestampAligned(iso) {
160
- const date = new Date(iso);
161
- const locale = 'en-US';
162
- const opts = {
163
- year: 'numeric',
164
- month: '2-digit',
165
- day: '2-digit',
166
- hour: 'numeric',
167
- minute: '2-digit',
168
- second: undefined,
169
- hour12: true,
170
- };
171
- let formatted = date.toLocaleString(locale, opts);
172
- // Drop the comma and use double-space between date and time for alignment.
173
- formatted = formatted.replace(', ', ' ');
174
- // Insert a leading space when hour is a single digit to align AM/PM column.
175
- // Example: "11/18/2025 1:07 AM" -> "11/18/2025 1:07 AM"
176
- return formatted.replace(/(\s)(\d:)/, '$1 $2');
177
- }
178
- function colorStatus(status) {
179
- const padded = status.padEnd(9);
180
- switch (status) {
181
- case 'completed':
182
- return chalk.green(padded);
183
- case 'error':
184
- return chalk.red(padded);
185
- case 'running':
186
- return chalk.yellow(padded);
187
- default:
188
- return padded;
189
- }
190
- }
191
121
  async function showSessionDetail(sessionId) {
192
122
  for (;;) {
193
123
  const meta = await readSessionMetadataSafe(sessionId);
@@ -294,7 +224,7 @@ function printModelSummaries(models) {
294
224
  console.log(chalk.bold('Models:'));
295
225
  for (const run of models) {
296
226
  const usage = run.usage
297
- ? ` tok=${run.usage.outputTokens?.toLocaleString() ?? 0}/${run.usage.totalTokens?.toLocaleString() ?? 0}`
227
+ ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}`
298
228
  : '';
299
229
  console.log(` - ${chalk.cyan(run.model)} — ${run.status}${usage}`);
300
230
  }
@@ -517,4 +447,4 @@ async function readStoredPrompt(sessionId) {
517
447
  }
518
448
  // Exported for testing
519
449
  export { askOracleFlow, showSessionDetail };
520
- export { resolveCost };
450
+ export { resolveSessionCost as resolveCost } from '../sessionTable.js';
@@ -7,7 +7,7 @@ export function startHeartbeat(config) {
7
7
  let pending = false;
8
8
  const start = Date.now();
9
9
  const timer = setInterval(async () => {
10
- // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
10
+ // stop flag flips asynchronously
11
11
  if (stopped || pending) {
12
12
  return;
13
13
  }
@@ -32,7 +32,7 @@ export function startHeartbeat(config) {
32
32
  }, intervalMs);
33
33
  timer.unref?.();
34
34
  const stop = () => {
35
- // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
35
+ // multiple callers may race to stop
36
36
  if (stopped) {
37
37
  return;
38
38
  }
@@ -9,7 +9,15 @@ const BACKGROUND_RETRY_BASE_MS = 3000;
9
9
  const BACKGROUND_RETRY_MAX_MS = 15000;
10
10
  export async function executeBackgroundResponse(params) {
11
11
  const { client, requestBody, log, wait, heartbeatIntervalMs, now, maxWaitMs } = params;
12
- const initialResponse = await client.responses.create(requestBody);
12
+ let initialResponse;
13
+ try {
14
+ initialResponse = await client.responses.create(requestBody);
15
+ }
16
+ catch (error) {
17
+ const transportError = toTransportError(error, requestBody.model);
18
+ log(chalk.yellow(describeTransportError(transportError, maxWaitMs)));
19
+ throw transportError;
20
+ }
13
21
  if (!initialResponse || !initialResponse.id) {
14
22
  throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
15
23
  }
@@ -60,7 +68,7 @@ async function pollBackgroundResponse(params) {
60
68
  // biome-ignore lint/nursery/noUnnecessaryConditions: intentional polling loop.
61
69
  while (true) {
62
70
  const status = response.status ?? 'completed';
63
- // biome-ignore lint/nursery/noUnnecessaryConditions: firstCycle toggles immediately; keep for clarity in logs.
71
+ // firstCycle toggles immediately; keep for clarity in logs.
64
72
  if (firstCycle) {
65
73
  firstCycle = false;
66
74
  log(chalk.dim(`API background response status=${status}. We'll keep retrying automatically.`));