@steipete/oracle 0.7.5 → 0.7.6

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 CHANGED
@@ -16,6 +16,7 @@ Oracle bundles your prompt and files so another AI can answer with real context.
16
16
  ## Quick start
17
17
 
18
18
  Install globally: `npm install -g @steipete/oracle`
19
+ Homebrew: `brew install steipete/tap/oracle`
19
20
 
20
21
  Use `npx -y @steipete/oracle …` (not `pnpx`)—pnpx's sandboxed cache can’t load the sqlite bindings and will throw missing `node_sqlite3.node` errors.
21
22
 
@@ -20,7 +20,7 @@ import { CHATGPT_URL } from '../src/browserMode.js';
20
20
  import { createRemoteBrowserExecutor } from '../src/remote/client.js';
21
21
  import { createGeminiWebExecutor } from '../src/gemini-web/index.js';
22
22
  import { applyHelpStyling } from '../src/cli/help.js';
23
- import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, } from '../src/cli/options.js';
23
+ import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, dedupePathInputs, } from '../src/cli/options.js';
24
24
  import { copyToClipboard } from '../src/cli/clipboard.js';
25
25
  import { buildMarkdownBundle } from '../src/cli/markdownBundle.js';
26
26
  import { shouldDetachSession } from '../src/cli/detach.js';
@@ -420,7 +420,13 @@ async function runRootCommand(options) {
420
420
  const previewMode = resolvePreviewMode(options.dryRun || options.preview);
421
421
  const mergedFileInputs = mergePathLikeOptions(options.file, options.include, options.files, options.path, options.paths);
422
422
  if (mergedFileInputs.length > 0) {
423
- options.file = mergedFileInputs;
423
+ const { deduped, duplicates } = dedupePathInputs(mergedFileInputs, { cwd: process.cwd() });
424
+ if (duplicates.length > 0) {
425
+ const preview = duplicates.slice(0, 8).join(', ');
426
+ const suffix = duplicates.length > 8 ? ` (+${duplicates.length - 8} more)` : '';
427
+ console.log(chalk.dim(`Ignoring duplicate --file inputs: ${preview}${suffix}`));
428
+ }
429
+ options.file = deduped;
424
430
  }
425
431
  const copyMarkdown = options.copyMarkdown || options.copy;
426
432
  const renderMarkdown = resolveRenderFlag(options.render, options.renderMarkdown);
@@ -10,27 +10,66 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
10
10
  const isAttachmentPresent = async (name) => {
11
11
  const check = await runtime.evaluate({
12
12
  expression: `(() => {
13
- const expected = ${JSON.stringify(name.toLowerCase())};
14
- const selectors = [
13
+ const expected = ${JSON.stringify(name)};
14
+ const normalizedExpected = String(expected || '').toLowerCase().replace(/\\s+/g, ' ').trim();
15
+ const expectedNoExt = normalizedExpected.replace(/\\.[a-z0-9]{1,10}$/i, '');
16
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
17
+ const matchesExpected = (value) => {
18
+ const text = normalize(value);
19
+ if (!text) return false;
20
+ if (text.includes(normalizedExpected)) return true;
21
+ if (expectedNoExt.length >= 6 && text.includes(expectedNoExt)) return true;
22
+ if (text.includes('…') || text.includes('...')) {
23
+ const marker = text.includes('…') ? '…' : '...';
24
+ const [prefixRaw, suffixRaw] = text.split(marker);
25
+ const prefix = normalize(prefixRaw);
26
+ const suffix = normalize(suffixRaw);
27
+ const target = expectedNoExt.length >= 6 ? expectedNoExt : normalizedExpected;
28
+ const matchesPrefix = !prefix || target.includes(prefix);
29
+ const matchesSuffix = !suffix || target.includes(suffix);
30
+ return matchesPrefix && matchesSuffix;
31
+ }
32
+ return false;
33
+ };
34
+
35
+ const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
36
+ const locateComposerRoot = () => {
37
+ for (const selector of promptSelectors) {
38
+ const node = document.querySelector(selector);
39
+ if (!node) continue;
40
+ return node.closest('form') ?? node.closest('[data-testid*="composer"]') ?? node.parentElement;
41
+ }
42
+ return document.querySelector('form') ?? document.body;
43
+ };
44
+ const root = locateComposerRoot();
45
+ const chipSelector = [
15
46
  '[data-testid*="attachment"]',
16
47
  '[data-testid*="chip"]',
17
- '[data-testid*="upload"]'
18
- ];
19
- const chips = selectors.some((selector) =>
20
- Array.from(document.querySelectorAll(selector)).some((node) =>
21
- (node?.textContent || '').toLowerCase().includes(expected),
22
- ),
23
- );
24
- if (chips) return true;
48
+ '[data-testid*="upload"]',
49
+ '[aria-label*="Remove"]',
50
+ 'button[aria-label*="Remove"]',
51
+ '[aria-label*="remove"]',
52
+ ].join(',');
53
+ const candidates = root ? Array.from(root.querySelectorAll(chipSelector)) : [];
54
+ const nodes = candidates.length > 0 ? candidates : Array.from(document.querySelectorAll(chipSelector));
55
+ for (const node of nodes) {
56
+ const text = node?.textContent ?? '';
57
+ const aria = node?.getAttribute?.('aria-label') ?? '';
58
+ const title = node?.getAttribute?.('title') ?? '';
59
+ if ([text, aria, title].some(matchesExpected)) {
60
+ return true;
61
+ }
62
+ }
63
+
25
64
  const cardTexts = Array.from(document.querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]')).map((btn) =>
26
- btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
65
+ btn?.parentElement?.parentElement?.innerText ?? '',
27
66
  );
28
- if (cardTexts.some((text) => text.includes(expected))) return true;
67
+ if (cardTexts.some(matchesExpected)) return true;
29
68
 
30
69
  const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
31
- Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
70
+ Array.from(el.files || []).some((f) => matchesExpected(f?.name ?? '')),
32
71
  );
33
- return inputs;
72
+ return Boolean(inputs);
34
73
  })()`,
35
74
  returnByValue: true,
36
75
  });
@@ -311,15 +350,14 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
311
350
  if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
312
351
  return true;
313
352
  if (raw.includes('…') || raw.includes('...')) {
314
- const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
315
- const pattern = escaped.replace(/\\…|\\\.\\\.\\\./g, '.*');
316
- try {
317
- const re = new RegExp(pattern);
318
- return re.test(normalizedExpected) || (expectedNoExt.length >= 6 && re.test(expectedNoExt));
319
- }
320
- catch {
321
- return false;
322
- }
353
+ const marker = raw.includes('…') ? '' : '...';
354
+ const [prefixRaw, suffixRaw] = raw.split(marker);
355
+ const prefix = prefixRaw.trim();
356
+ const suffix = suffixRaw.trim();
357
+ const target = expectedNoExt.length >= 6 ? expectedNoExt : normalizedExpected;
358
+ const matchesPrefix = !prefix || target.includes(prefix);
359
+ const matchesSuffix = !suffix || target.includes(suffix);
360
+ return matchesPrefix && matchesSuffix;
323
361
  }
324
362
  return false;
325
363
  });
