@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.
- package/README.md +11 -9
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +16 -48
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/browser/actions/attachments.js +47 -16
- package/dist/src/browser/actions/promptComposer.js +67 -18
- package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
- package/dist/src/browser/chromeCookies.js +44 -6
- package/dist/src/browser/chromeLifecycle.js +166 -25
- package/dist/src/browser/config.js +25 -1
- package/dist/src/browser/constants.js +22 -3
- package/dist/src/browser/index.js +384 -22
- package/dist/src/browser/profileSync.js +141 -0
- package/dist/src/browser/prompt.js +3 -1
- package/dist/src/browser/reattach.js +59 -0
- package/dist/src/browser/sessionRunner.js +15 -1
- package/dist/src/browser/windowsCookies.js +2 -1
- package/dist/src/cli/browserConfig.js +11 -0
- package/dist/src/cli/browserDefaults.js +41 -0
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +4 -2
- package/dist/src/cli/engine.js +2 -2
- package/dist/src/cli/help.js +2 -2
- package/dist/src/cli/options.js +2 -1
- package/dist/src/cli/runOptions.js +1 -1
- package/dist/src/cli/sessionDisplay.js +102 -104
- package/dist/src/cli/sessionRunner.js +39 -6
- package/dist/src/cli/sessionTable.js +88 -0
- package/dist/src/cli/tui/index.js +19 -89
- package/dist/src/heartbeat.js +2 -2
- package/dist/src/oracle/background.js +10 -2
- package/dist/src/oracle/client.js +107 -0
- package/dist/src/oracle/config.js +10 -2
- package/dist/src/oracle/errors.js +24 -4
- package/dist/src/oracle/modelResolver.js +144 -0
- package/dist/src/oracle/oscProgress.js +1 -1
- package/dist/src/oracle/run.js +83 -34
- package/dist/src/oracle/runUtils.js +12 -8
- package/dist/src/remote/server.js +214 -23
- package/dist/src/sessionManager.js +5 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- 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
|
-
|
|
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(
|
|
38
|
+
console.log(formatSessionTableHeader(richTty));
|
|
26
39
|
for (const entry of filteredEntries) {
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
508
|
+
const cost = resolveSessionCost(metadata);
|
|
501
509
|
const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
|
|
502
|
-
const tokensDisplay =
|
|
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
|
|
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 =
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
52
|
+
choices.push(new inquirer.Separator(headerLabel));
|
|
61
53
|
choices.push(...older.map(toSessionChoice));
|
|
62
54
|
}
|
|
63
|
-
choices.push(
|
|
64
|
-
choices.push(
|
|
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:
|
|
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
|
|
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';
|
package/dist/src/heartbeat.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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.`));
|