@steipete/oracle 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +40 -7
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/.DS_Store +0 -0
  4. package/dist/bin/oracle-cli.js +315 -47
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/src/browser/actions/modelSelection.js +117 -29
  7. package/dist/src/browser/config.js +6 -0
  8. package/dist/src/browser/cookies.js +50 -12
  9. package/dist/src/browser/index.js +19 -5
  10. package/dist/src/browser/prompt.js +6 -5
  11. package/dist/src/browser/sessionRunner.js +14 -3
  12. package/dist/src/cli/browserConfig.js +109 -2
  13. package/dist/src/cli/detach.js +12 -0
  14. package/dist/src/cli/dryRun.js +60 -8
  15. package/dist/src/cli/engine.js +7 -0
  16. package/dist/src/cli/help.js +3 -1
  17. package/dist/src/cli/hiddenAliases.js +17 -0
  18. package/dist/src/cli/markdownRenderer.js +79 -0
  19. package/dist/src/cli/notifier.js +223 -0
  20. package/dist/src/cli/options.js +22 -0
  21. package/dist/src/cli/promptRequirement.js +3 -0
  22. package/dist/src/cli/runOptions.js +43 -0
  23. package/dist/src/cli/sessionCommand.js +1 -1
  24. package/dist/src/cli/sessionDisplay.js +94 -7
  25. package/dist/src/cli/sessionRunner.js +32 -2
  26. package/dist/src/cli/tui/index.js +457 -0
  27. package/dist/src/config.js +27 -0
  28. package/dist/src/mcp/server.js +36 -0
  29. package/dist/src/mcp/tools/consult.js +158 -0
  30. package/dist/src/mcp/tools/sessionResources.js +64 -0
  31. package/dist/src/mcp/tools/sessions.js +106 -0
  32. package/dist/src/mcp/types.js +17 -0
  33. package/dist/src/mcp/utils.js +24 -0
  34. package/dist/src/oracle/client.js +24 -6
  35. package/dist/src/oracle/config.js +10 -0
  36. package/dist/src/oracle/files.js +151 -8
  37. package/dist/src/oracle/format.js +2 -7
  38. package/dist/src/oracle/fsAdapter.js +4 -1
  39. package/dist/src/oracle/gemini.js +161 -0
  40. package/dist/src/oracle/logging.js +36 -0
  41. package/dist/src/oracle/oscProgress.js +7 -1
  42. package/dist/src/oracle/run.js +148 -64
  43. package/dist/src/oracle/tokenEstimate.js +34 -0
  44. package/dist/src/oracle.js +1 -0
  45. package/dist/src/sessionManager.js +50 -3
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  48. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  49. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  50. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  51. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  52. package/dist/vendor/oracle-notifier/README.md +24 -0
  53. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  54. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  55. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  56. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  57. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  58. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  59. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  60. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  61. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  62. package/package.json +22 -6
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  68. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  69. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -8,34 +8,31 @@ import { APIConnectionError, APIConnectionTimeoutError } from 'openai';
8
8
  import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './config.js';
9
9
  import { readFiles } from './files.js';
10
10
  import { buildPrompt, buildRequestBody } from './request.js';
11
+ import { estimateRequestTokens } from './tokenEstimate.js';
11
12
  import { formatElapsed, formatUSD } from './format.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';
16
+ import { formatBaseUrlForLog, maskApiKey } from './logging.js';
15
17
  import { startHeartbeat } from '../heartbeat.js';
16
18
  import { startOscProgress } from './oscProgress.js';
17
19
  import { getCliVersion } from '../version.js';
18
20
  import { createFsAdapter } from './fsAdapter.js';
19
- const isTty = process.stdout.isTTY;
21
+ import { resolveGeminiModelId } from './gemini.js';
22
+ const isTty = process.stdout.isTTY && chalk.level > 0;
20
23
  const dim = (text) => (isTty ? kleur.dim(text) : text);
21
24
  const BACKGROUND_MAX_WAIT_MS = 30 * 60 * 1000;
22
25
  const BACKGROUND_POLL_INTERVAL_MS = 5000;
23
26
  const BACKGROUND_RETRY_BASE_MS = 3000;
24
27
  const BACKGROUND_RETRY_MAX_MS = 15000;