@@ -488,14 +526,14 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
488
526
  if (text.includes(normalized)) return true;
489
527
  if (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)) return true;
490
528
  if (text.includes('…') || text.includes('...')) {
491
- const escaped = text.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, '\\\\$&');
492
- const pattern = escaped.replaceAll('…', '.*').replaceAll('...', '.*');
493
- try {
494
- const re = new RegExp(pattern);
495
- return re.test(normalized) || (normalizedNoExt.length >= 6 && re.test(normalizedNoExt));
496
- } catch {
497
- return false;
498
- }
529
+ const marker = text.includes('…') ? '' : '...';
530
+ const [prefixRaw, suffixRaw] = text.split(marker);
531
+ const prefix = (prefixRaw ?? '').toLowerCase();
532
+ const suffix = (suffixRaw ?? '').toLowerCase();
533
+ const target = normalizedNoExt.length >= 6 ? normalizedNoExt : normalized;
534
+ const matchesPrefix = !prefix || target.includes(prefix);
535
+ const matchesSuffix = !suffix || target.includes(suffix);
536
+ return matchesPrefix && matchesSuffix;
499
537
  }
500
538
  return false;
501
539
  };
@@ -134,7 +134,7 @@ export async function submitPrompt(deps, prompt, logger) {
134
134
  else {
135
135
  logger('Clicked send button');
136
136
  }
137
- await verifyPromptCommitted(runtime, prompt, 30_000, logger);
137
+ await verifyPromptCommitted(runtime, prompt, 60_000, logger);
138
138
  }
139
139
  export async function clearPromptComposer(Runtime, logger) {
140
140
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
@@ -68,7 +68,13 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
68
68
  }
69
69
  })().finally(() => {
70
70
  const exitCode = signal === 'SIGINT' ? 130 : 1;
71
- process.exit(exitCode);
71
+ // Vitest treats any `process.exit()` call as an unhandled failure, even if mocked.
72
+ // Keep production behavior (hard-exit on signals) while letting tests observe state changes.
73
+ process.exitCode = exitCode;
74
+ const isTestRun = process.env.VITEST === '1' || process.env.NODE_ENV === 'test';
75
+ if (!isTestRun) {
76
+ process.exit(exitCode);
77
+ }
72
78
  });
73
79
  };
74
80
  for (const signal of signals) {
@@ -54,11 +54,30 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
54
54
  if (DOM && typeof DOM.enable === 'function') {
55
55
  await DOM.enable();
56
56
  }
57
+ const ensureConversationOpen = async () => {
58
+ const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
59
+ const href = typeof result?.value === 'string' ? result.value : '';
60
+ if (href.includes('/c/')) {
61
+ return;
62
+ }
63
+ const opened = await openConversationFromSidebarWithRetry(Runtime, {
64
+ conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ''),
65
+ preferProjects: true,
66
+ promptPreview: deps.promptPreview,
67
+ }, 15_000);
68
+ if (!opened) {
69
+ throw new Error('Unable to locate prior ChatGPT conversation in sidebar.');
70
+ }
71
+ await waitForLocationChange(Runtime, 15_000);
72
+ };
57
73
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
58
74
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
59
75
  const timeoutMs = config?.timeoutMs ?? 120_000;