28
+ const DEFAULT_TIMEOUT_NON_PRO_MS = 30_000;
29
+ const DEFAULT_TIMEOUT_PRO_MS = 20 * 60 * 1000;
25
30
  const defaultWait = (ms) => new Promise((resolve) => {
26
31
  setTimeout(resolve, ms);
27
32
  });
28
33
  export async function runOracle(options, deps = {}) {
29
- const { apiKey = options.apiKey ?? process.env.OPENAI_API_KEY, cwd = process.cwd(), fs: fsModule = createFsAdapter(fs), log = console.log, write = (text) => process.stdout.write(text), now = () => performance.now(), clientFactory = createDefaultClientFactory(), client, wait = defaultWait, } = deps;
30
- const maskApiKey = (key) => {
31
- if (!key)
32
- return null;
33
- if (key.length <= 8)
34
- return `${key[0] ?? ''}***${key[key.length - 1] ?? ''}`;
35
- const prefix = key.slice(0, 4);
36
- const suffix = key.slice(-4);
37
- return `${prefix}****${suffix}`;
38
- };
34
+ const { apiKey: optionsApiKey = options.apiKey, cwd = process.cwd(), fs: fsModule = createFsAdapter(fs), log = console.log, write = (text) => process.stdout.write(text), now = () => performance.now(), clientFactory = createDefaultClientFactory(), client, wait = defaultWait, } = deps;
35
+ const baseUrl = options.baseUrl?.trim() || process.env.OPENAI_BASE_URL?.trim();
39
36
  const logVerbose = (message) => {
40
37
  if (options.verbose) {
41
38
  log(dim(`[verbose] ${message}`));
@@ -43,15 +40,22 @@ export async function runOracle(options, deps = {}) {
43
40
  };
44
41
  const previewMode = resolvePreviewMode(options.previewMode ?? options.preview);
45
42
  const isPreview = Boolean(previewMode);
43
+ const getApiKeyForModel = (model) => {
44
+ if (model.startsWith('gpt')) {
45
+ return optionsApiKey ?? process.env.OPENAI_API_KEY;
46
+ }
47
+ if (model.startsWith('gemini')) {
48
+ return optionsApiKey ?? process.env.GEMINI_API_KEY;
49
+ }
50
+ return undefined;
51
+ };
52
+ const envVar = options.model.startsWith('gpt') ? 'OPENAI_API_KEY' : 'GEMINI_API_KEY';
53
+ const apiKey = getApiKeyForModel(options.model);
46
54
  if (!apiKey) {
47
- throw new PromptValidationError('Missing OPENAI_API_KEY. Set it via the environment or a .env file.', {
48
- env: 'OPENAI_API_KEY',
55
+ throw new PromptValidationError(`Missing ${envVar}. Set it via the environment or a .env file.`, {
56
+ env: envVar,
49
57
  });
50
58
  }
51
- const maskedKey = maskApiKey(apiKey);
52
- if (maskedKey) {
53
- log(dim(`Using OPENAI_API_KEY=${maskedKey}`));
54
- }
55
59
  const modelConfig = MODEL_CONFIGS[options.model];
56
60
  if (!modelConfig) {
57
61
  throw new PromptValidationError(`Unsupported model "${options.model}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`, { model: options.model });
@@ -61,6 +65,8 @@ export async function runOracle(options, deps = {}) {
61
65
  const files = await readFiles(options.file ?? [], { cwd, fsModule });
62
66
  const searchEnabled = options.search !== false;
63
67
  logVerbose(`cwd: ${cwd}`);
68
+ let pendingNoFilesTip = null;
69
+ let pendingShortPromptTip = null;
64
70
  if (files.length > 0) {
65
71
  const displayPaths = files
66
72
  .map((file) => path.relative(cwd, file.path) || file.path)
@@ -72,9 +78,15 @@ export async function runOracle(options, deps = {}) {
72
78
  else {
73
79
  logVerbose('No files attached.');
74
80
  if (!isPreview) {
75
- log(dim('Tip: no files attached — Oracle works best with project context. Add files via --file path/to/code or docs.'));
81
+ pendingNoFilesTip =
82
+ 'Tip: no files attached — Oracle works best with project context. Add files via --file path/to/code or docs.';
76
83
  }
77
84
  }
85
+ const shortPrompt = (options.prompt?.trim().length ?? 0) < 80;
86
+ if (!isPreview && shortPrompt) {
87
+ pendingShortPromptTip =
88
+ 'Tip: brief prompts often yield generic answers — aim for 6–30 sentences and attach key files.';
89
+ }
78
90
  const fileTokenInfo = getFileTokenStats(files, {
79
91
  cwd,
80
92
  tokenizer: modelConfig.tokenizer,
@@ -85,22 +97,50 @@ export async function runOracle(options, deps = {}) {
85
97
  logVerbose(`Attached files use ${totalFileTokens.toLocaleString()} tokens`);
86
98
  const systemPrompt = options.system?.trim() || DEFAULT_SYSTEM_PROMPT;
87
99
  const promptWithFiles = buildPrompt(options.prompt, files, cwd);
88
- const tokenizerInput = [
89
- { role: 'system', content: systemPrompt },
90
- { role: 'user', content: promptWithFiles },
91
- ];
92
- const estimatedInputTokens = modelConfig.tokenizer(tokenizerInput, TOKENIZER_OPTIONS);
93
- logVerbose(`Estimated tokens (prompt + files): ${estimatedInputTokens.toLocaleString()}`);
94
100
  const fileCount = files.length;
95
101
  const cliVersion = getCliVersion();
96
102
  const richTty = process.stdout.isTTY && chalk.level > 0;
103
+ const timeoutSeconds = options.timeoutSeconds === undefined || options.timeoutSeconds === 'auto'
104
+ ? options.model === 'gpt-5-pro'
105
+ ? DEFAULT_TIMEOUT_PRO_MS / 1000
106
+ : DEFAULT_TIMEOUT_NON_PRO_MS / 1000
107
+ : options.timeoutSeconds;
108
+ const timeoutMs = timeoutSeconds * 1000;
109
+ // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
110
+ const effectiveModelId = options.effectiveModelId ??
111
+ (options.model.startsWith('gemini') ? resolveGeminiModelId(options.model) : modelConfig.model);
97
112
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
113
+ const requestBody = buildRequestBody({
114
+ modelConfig,
115
+ systemPrompt,
116
+ userPrompt: promptWithFiles,
117
+ searchEnabled,
118
+ maxOutputTokens: options.maxOutput,
119
+ background: useBackground,
120
+ storeResponse: useBackground,
121
+ });
122
+ const estimatedInputTokens = estimateRequestTokens(requestBody, modelConfig);
98
123
  const tokenLabel = richTty ? chalk.green(estimatedInputTokens.toLocaleString()) : estimatedInputTokens.toLocaleString();
99
124
  const fileLabel = richTty ? chalk.magenta(fileCount.toString()) : fileCount.toString();
100
- const headerLine = `Oracle (${cliVersion}) consulting ${headerModelLabel}'s crystal ball with ${tokenLabel} tokens and ${fileLabel} files...`;
125
+ const filesPhrase = fileCount === 0 ? 'no files' : `${fileLabel} files`;
126
+ const headerLine = `🧿 oracle (${cliVersion}) summons ${headerModelLabel} — ${tokenLabel} tokens, ${filesPhrase}`;
101
127
  const shouldReportFiles = (options.filesReport || fileTokenInfo.totalTokens > inputTokenBudget) && fileTokenInfo.stats.length > 0;
102
128
  if (!isPreview) {
103
129
  log(headerLine);
130
+ const maskedKey = maskApiKey(apiKey);
131
+ if (maskedKey) {
132
+ const resolvedSuffix = options.model.startsWith('gemini') && effectiveModelId !== modelConfig.model ? ` (resolved: ${effectiveModelId})` : '';
133
+ log(dim(`Using ${envVar}=${maskedKey} for model ${modelConfig.model}${resolvedSuffix}`));
134
+ }
135
+ if (baseUrl) {
136
+ log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
137
+ }
138
+ if (pendingNoFilesTip) {
139
+ log(dim(pendingNoFilesTip));
140
+ }
141
+ if (pendingShortPromptTip) {
142
+ log(dim(pendingShortPromptTip));
143
+ }
104
144
  if (options.model === 'gpt-5-pro') {
105
145
  log(dim('Pro is thinking, this can take up to 30 minutes...'));
106
146
  }
@@ -112,15 +152,7 @@ export async function runOracle(options, deps = {}) {
112
152
  if (estimatedInputTokens > inputTokenBudget) {
113
153
  throw new PromptValidationError(`Input too large (${estimatedInputTokens.toLocaleString()} tokens). Limit is ${inputTokenBudget.toLocaleString()} tokens.`, { estimatedInputTokens, inputTokenBudget });
114
154
  }
115
- const requestBody = buildRequestBody({
116
- modelConfig,
117
- systemPrompt,
118
- userPrompt: promptWithFiles,
119
- searchEnabled,
120
- maxOutputTokens: options.maxOutput,
121
- background: useBackground,
122
- storeResponse: useBackground,
123
- });
155
+ logVerbose(`Estimated tokens (request body): ${estimatedInputTokens.toLocaleString()}`);
124
156
  if (isPreview && previewMode) {
125
157
  if (previewMode === 'json' || previewMode === 'full') {
126
158
  log('Request JSON');
@@ -141,11 +173,22 @@ export async function runOracle(options, deps = {}) {
141
173
  inputTokenBudget,
142
174
  };
143
175
  }
144
- const openAiClient = client ?? clientFactory(apiKey);
145
- logVerbose('Dispatching request to OpenAI Responses API...');
176
+ const apiEndpoint = modelConfig.model.startsWith('gemini') ? undefined : baseUrl;
177
+ const clientInstance = client ??
178
+ clientFactory(apiKey, {
179
+ baseUrl: apiEndpoint,
180
+ azure: options.azure,
181
+ model: options.model,
182
+ resolvedModelId: effectiveModelId,
183
+ });
184
+ logVerbose('Dispatching request to API...');
185
+ if (options.verbose) {
186
+ log(''); // ensure verbose section is separated from Answer stream
187
+ }
146
188
  const stopOscProgress = startOscProgress({
147
- label: useBackground ? 'Waiting for OpenAI (background)' : 'Waiting for OpenAI',
148
- targetMs: useBackground ? BACKGROUND_MAX_WAIT_MS : 10 * 60_000,
189
+ label: useBackground ? 'Waiting for API (background)' : 'Waiting for API',
190
+ targetMs: useBackground ? timeoutMs : Math.min(timeoutMs, 10 * 60_000),
191
+ indeterminate: true,
149
192
  write,
150
193
  });
151
194
  const runStart = now();
@@ -153,6 +196,12 @@ export async function runOracle(options, deps = {}) {
153
196
  let elapsedMs = 0;
154
197
  let sawTextDelta = false;
155
198
  let answerHeaderPrinted = false;
199
+ const timeoutExceeded = () => now() - runStart >= timeoutMs;
200
+ const throwIfTimedOut = () => {
201
+ if (timeoutExceeded()) {
202
+ throw new OracleTransportError('client-timeout', `Timed out waiting for API response after ${formatElapsed(timeoutMs)}.`);
203
+ }
204
+ };
156
205
  const ensureAnswerHeader = () => {
157
206
  if (!options.silent && !answerHeaderPrinted) {
158
207
  log('');
@@ -163,17 +212,18 @@ export async function runOracle(options, deps = {}) {
163
212
  try {
164
213
  if (useBackground) {
165
214
  response = await executeBackgroundResponse({
166
- client: openAiClient,
215
+ client: clientInstance,
167
216
  requestBody,
168
217
  log,
169
218
  wait,
170
219
  heartbeatIntervalMs: options.heartbeatIntervalMs,
171
220
  now,
221
+ maxWaitMs: timeoutMs,
172
222
  });
173
223
  elapsedMs = now() - runStart;
174
224
  }
175
225
  else {
176
- const stream = await openAiClient.responses.stream(requestBody);
226
+ const stream = await clientInstance.responses.stream(requestBody);
177
227
  let heartbeatActive = false;
178
228
  let stopHeartbeat = null;
179
229
  const stopHeartbeatNow = () => {
@@ -192,13 +242,16 @@ export async function runOracle(options, deps = {}) {
192
242
  isActive: () => heartbeatActive,
193
243
  makeMessage: (elapsedMs) => {
194
244
  const elapsedText = formatElapsed(elapsedMs);
195
- return `API connection active — ${elapsedText} elapsed. Expect up to ~10 min before GPT-5 responds.`;
245
+ const timeoutLabel = Math.round(timeoutMs / 60000);
246
+ return `API connection active — ${elapsedText} elapsed. Timeout in ~${timeoutLabel} min if no response.`;
196
247
  },
197
248
  });
198
249
  }
199
250
  try {
200
251
  for await (const event of stream) {
201
- if (event.type === 'response.output_text.delta') {
252
+ throwIfTimedOut();
253
+ const isTextDelta = event.type === 'chunk' || event.type === 'response.output_text.delta';
254
+ if (isTextDelta) {
202
255
  stopOscProgress();
203
256
  stopHeartbeatNow();
204
257
  sawTextDelta = true;
@@ -208,17 +261,17 @@ export async function runOracle(options, deps = {}) {
208
261
  }
209
262
  }
210
263
  }
264
+ throwIfTimedOut();
211
265
  }
212
266
  catch (streamError) {
213
- if (typeof stream.abort === 'function') {
214
- stream.abort();
215
- }
267
+ // stream.abort() is not available on the interface
216
268
  stopHeartbeatNow();
217
269
  const transportError = toTransportError(streamError);
218
270
  log(chalk.yellow(describeTransportError(transportError)));
219
271
  throw transportError;
220
272
  }
221
273
  response = await stream.finalResponse();
274
+ throwIfTimedOut();
222
275
  stopHeartbeatNow();
223
276
  elapsedMs = now() - runStart;
224
277
  }
@@ -227,19 +280,42 @@ export async function runOracle(options, deps = {}) {
227
280
  stopOscProgress();
228
281
  }
229
282
  if (!response) {
230
- throw new Error('OpenAI did not return a response.');
283
+ throw new Error('API did not return a response.');
284
+ }
285
+ // biome-ignore lint/nursery/noUnnecessaryConditions: we only add spacing when any streamed text was printed
286
+ if (sawTextDelta && !options.silent) {
287
+ write('\n');
288
+ log('');
231
289
  }
232
290
  logVerbose(`Response status: ${response.status ?? 'completed'}`);
233
291
  if (response.status && response.status !== 'completed') {
234
- const detail = response.error?.message || response.incomplete_details?.reason || response.status;
235
- log(chalk.yellow(`OpenAI ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
236
- throw new OracleResponseError(`Response did not complete: ${detail}`, response);
292
+ // API can reply `in_progress` even after the stream closes; give it a brief grace poll.
293
+ if (response.id && response.status === 'in_progress') {
294
+ const polishingStart = now();
295
+ const pollIntervalMs = 2_000;
296
+ const maxWaitMs = 60_000;
297
+ log(chalk.dim('Response still in_progress; polling until completion...'));
298
+ // Short polling loop — we don't want to hang forever, just catch late finalization.
299
+ while (now() - polishingStart < maxWaitMs) {
300
+ await wait(pollIntervalMs);
301
+ const refreshed = await clientInstance.responses.retrieve(response.id);
302
+ if (refreshed.status === 'completed') {
303
+ response = refreshed;
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ if (response.status !== 'completed') {
309
+ const detail = response.error?.message || response.incomplete_details?.reason || response.status;
310
+ log(chalk.yellow(`API ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
311
+ throw new OracleResponseError(`Response did not complete: ${detail}`, response);
312
+ }
237
313
  }
238
314
  const answerText = extractTextOutput(response);
239
315
  if (!options.silent) {
240
316
  // biome-ignore lint/nursery/noUnnecessaryConditions: flips true when streaming events arrive
241
317
  if (sawTextDelta) {
242
- write('\n\n');
318
+ write('\n');
243
319
  }
244
320
  else {
245
321
  ensureAnswerHeader();
@@ -261,18 +337,26 @@ export async function runOracle(options, deps = {}) {
261
337
  const tokensDisplay = [inputTokens, outputTokens, reasoningTokens, totalTokens]
262
338
  .map((value, index) => formatTokenValue(value, usage, index))
263
339
  .join('/');
264
- statsParts.push(`tok(i/o/r/t)=${tokensDisplay}`);
340
+ const tokensLabel = options.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
341
+ statsParts.push(`${tokensLabel}=${tokensDisplay}`);
342
+ const actualInput = usage.input_tokens;
343
+ if (actualInput !== undefined) {
344
+ const delta = actualInput - estimatedInputTokens;
345
+ const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
346
+ statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
347
+ }
265
348
  if (!searchEnabled) {
266
349
  statsParts.push('search=off');
267
350
  }
268
351
  if (files.length > 0) {
269
352
  statsParts.push(`files=${files.length}`);
270
353
  }
271
- log(chalk.blue(`Finished in ${elapsedDisplay} (${statsParts.join(' | ')})`));
354
+ const sessionPrefix = options.sessionId ? `${options.sessionId} ` : '';
355
+ log(chalk.blue(`Finished ${sessionPrefix}in ${elapsedDisplay} (${statsParts.join(' | ')})`));
272
356
  return {
273
357
  mode: 'live',
274
358
  response,
275
- usage: { inputTokens, outputTokens, reasoningTokens, totalTokens },
359
+ usage: { inputTokens, outputTokens, reasoningTokens, totalTokens, cost },
276
360
  elapsedMs,
277
361
  };
278
362
  }
@@ -317,13 +401,13 @@ export function extractTextOutput(response) {
317
401
  return '';
318
402
  }
319
403
  async function executeBackgroundResponse(params) {
320
- const { client, requestBody, log, wait, heartbeatIntervalMs, now } = params;
404
+ const { client, requestBody, log, wait, heartbeatIntervalMs, now, maxWaitMs } = params;
321
405
  const initialResponse = await client.responses.create(requestBody);
322
406
  if (!initialResponse || !initialResponse.id) {
323
- throw new OracleResponseError('OpenAI did not return a response ID for the background run.', initialResponse);
407
+ throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
324
408
  }
325
409
  const responseId = initialResponse.id;
326
- log(dim(`OpenAI scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(BACKGROUND_MAX_WAIT_MS / 60000)} minutes for completion...`));
410
+ log(dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(BACKGROUND_MAX_WAIT_MS / 60000)} minutes for completion...`));
327
411
  let heartbeatActive = false;
328
412
  let stopHeartbeat = null;
329
413
  const stopHeartbeatNow = () => {
@@ -342,7 +426,7 @@ async function executeBackgroundResponse(params) {
342
426
  isActive: () => heartbeatActive,
343
427
  makeMessage: (elapsedMs) => {
344
428
  const elapsedText = formatElapsed(elapsedMs);
345
- return `OpenAI background run still in progress — ${elapsedText} elapsed.`;
429
+ return `API background run still in progress — ${elapsedText} elapsed.`;
346
430
  },
347
431
  });
348
432
  }
@@ -354,7 +438,7 @@ async function executeBackgroundResponse(params) {
354
438
  log,
355
439
  wait,
356
440
  now,
357
- maxWaitMs: BACKGROUND_MAX_WAIT_MS,
441
+ maxWaitMs,
358
442
  });
359
443
  }
360
444
  finally {
@@ -373,10 +457,10 @@ async function pollBackgroundResponse(params) {
373
457
  // biome-ignore lint/nursery/noUnnecessaryConditions: guard only for first iteration
374
458
  if (firstCycle) {
375
459
  firstCycle = false;
376
- log(dim(`OpenAI background response status=${status}. We'll keep retrying automatically.`));
460
+ log(dim(`API background response status=${status}. We'll keep retrying automatically.`));
377
461
  }
378
462
  else if (status !== lastStatus && status !== 'completed') {
379
- log(dim(`OpenAI background response status=${status}.`));
463
+ log(dim(`API background response status=${status}.`));
380
464
  }
381
465
  lastStatus = status;
382
466
  if (status === 'completed') {
@@ -387,11 +471,11 @@ async function pollBackgroundResponse(params) {
387
471
  throw new OracleResponseError(`Response did not complete: ${detail}`, response);
388
472
  }
389
473
  if (now() - startMark >= maxWaitMs) {
390
- throw new OracleTransportError('client-timeout', 'Timed out waiting for OpenAI background response to finish.');
474
+ throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
391
475
  }
392
476
  await wait(BACKGROUND_POLL_INTERVAL_MS);
393
477
  if (now() - startMark >= maxWaitMs) {
394
- throw new OracleTransportError('client-timeout', 'Timed out waiting for OpenAI background response to finish.');
478
+ throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
395
479
  }
396
480
  const { response: nextResponse, reconnected } = await retrieveBackgroundResponseWithRetry({
397
481
  client,
@@ -404,7 +488,7 @@ async function pollBackgroundResponse(params) {
404
488
  });
405
489
  if (reconnected) {
406
490
  const nextStatus = nextResponse.status ?? 'in_progress';
407
- log(dim(`Reconnected to OpenAI background response (status=${nextStatus}). OpenAI is still working...`));
491
+ log(dim(`Reconnected to API background response (status=${nextStatus}). API is still working...`));
408
492
  }
409
493
  response = nextResponse;
410
494
  }
@@ -428,7 +512,7 @@ async function retrieveBackgroundResponseWithRetry(params) {
428
512
  log(chalk.yellow(`${describeTransportError(transportError)} Retrying in ${formatElapsed(delay)}...`));
429
513
  await wait(delay);
430
514
  if (now() - startMark >= maxWaitMs) {
431
- throw new OracleTransportError('client-timeout', 'Timed out waiting for OpenAI background response to finish.');
515
+ throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
432
516
  }
433
517
  }
434
518
  }
@@ -0,0 +1,34 @@
1
+ import { TOKENIZER_OPTIONS } from './config.js';
2
+ /**
3
+ * Estimate input tokens from the full request body instead of just system/user text.
4
+ * This is a conservative approximation: we tokenize the key textual fields and add a fixed buffer
5
+ * to cover structural JSON overhead and server-side wrappers (tools/reasoning/background/store).
6
+ */
7
+ export function estimateRequestTokens(requestBody, modelConfig, bufferTokens = 200) {
8
+ const parts = [];
9
+ if (requestBody.instructions) {
10
+ parts.push(requestBody.instructions);
11
+ }
12
+ for (const turn of requestBody.input ?? []) {
13
+ for (const content of turn.content ?? []) {
14
+ if (typeof content.text === 'string') {
15
+ parts.push(content.text);
16
+ }
17
+ }
18
+ }
19
+ if (requestBody.tools && requestBody.tools.length > 0) {
20
+ parts.push(JSON.stringify(requestBody.tools));
21
+ }
22
+ if (requestBody.reasoning) {
23
+ parts.push(JSON.stringify(requestBody.reasoning));
24
+ }
25
+ if (requestBody.background) {
26
+ parts.push('background:true');
27
+ }
28
+ if (requestBody.store) {
29
+ parts.push('store:true');
30
+ }
31
+ const concatenated = parts.join('\n');
32
+ const baseEstimate = modelConfig.tokenizer(concatenated, TOKENIZER_OPTIONS);
33
+ return baseEstimate + bufferTokens;
34
+ }
@@ -7,3 +7,4 @@ export { getFileTokenStats, printFileTokenStats } from './oracle/tokenStats.js';
7
7
  export { OracleResponseError, OracleTransportError, OracleUserError, FileValidationError, BrowserAutomationError, PromptValidationError, describeTransportError, extractResponseMetadata, asOracleUserError, toTransportError, } from './oracle/errors.js';
8
8
  export { createDefaultClientFactory } from './oracle/client.js';
9
9
  export { runOracle, extractTextOutput } from './oracle/run.js';
10
+ export { resolveGeminiModelId } from './oracle/gemini.js';
@@ -5,6 +5,7 @@ import { createWriteStream } from 'node:fs';
5
5
  const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
6
6
  const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
7
7
  const MAX_STATUS_LIMIT = 1000;
8
+ const ZOMBIE_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
8
9
  const DEFAULT_SLUG = 'session';
9
10
  const MAX_SLUG_WORDS = 5;
10
11
  const MIN_CUSTOM_SLUG_WORDS = 3;
@@ -67,7 +68,7 @@ async function ensureUniqueSessionId(baseSlug) {
67
68
  }
68
69
  return candidate;
69
70
  }
70
- export async function initializeSession(options, cwd) {
71
+ export async function initializeSession(options, cwd, notifications) {
71
72
  await ensureSessionStorage();
72
73
  const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
73
74
  const sessionId = await ensureUniqueSessionId(baseSlug);
@@ -84,6 +85,7 @@ export async function initializeSession(options, cwd) {
84
85
  cwd,
85
86
  mode,
86
87
  browser: browserConfig ? { config: browserConfig } : undefined,
88
+ notifications,
87
89
  options: {
88
90
  prompt: options.prompt,
89
91
  file: options.file ?? [],
@@ -99,7 +101,11 @@ export async function initializeSession(options, cwd) {
99
101
  verbose: options.verbose,
100
102
  heartbeatIntervalMs: options.heartbeatIntervalMs,
101
103
  browserInlineFiles: options.browserInlineFiles,
104
+ browserBundleFiles: options.browserBundleFiles,
102
105
  background: options.background,
106
+ search: options.search,
107
+ baseUrl: options.baseUrl,
108
+ azure: options.azure,
103
109
  },
104
110
  };
105
111
  await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
@@ -110,7 +116,8 @@ export async function initializeSession(options, cwd) {
110
116
  export async function readSessionMetadata(sessionId) {
111
117
  try {
112
118
  const raw = await fs.readFile(metaPath(sessionId), 'utf8');
113
- return JSON.parse(raw);
119
+ const parsed = JSON.parse(raw);
120
+ return await markZombie(parsed, { persist: false }); // transient check; do not touch disk on single read
114
121
  }
115
122
  catch {
116
123
  return null;
@@ -138,8 +145,9 @@ export async function listSessionsMetadata() {
138
145
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
139
146
  const metas = [];
140
147
  for (const entry of entries) {
141
- const meta = await readSessionMetadata(entry);
148
+ let meta = await readSessionMetadata(entry);
142
149
  if (meta) {
150
+ meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
143
151
  metas.push(meta);
144
152
  }
145
153
  }
@@ -164,6 +172,15 @@ export async function readSessionLog(sessionId) {
164
172
  return '';
165
173
  }
166
174
  }
175
+ export async function readSessionRequest(sessionId) {
176
+ try {
177
+ const raw = await fs.readFile(requestPath(sessionId), 'utf8');
178
+ return JSON.parse(raw);
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
167
184
  export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
168
185
  await ensureSessionStorage();
169
186
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
@@ -203,6 +220,7 @@ export async function wait(ms) {
203
220
  return new Promise((resolve) => setTimeout(resolve, ms));
204
221
  }
205
222
  export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
223
+ export { ZOMBIE_MAX_AGE_MS };
206
224
  export async function getSessionPaths(sessionId) {
207
225
  const dir = sessionDir(sessionId);
208
226
  const metadata = metaPath(sessionId);
@@ -220,3 +238,32 @@ export async function getSessionPaths(sessionId) {
220
238
  }
221
239
  return { dir, metadata, log, request };
222
240
  }
241
+ async function markZombie(meta, { persist }) {
242
+ if (!isZombie(meta)) {
243
+ return meta;
244
+ }
245
+ const updated = {
246
+ ...meta,
247
+ status: 'error',
248
+ errorMessage: 'Session marked as zombie (>30m stale)',
249
+ completedAt: new Date().toISOString(),
250
+ };
251
+ if (persist) {
252
+ await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
253
+ }
254
+ return updated;
255
+ }
256
+ function isZombie(meta) {
257
+ if (meta.status !== 'running') {
258
+ return false;
259
+ }
260
+ const reference = meta.startedAt ?? meta.createdAt;
261
+ if (!reference) {
262
+ return false;
263
+ }
264
+ const startedMs = Date.parse(reference);
265
+ if (Number.isNaN(startedMs)) {
266
+ return false;
267
+ }
268
+ return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
269
+ }
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.steipete.oracle.notifier</string>
7
+ <key>CFBundleName</key>
8
+ <string>OracleNotifier</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Oracle Notifier</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>OracleNotifier</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>OracleIcon</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>LSMinimumSystemVersion</key>
18
+ <string>13.0</string>
19
+ </dict>
20
+ </plist>