60
- const answer = await waitForResponse(Runtime, timeoutMs, logger);
61
- const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
76
+ const pingTimeoutMs = Math.min(5_000, Math.max(1_500, Math.floor(timeoutMs * 0.05)));
77
+ await withTimeout(Runtime.evaluate({ expression: '1+1', returnByValue: true }), pingTimeoutMs, 'Reattach target did not respond');
78
+ await ensureConversationOpen();
79
+ const answer = await withTimeout(waitForResponse(Runtime, timeoutMs, logger), timeoutMs + 5_000, 'Reattach response timed out');
80
+ const markdown = (await withTimeout(captureMarkdown(Runtime, answer.meta, logger), 15_000, 'Reattach markdown capture timed out')) ?? answer.text;
62
81
  if (client && typeof client.close === 'function') {
63
82
  try {
64
83
  await client.close();
@@ -122,10 +141,11 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
122
141
  await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
123
142
  }
124
143
  else {
125
- const opened = await openConversationFromSidebar(Runtime, {
144
+ const opened = await openConversationFromSidebarWithRetry(Runtime, {
126
145
  conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ''),
127
146
  preferProjects: resolved.url !== CHATGPT_URL,
128
- });
147
+ promptPreview: deps.promptPreview,
148
+ }, 15_000);
129
149
  if (!opened) {
130
150
  throw new Error('Unable to locate prior ChatGPT conversation in sidebar.');
131
151
  }
@@ -163,7 +183,10 @@ function extractConversationIdFromUrl(url) {
163
183
  }
164
184
  function buildConversationUrl(runtime, baseUrl) {
165
185
  if (runtime.tabUrl) {
166
- return runtime.tabUrl;
186
+ if (runtime.tabUrl.includes('/c/')) {
187
+ return runtime.tabUrl;
188
+ }
189
+ return null;
167
190
  }
168
191
  const conversationId = runtime.conversationId;
169
192
  if (!conversationId) {
@@ -177,11 +200,24 @@ function buildConversationUrl(runtime, baseUrl) {
177
200
  return null;
178
201
  }
179
202
  }
203
+ async function withTimeout(task, ms, label) {
204
+ let timeoutId;
205
+ const timeout = new Promise((_, reject) => {
206
+ timeoutId = setTimeout(() => reject(new Error(label)), ms);
207
+ });
208
+ return Promise.race([task, timeout]).finally(() => {
209
+ if (timeoutId) {
210
+ clearTimeout(timeoutId);
211
+ }
212
+ });
213
+ }
180
214
  async function openConversationFromSidebar(Runtime, options) {
181
215
  const response = await Runtime.evaluate({
182
216
  expression: `(() => {
183
217
  const conversationId = ${JSON.stringify(options.conversationId ?? null)};
184
218
  const preferProjects = ${JSON.stringify(Boolean(options.preferProjects))};
219
+ const promptPreview = ${JSON.stringify(options.promptPreview ?? null)};
220
+ const promptNeedle = promptPreview ? promptPreview.trim().toLowerCase().slice(0, 100) : '';
185
221
  const nav = document.querySelector('nav') || document.querySelector('aside') || document.body;
186
222
  if (preferProjects) {
187
223
  const projectLink = Array.from(nav.querySelectorAll('a,button'))
@@ -190,28 +226,95 @@ async function openConversationFromSidebar(Runtime, options) {
190
226
  projectLink.click();
191
227
  }
192
228
  }
193
- const links = Array.from(nav.querySelectorAll('a[href]'))
194
- .filter((el) => el instanceof HTMLAnchorElement)
195
- .map((el) => el);
196
- const convoLinks = links.filter((el) => el.href.includes('/c/'));
229
+ const allElements = Array.from(
230
+ document.querySelectorAll(
231
+ 'a,button,[role="link"],[role="button"],[data-href],[data-url],[data-conversation-id],[data-testid*="conversation"],[data-testid*="history"]',
232
+ ),
233
+ );
234
+ const getHref = (el) =>
235
+ el.getAttribute('href') ||
236
+ el.getAttribute('data-href') ||
237
+ el.getAttribute('data-url') ||
238
+ el.dataset?.href ||
239
+ el.dataset?.url ||
240
+ '';
241
+ const toCandidate = (el) => {
242
+ const clickable = el.closest('a,button,[role="link"],[role="button"]') || el;
243
+ const rawText = (el.textContent || clickable.textContent || '').trim();
244
+ return {
245
+ el,
246
+ clickable,
247
+ href: getHref(clickable) || getHref(el),
248
+ conversationId:
249
+ clickable.getAttribute('data-conversation-id') ||
250
+ el.getAttribute('data-conversation-id') ||
251
+ clickable.dataset?.conversationId ||
252
+ el.dataset?.conversationId ||
253
+ '',
254
+ testId: clickable.getAttribute('data-testid') || el.getAttribute('data-testid') || '',
255
+ text: rawText.replace(/\\s+/g, ' ').slice(0, 400),
256
+ inNav: Boolean(clickable.closest('nav,aside')),
257
+ };
258
+ };
259
+ const candidates = allElements.map(toCandidate);
260
+ const mainCandidates = candidates.filter((item) => !item.inNav);
261
+ const navCandidates = candidates.filter((item) => item.inNav);
262
+ const visible = (item) => {
263
+ const rect = item.clickable.getBoundingClientRect();
264
+ return rect.width > 0 && rect.height > 0;
265
+ };
266
+ const pick = (items) => (items.find(visible) || items[0] || null);
197
267
  let target = null;
198
268
  if (conversationId) {
199
- target = convoLinks.find((el) => el.href.includes('/c/' + conversationId));
269
+ const byId = (item) =>
270
+ (item.href && item.href.includes('/c/' + conversationId)) ||
271
+ (item.conversationId && item.conversationId === conversationId);
272
+ target = pick(mainCandidates.filter(byId)) || pick(navCandidates.filter(byId));
273
+ }
274
+ if (!target && promptNeedle) {
275
+ const byPrompt = (item) => item.text && item.text.toLowerCase().includes(promptNeedle);
276
+ target = pick(mainCandidates.filter(byPrompt)) || pick(navCandidates.filter(byPrompt));
200
277
  }
201
- if (!target && convoLinks.length > 0) {
202
- target = convoLinks[0];
278
+ if (!target) {
279
+ const byHref = (item) => item.href && item.href.includes('/c/');
280
+ target = pick(mainCandidates.filter(byHref)) || pick(navCandidates.filter(byHref));
281
+ }
282
+ if (!target) {
283
+ const byTestId = (item) => /conversation|history/i.test(item.testId || '');
284
+ target = pick(mainCandidates.filter(byTestId)) || pick(navCandidates.filter(byTestId));
203
285
  }
204
286
  if (target) {
205
- target.scrollIntoView({ block: 'center' });
206
- target.click();
207
- return { ok: true, href: target.href, count: convoLinks.length };
287
+ target.clickable.scrollIntoView({ block: 'center' });
288
+ target.clickable.dispatchEvent(
289
+ new MouseEvent('click', { bubbles: true, cancelable: true, view: window }),
290
+ );
291
+ return {
292
+ ok: true,
293
+ href: target.href || '',
294
+ count: candidates.length,
295
+ scope: target.inNav ? 'nav' : 'main',
296
+ };
208
297
  }
209
- return { ok: false, count: convoLinks.length };
298
+ return { ok: false, count: candidates.length };
210
299
  })()`,
211
300
  returnByValue: true,
212
301
  });
213
302
  return Boolean(response.result?.value?.ok);
214
303
  }
304
+ async function openConversationFromSidebarWithRetry(Runtime, options, timeoutMs) {
305
+ const start = Date.now();
306
+ let attempt = 0;
307
+ while (Date.now() - start < timeoutMs) {
308
+ // Retry because project list can hydrate after initial navigation.
309
+ const opened = await openConversationFromSidebar(Runtime, options);
310
+ if (opened) {
311
+ return true;
312
+ }
313
+ attempt += 1;
314
+ await delay(attempt < 5 ? 250 : 500);
315
+ }
316
+ return false;
317
+ }
215
318
  async function waitForLocationChange(Runtime, timeoutMs) {
216
319
  const start = Date.now();
217
320
  let lastHref = '';
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
- import { formatElapsed } from '../oracle.js';
3
2
  import { formatTokenCount } from '../oracle/runUtils.js';
3
+ import { formatFinishLine } from '../oracle/finishLine.js';
4
4
  import { runBrowserMode } from '../browserMode.js';
5
5
  import { assembleBrowserPrompt } from './prompt.js';
6
6
  import { BrowserAutomationError } from '../oracle/errors.js';
@@ -88,12 +88,22 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
88
88
  ]
89
89
  .map((value) => formatTokenCount(value))
90
90
  .join('/');
91
- const tokensLabel = runOptions.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
92
- const statsParts = [`${runOptions.model}[browser]`, `${tokensLabel}=${tokensDisplay}`];
93
- if (runOptions.file && runOptions.file.length > 0) {
94
- statsParts.push(`files=${runOptions.file.length}`);
91
+ const tokensPart = (() => {
92
+ const parts = tokensDisplay.split('/');
93
+ if (parts.length !== 4)
94
+ return tokensDisplay;
95
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
96
+ })();
97
+ const { line1, line2 } = formatFinishLine({
98
+ elapsedMs: browserResult.tookMs,
99
+ model: `${runOptions.model}[browser]`,
100
+ tokensPart,
101
+ detailParts: [runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null],
102
+ });
103
+ log(chalk.blue(line1));
104
+ if (line2) {
105
+ log(chalk.dim(line2));
95
106
  }
96
- log(chalk.blue(`Finished in ${formatElapsed(browserResult.tookMs)} (${statsParts.join(' | ')})`));
97
107
  return {
98
108
  usage,
99
109
  elapsedMs: browserResult.tookMs,
@@ -1,4 +1,6 @@
1
1
  import { InvalidArgumentError } from 'commander';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
2
4
  import { DEFAULT_MODEL, MODEL_CONFIGS } from '../oracle.js';
3
5
  export function collectPaths(value, previous = []) {
4
6
  if (!value) {
@@ -17,6 +19,31 @@ export function mergePathLikeOptions(file, include, filesAlias, pathAlias, paths
17
19
  const withPathAlias = collectPaths(pathAlias, withFilesAlias);
18
20
  return collectPaths(pathsAlias, withPathAlias);
19
21
  }
22
+ export function dedupePathInputs(inputs, { cwd = process.cwd() } = {}) {
23
+ const deduped = [];
24
+ const duplicates = [];
25
+ const seen = new Set();
26
+ for (const entry of inputs ?? []) {
27
+ const raw = entry?.trim();
28
+ if (!raw)
29
+ continue;
30
+ let key = raw;
31
+ if (!raw.startsWith('!') && !fg.isDynamicPattern(raw)) {
32
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
33
+ key = `path:${path.normalize(absolute)}`;
34
+ }
35
+ else {
36
+ key = `pattern:${raw}`;
37
+ }
38
+ if (seen.has(key)) {
39
+ duplicates.push(raw);
40
+ continue;
41
+ }
42
+ seen.add(key);
43
+ deduped.push(raw);
44
+ }
45
+ return { deduped, duplicates };
46
+ }
20
47
  export function collectModelList(value, previous = []) {
21
48
  if (!value) {
22
49
  return previous;
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
3
  import { renderMarkdownAnsi } from './markdownRenderer.js';
4
- import { formatElapsed, formatUSD } from '../oracle/format.js';
4
+ import { formatFinishLine } from '../oracle/finishLine.js';
5
5
  import { sessionStore, wait } from '../sessionStore.js';
6
6
  import { formatTokenCount, formatTokenValue } from '../oracle/runUtils.js';
7
7
  import { resumeBrowserSession } from '../browser/reattach.js';
@@ -92,7 +92,7 @@ export async function attachSession(sessionId, options) {
92
92
  if (message) {
93
93
  console.log(dim(message));
94
94
  }
95
- }), { verbose: true }));
95
+ }), { verbose: true }), { promptPreview: metadata.promptPreview });
96
96
  const outputTokens = estimateTokenCount(result.answerMarkdown);
97
97
  const logWriter = sessionStore.createLogWriter(sessionId);
98
98
  logWriter.logLine('[reattach] captured assistant response from existing Chrome tab');
@@ -516,7 +516,6 @@ export function formatCompletionSummary(metadata, options = {}) {
516
516
  const modeLabel = metadata.mode === 'browser' ? `${metadata.model ?? 'n/a'}[browser]` : metadata.model ?? 'n/a';
517
517
  const usage = metadata.usage;
518
518
  const cost = resolveSessionCost(metadata);
519
- const costPart = cost != null ? ` | ${formatUSD(cost)}` : '';
520
519
  const tokensDisplay = [
521
520
  usage.inputTokens ?? 0,
522
521
  usage.outputTokens ?? 0,
@@ -530,10 +529,23 @@ export function formatCompletionSummary(metadata, options = {}) {
530
529
  total_tokens: usage.totalTokens,
531
530
  }, index))
532
531
  .join('/');
532
+ const tokensPart = (() => {
533
+ const parts = tokensDisplay.split('/');
534
+ if (parts.length !== 4)
535
+ return tokensDisplay;
536
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
537
+ })();
533
538
  const filesCount = metadata.options?.file?.length ?? 0;
534
- const filesPart = filesCount > 0 ? ` | files=${filesCount}` : '';
535
- const slugPart = options.includeSlug ? ` | slug=${metadata.id}` : '';
536
- return `Finished in ${formatElapsed(metadata.elapsedMs)} (${modeLabel}${costPart} | tok(i/o/r/t)=${tokensDisplay}${filesPart}${slugPart})`;
539
+ const filesPart = filesCount > 0 ? `files=${filesCount}` : null;
540
+ const slugPart = options.includeSlug ? `slug=${metadata.id}` : null;
541
+ const { line1, line2 } = formatFinishLine({
542
+ elapsedMs: metadata.elapsedMs,
543
+ model: modeLabel,
544
+ costUsd: cost ?? null,
545
+ tokensPart,
546
+ detailParts: [filesPart, slugPart],
547
+ });
548
+ return line2 ? `${line1} | ${line2}` : line1;
537
549
  }
538
550
  async function readStoredPrompt(sessionId) {
539
551
  const request = await sessionStore.readRequest(sessionId);
@@ -15,10 +15,9 @@ import { resolveModelConfig } from '../oracle/modelResolver.js';
15
15
  import { buildPrompt, buildRequestBody } from '../oracle/request.js';
16
16
  import { estimateRequestTokens } from '../oracle/tokenEstimate.js';
17
17
  import { formatTokenEstimate, formatTokenValue } from '../oracle/runUtils.js';
18
- import { formatElapsed } from '../oracle/format.js';
18
+ import { formatFinishLine } from '../oracle/finishLine.js';
19
19
  import { sanitizeOscProgress } from './oscUtils.js';
20
20
  import { readFiles } from '../oracle/files.js';
21
- import { formatUSD } from '../oracle/format.js';
22
21
  import { cwd as getCwd } from 'node:process';
23
22
  const isTty = process.stdout.isTTY;
24
23
  const dim = (text) => (isTty ? kleur.dim(text) : text);
@@ -211,10 +210,21 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
211
210
  total_tokens: aggregateUsage.totalTokens,
212
211
  }, idx))
213
212
  .join('/');
214
- const costLabel = aggregateUsage.cost != null ? formatUSD(aggregateUsage.cost) : 'cost=N/A';
213
+ const tokensPart = (() => {
214
+ const parts = tokensDisplay.split('/');
215
+ if (parts.length !== 4)
216
+ return tokensDisplay;
217
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
218
+ })();
215
219
  const statusColor = summary.rejected.length === 0 ? kleur.green : summary.fulfilled.length > 0 ? kleur.yellow : kleur.red;
216
220
  const overallText = `${summary.fulfilled.length}/${multiModels.length} models`;
217
- log(statusColor(`Finished in ${formatElapsed(summary.elapsedMs)} (${overallText} | ${costLabel} | tok(i/o/r/t)=${tokensDisplay})`));
221
+ const { line1 } = formatFinishLine({
222
+ elapsedMs: summary.elapsedMs,
223
+ model: overallText,
224
+ costUsd: aggregateUsage.cost ?? null,
225
+ tokensPart,
226
+ });
227
+ log(statusColor(line1));
218
228
  const hasFailure = summary.rejected.length > 0;
219
229
  await sessionStore.updateSession(sessionMeta.id, {
220
230
  status: hasFailure ? 'error' : 'completed',
@@ -0,0 +1,32 @@
1
+ import { formatUSD } from './format.js';
2
+ export function formatElapsedCompact(ms) {
3
+ if (!Number.isFinite(ms) || ms < 0) {
4
+ return 'unknown';
5
+ }
6
+ if (ms < 60_000) {
7
+ return `${(ms / 1000).toFixed(1)}s`;
8
+ }
9
+ if (ms < 60 * 60_000) {
10
+ const minutes = Math.floor(ms / 60_000);
11
+ const seconds = Math.floor((ms % 60_000) / 1000);
12
+ return `${minutes}m${seconds.toString().padStart(2, '0')}s`;
13
+ }
14
+ const hours = Math.floor(ms / (60 * 60_000));
15
+ const minutes = Math.floor((ms % (60 * 60_000)) / 60_000);
16
+ return `${hours}h${minutes.toString().padStart(2, '0')}m`;
17
+ }
18
+ export function formatFinishLine({ elapsedMs, model, costUsd, tokensPart, summaryExtraParts, detailParts, }) {
19
+ const line1Parts = [
20
+ formatElapsedCompact(elapsedMs),
21
+ typeof costUsd === 'number' ? formatUSD(costUsd) : null,
22
+ model,
23
+ tokensPart,
24
+ ...(summaryExtraParts ?? []),
25
+ ];
26
+ const line1 = line1Parts.filter((part) => typeof part === 'string' && part.length > 0).join(' · ');
27
+ const line2Parts = (detailParts ?? []).filter((part) => typeof part === 'string' && part.length > 0);
28
+ if (line2Parts.length === 0) {
29
+ return { line1 };
30
+ }
31
+ return { line1, line2: line2Parts.join(' | ') };
32
+ }
@@ -8,7 +8,8 @@ import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './confi
8
8
  import { readFiles } from './files.js';
9
9
  import { buildPrompt, buildRequestBody } from './request.js';
10
10
  import { estimateRequestTokens } from './tokenEstimate.js';
11
- import { formatElapsed, formatUSD } from './format.js';
11
+ import { formatElapsed } from './format.js';
12
+ import { formatFinishLine } from './finishLine.js';
12
13
  import { getFileTokenStats, printFileTokenStats } from './tokenStats.js';
13
14
  import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from './errors.js';
14
15
  import { createDefaultClientFactory } from './client.js';
@@ -530,46 +531,48 @@ export async function runOracle(options, deps = {}) {
530
531
  pricing: { inputUsdPerToken: pricing.inputPerToken, outputUsdPerToken: pricing.outputPerToken },
531
532
  })?.totalUsd
532
533
  : undefined;
533
- const elapsedDisplay = formatElapsed(elapsedMs);
534
- const statsParts = [];
535
534
  const effortLabel = modelConfig.reasoning?.effort;
536
535
  const modelLabel = effortLabel ? `${modelConfig.model}[${effortLabel}]` : modelConfig.model;
537
536
  const sessionIdContainsModel = typeof options.sessionId === 'string' && options.sessionId.toLowerCase().includes(modelConfig.model.toLowerCase());
538
- // Avoid duplicating the model name in the prefix (session id) and the stats bundle; keep a single source of truth.
539
- if (!sessionIdContainsModel) {
540
- statsParts.push(modelLabel);
541
- }
542
- if (cost != null) {
543
- statsParts.push(formatUSD(cost));
544
- }
545
- else {
546
- statsParts.push('cost=N/A');
547
- }
548
537
  const tokensDisplay = [inputTokens, outputTokens, reasoningTokens, totalTokens]
549
538
  .map((value, index) => formatTokenValue(value, usage, index))
550
539
  .join('/');
551
- const tokensLabel = options.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
552
- statsParts.push(`${tokensLabel}=${tokensDisplay}`);
553
- if (options.verbose) {
554
- // Only surface request-vs-response deltas when verbose is explicitly requested to keep default stats compact.
555
- const actualInput = usage.input_tokens;
556
- if (actualInput !== undefined) {
557
- const delta = actualInput - estimatedInputTokens;
558
- const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
559
- statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
560
- }
561
- }
562
- if (!searchEnabled) {
563
- statsParts.push('search=off');
564
- }
565
- if (files.length > 0) {
566
- statsParts.push(`files=${files.length}`);
567
- }
568
- const sessionPrefix = options.sessionId ? `${options.sessionId} ` : '';
540
+ const tokensPart = (() => {
541
+ const parts = tokensDisplay.split('/');
542
+ if (parts.length !== 4)
543
+ return tokensDisplay;
544
+ return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
545
+ })();
546
+ const modelPart = sessionIdContainsModel ? null : modelLabel;
547
+ const actualInput = usage.input_tokens;
548
+ const estActualPart = (() => {
549
+ if (!options.verbose)
550
+ return null;
551
+ if (actualInput === undefined)
552
+ return null;
553
+ const delta = actualInput - estimatedInputTokens;
554
+ const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
555
+ return `est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`;
556
+ })();
557
+ const { line1, line2 } = formatFinishLine({
558
+ elapsedMs,
559
+ model: modelPart,
560
+ costUsd: cost ?? null,
561
+ tokensPart,
562
+ summaryExtraParts: options.sessionId ? [`sid=${options.sessionId}`] : null,
563
+ detailParts: [
564
+ estActualPart,
565
+ !searchEnabled ? 'search=off' : null,
566
+ files.length > 0 ? `files=${files.length}` : null,
567
+ ],
568
+ });
569
569
  if (!options.silent) {
570
570
  log('');
571
571
  }
572
- log(chalk.blue(`Finished ${sessionPrefix}in ${elapsedDisplay} (${statsParts.join(' | ')})`));
572
+ log(chalk.blue(line1));
573
+ if (line2) {
574
+ log(dim(line2));
575
+ }
573
576
  return {
574
577
  mode: 'live',
575
578
  response,
@@ -445,6 +445,22 @@ async function markZombie(meta, { persist }) {
445
445
  if (!isZombie(meta)) {
446
446
  return meta;
447
447
  }
448
+ if (meta.mode === 'browser') {
449
+ const runtime = meta.browser?.runtime;
450
+ if (runtime) {
451
+ const signals = [];
452
+ if (runtime.chromePid) {
453
+ signals.push(isProcessAlive(runtime.chromePid));
454
+ }
455
+ if (runtime.chromePort) {
456
+ const host = runtime.chromeHost ?? '127.0.0.1';
457
+ signals.push(await isPortOpen(host, runtime.chromePort));
458
+ }
459
+ if (signals.some(Boolean)) {
460
+ return meta;
461
+ }
462
+ }
463
+ }
448
464
  const updated = {
449
465
  ...meta,
450
466
  status: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",