@steipete/oracle 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +55 -10
  2. package/dist/bin/oracle-cli.js +440 -98
  3. package/dist/src/browser/actions/modelSelection.js +74 -20
  4. package/dist/src/browser/actions/navigation.js +5 -3
  5. package/dist/src/browser/actions/promptComposer.js +76 -18
  6. package/dist/src/browser/actions/thinkingTime.js +133 -19
  7. package/dist/src/browser/constants.js +1 -1
  8. package/dist/src/browser/index.js +78 -9
  9. package/dist/src/browser/manualLoginProfile.js +54 -0
  10. package/dist/src/browser/projectSourcesRunner.js +16 -5
  11. package/dist/src/browser/prompt.js +56 -37
  12. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  13. package/dist/src/browser/reattachability.js +22 -0
  14. package/dist/src/browser/sessionRunner.js +73 -1
  15. package/dist/src/browser/utils.js +1 -47
  16. package/dist/src/browser/zipBundle.js +152 -0
  17. package/dist/src/cli/browserConfig.js +13 -11
  18. package/dist/src/cli/browserDefaults.js +2 -1
  19. package/dist/src/cli/docsCheck.js +186 -0
  20. package/dist/src/cli/engine.js +11 -4
  21. package/dist/src/cli/options.js +12 -6
  22. package/dist/src/cli/perfTrace.js +242 -0
  23. package/dist/src/cli/promptRequirement.js +2 -0
  24. package/dist/src/cli/providerDoctor.js +85 -0
  25. package/dist/src/cli/runOptions.js +46 -16
  26. package/dist/src/cli/sessionDisplay.js +47 -4
  27. package/dist/src/cli/sessionLifecycle.js +38 -0
  28. package/dist/src/cli/sessionRunner.js +272 -3
  29. package/dist/src/cli/sessionTable.js +2 -1
  30. package/dist/src/duration.js +47 -0
  31. package/dist/src/mcp/tools/consult.js +19 -3
  32. package/dist/src/mcp/types.js +1 -0
  33. package/dist/src/mcp/utils.js +4 -1
  34. package/dist/src/oracle/baseUrl.js +17 -0
  35. package/dist/src/oracle/client.js +1 -22
  36. package/dist/src/oracle/config.js +17 -4
  37. package/dist/src/oracle/gemini.js +2 -22
  38. package/dist/src/oracle/geminiModels.js +21 -0
  39. package/dist/src/oracle/modelResolver.js +7 -1
  40. package/dist/src/oracle/multiModelRunner.js +20 -2
  41. package/dist/src/oracle/providerFailures.js +204 -0
  42. package/dist/src/oracle/providerRoutePlan.js +308 -0
  43. package/dist/src/oracle/providerRouting.js +92 -0
  44. package/dist/src/oracle/run.js +104 -107
  45. package/dist/src/oracle.js +1 -0
  46. package/dist/src/remote/client.js +8 -0
  47. package/dist/src/remote/server.js +26 -0
  48. package/dist/src/sessionManager.js +43 -23
  49. package/package.json +15 -12
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { performance } from "node:perf_hooks";
7
- import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from "./config.js";
7
+ import { DEFAULT_SYSTEM_PROMPT, TOKENIZER_OPTIONS } from "./config.js";
8
8
  import { readFiles } from "./files.js";
9
9
  import { buildPrompt, buildRequestBody } from "./request.js";
10
10
  import { estimateRequestTokens } from "./tokenEstimate.js";
@@ -12,8 +12,9 @@ import { formatElapsed } from "./format.js";
12
12
  import { formatFinishLine } from "./finishLine.js";
13
13
  import { getFileTokenStats, printFileTokenStats } from "./tokenStats.js";
14
14
  import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from "./errors.js";
15
- import { createDefaultClientFactory, isCustomBaseUrl } from "./client.js";
16
- import { formatBaseUrlForLog, maskApiKey } from "./logging.js";
15
+ import { isCustomBaseUrl } from "./baseUrl.js";
16
+ import { createDefaultClientFactory } from "./client.js";
17
+ import { maskApiKey } from "./logging.js";
17
18
  import { startHeartbeat } from "../heartbeat.js";
18
19
  import { startOscProgress } from "./oscProgress.js";
19
20
  import { createFsAdapter } from "./fsAdapter.js";
@@ -24,7 +25,9 @@ import { createMarkdownStreamer } from "markdansi";
24
25
  import { executeBackgroundResponse } from "./background.js";
25
26
  import { formatTokenEstimate, formatTokenValue, resolvePreviewMode } from "./runUtils.js";
26
27
  import { estimateUsdCost } from "tokentally";
27
- import { defaultOpenRouterBaseUrl, isKnownModel, isOpenRouterBaseUrl, isProModel, resolveModelConfig, normalizeOpenRouterBaseUrl, } from "./modelResolver.js";
28
+ import { isOpenRouterBaseUrl, isProModel, resolveModelConfig } from "./modelResolver.js";
29
+ import { validateProviderRouting } from "./providerRouting.js";
30
+ import { formatRouteTargetForLog, resolveProviderRoute, } from "./providerRoutePlan.js";
28
31
  const isStdoutTty = process.stdout.isTTY && chalk.level > 0;
29
32
  const dim = (text) => (isStdoutTty ? kleur.dim(text) : text);
30
33
  // Default timeout for non-pro API runs (fast models) — give them up to 120s.
@@ -33,100 +36,84 @@ const DEFAULT_TIMEOUT_PRO_MS = 60 * 60 * 1000;
33
36
  const defaultWait = (ms) => new Promise((resolve) => {
34
37
  setTimeout(resolve, ms);
35
38
  });
39
+ function formatProviderRouteLogLine(route, keySource) {
40
+ if (route.isAzureOpenAI) {
41
+ return `Provider: Azure OpenAI | endpoint: ${formatRouteTargetForLog(route.azureEndpoint)} | deployment: ${route.azureDeploymentName || "none"} | key: ${keySource}`;
42
+ }
43
+ return `Provider: ${route.providerLabel} | base: ${route.base} | key: ${keySource}`;
44
+ }
45
+ function runtimeKeySource({ route, providerMode, optionsApiKey, }) {
46
+ if (optionsApiKey &&
47
+ (route.isAzureOpenAI ||
48
+ providerMode === "openai" ||
49
+ route.provider === "openai" ||
50
+ route.providerLabel === "OpenAI-compatible")) {
51
+ return "apiKey option";
52
+ }
53
+ if (route.isAzureOpenAI) {
54
+ return "AZURE_OPENAI_API_KEY|OPENAI_API_KEY";
55
+ }
56
+ if (providerMode === "openai") {
57
+ return "OPENAI_API_KEY";
58
+ }
59
+ if (isOpenRouterBaseUrl(route.baseUrl) || route.openRouterFallback || route.model.includes("/")) {
60
+ return "OPENROUTER_API_KEY";
61
+ }
62
+ if (route.model.startsWith("gpt"))
63
+ return "OPENAI_API_KEY";
64
+ if (route.model.startsWith("gemini"))
65
+ return "GEMINI_API_KEY";
66
+ if (route.model.startsWith("claude"))
67
+ return "ANTHROPIC_API_KEY";
68
+ if (route.model.startsWith("grok"))
69
+ return "XAI_API_KEY";
70
+ return optionsApiKey ? "apiKey option" : route.keySource;
71
+ }
36
72
  export async function runOracle(options, deps = {}) {
37
73
  const { apiKey: optionsApiKey = options.apiKey, cwd = process.cwd(), fs: fsModule = createFsAdapter(fs), log = console.log, write: sinkWrite = (_text) => true, allowStdout = true, stdoutWrite: stdoutWriteDep, now = () => performance.now(), clientFactory = createDefaultClientFactory(), client, wait = defaultWait, } = deps;
38
74
  const stdoutWrite = allowStdout
39
75
  ? (stdoutWriteDep ?? process.stdout.write.bind(process.stdout))
40
76
  : () => true;
41
77
  const isTty = allowStdout && isStdoutTty;
42
- const resolvedXaiBaseUrl = process.env.XAI_BASE_URL?.trim() || "https://api.x.ai/v1";
43
- const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim();
44
- const defaultOpenRouterBase = defaultOpenRouterBaseUrl();
45
- const knownModelConfig = isKnownModel(options.model) ? MODEL_CONFIGS[options.model] : undefined;
46
- const provider = knownModelConfig?.provider ?? "other";
47
- const hasOpenAIKey = Boolean(optionsApiKey) ||
48
- Boolean(process.env.OPENAI_API_KEY) ||
49
- Boolean(process.env.AZURE_OPENAI_API_KEY && options.azure?.endpoint);
50
- const hasAnthropicKey = Boolean(optionsApiKey) || Boolean(process.env.ANTHROPIC_API_KEY);
51
- const hasGeminiKey = Boolean(optionsApiKey) || Boolean(process.env.GEMINI_API_KEY);
52
- const hasXaiKey = Boolean(optionsApiKey) || Boolean(process.env.XAI_API_KEY);
53
- let baseUrl = options.baseUrl?.trim();
54
- if (!baseUrl) {
55
- if (options.model.startsWith("grok")) {
56
- baseUrl = resolvedXaiBaseUrl;
57
- }
58
- else if (provider === "anthropic") {
59
- baseUrl = process.env.ANTHROPIC_BASE_URL?.trim();
60
- }
61
- else {
62
- baseUrl = process.env.OPENAI_BASE_URL?.trim();
63
- }
64
- }
65
- const providerKeyMissing = (provider === "openai" && !hasOpenAIKey) ||
66
- (provider === "anthropic" && !hasAnthropicKey) ||
67
- (provider === "google" && !hasGeminiKey) ||
68
- (provider === "xai" && !hasXaiKey) ||
69
- provider === "other";
70
- const openRouterFallback = providerKeyMissing && Boolean(openRouterApiKey);
71
- if (!baseUrl || openRouterFallback) {
72
- if (openRouterFallback) {
73
- baseUrl = defaultOpenRouterBase;
74
- }
75
- }
76
- if (baseUrl && isOpenRouterBaseUrl(baseUrl)) {
77
- baseUrl = normalizeOpenRouterBaseUrl(baseUrl);
78
- }
79
- const logVerbose = (message) => {
80
- if (options.verbose) {
81
- log(dim(`[verbose] ${message}`));
82
- }
83
- };
84
78
  const previewMode = resolvePreviewMode(options.previewMode ?? options.preview);
85
79
  const isPreview = Boolean(previewMode);
86
- const isAzureOpenAI = Boolean(options.azure?.endpoint);
87
- const getApiKeyForModel = (model) => {
88
- if (isOpenRouterBaseUrl(baseUrl) || openRouterFallback) {
89
- return { key: optionsApiKey ?? openRouterApiKey, source: "OPENROUTER_API_KEY" };
90
- }
91
- if (typeof model === "string" && model.startsWith("gpt")) {
92
- if (optionsApiKey)
93
- return { key: optionsApiKey, source: "apiKey option" };
94
- if (isAzureOpenAI) {
95
- const key = process.env.AZURE_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY;
96
- return { key, source: "AZURE_OPENAI_API_KEY|OPENAI_API_KEY" };
80
+ const providerMode = options.provider ?? "auto";
81
+ validateProviderRouting({
82
+ model: options.model,
83
+ providerMode,
84
+ azure: options.azure,
85
+ }, {
86
+ onAzureDeploymentMissing: (state) => {
87
+ if (!isPreview && !options.suppressHeader) {
88
+ log(dim(`Provider: Azure OpenAI | endpoint: ${formatRouteTargetForLog(state.azureEndpoint)} | deployment: none | key: ${optionsApiKey ? "apiKey option" : "AZURE_OPENAI_API_KEY|OPENAI_API_KEY"}`));
97
89
  }
98
- return { key: process.env.OPENAI_API_KEY, source: "OPENAI_API_KEY" };
99
- }
100
- if (typeof model === "string" && model.startsWith("gemini")) {
101
- return { key: optionsApiKey ?? process.env.GEMINI_API_KEY, source: "GEMINI_API_KEY" };
102
- }
103
- if (typeof model === "string" && model.startsWith("claude")) {
104
- return { key: optionsApiKey ?? process.env.ANTHROPIC_API_KEY, source: "ANTHROPIC_API_KEY" };
105
- }
106
- if (typeof model === "string" && model.startsWith("grok")) {
107
- return { key: optionsApiKey ?? process.env.XAI_API_KEY, source: "XAI_API_KEY" };
90
+ },
91
+ });
92
+ const route = resolveProviderRoute({
93
+ model: options.model,
94
+ providerMode,
95
+ azure: options.azure,
96
+ baseUrl: options.baseUrl,
97
+ apiKey: optionsApiKey,
98
+ env: process.env,
99
+ });
100
+ const { isAzureOpenAI, azureDeploymentName } = route;
101
+ const baseUrl = route.baseUrl;
102
+ const openRouterFallback = route.openRouterFallback;
103
+ const logVerbose = (message) => {
104
+ if (options.verbose) {
105
+ log(dim(`[verbose] ${message}`));
108
106
  }
109
- return {
110
- key: optionsApiKey ?? openRouterApiKey,
111
- source: optionsApiKey ? "apiKey option" : "OPENROUTER_API_KEY",
112
- };
113
107
  };
114
- const apiKeyResult = getApiKeyForModel(options.model);
115
- const apiKey = apiKeyResult.key;
108
+ const apiKey = route.apiKey;
116
109
  if (!apiKey) {
117
- const envVar = isOpenRouterBaseUrl(baseUrl) || openRouterFallback
118
- ? "OPENROUTER_API_KEY"
119
- : options.model.startsWith("gpt")
120
- ? isAzureOpenAI
121
- ? "AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)"
122
- : "OPENAI_API_KEY"
123
- : options.model.startsWith("gemini")
124
- ? "GEMINI_API_KEY"
125
- : options.model.startsWith("claude")
126
- ? "ANTHROPIC_API_KEY"
127
- : options.model.startsWith("grok")
128
- ? "XAI_API_KEY"
129
- : "OPENROUTER_API_KEY";
110
+ const envVar = isAzureOpenAI
111
+ ? "AZURE_OPENAI_API_KEY (or OPENAI_API_KEY)"
112
+ : providerMode === "openai"
113
+ ? "OPENAI_API_KEY"
114
+ : isOpenRouterBaseUrl(baseUrl) || openRouterFallback
115
+ ? "OPENROUTER_API_KEY"
116
+ : route.keySource;
130
117
  const browserModeHint = options.model.startsWith("gpt")
131
118
  ? ' If you have a ChatGPT Pro subscription, retry with --engine browser (or MCP engine:"browser" / preset:"chatgpt-pro-heavy"); browser mode uses your signed-in ChatGPT session instead of an API key.'
132
119
  : "";
@@ -134,7 +121,7 @@ export async function runOracle(options, deps = {}) {
134
121
  env: envVar,
135
122
  });
136
123
  }
137
- const envVar = apiKeyResult.source;
124
+ const envVar = runtimeKeySource({ route, providerMode, optionsApiKey });
138
125
  const minPromptLength = Number.parseInt(process.env.ORACLE_MIN_PROMPT_CHARS ?? "10", 10);
139
126
  const promptLength = options.prompt?.trim().length ?? 0;
140
127
  // Enforce the short-prompt guardrail on pro-tier models because they're costly; cheaper models can run short prompts without blocking.
@@ -142,7 +129,7 @@ export async function runOracle(options, deps = {}) {
142
129
  if (isProTierModel && !Number.isNaN(minPromptLength) && promptLength < minPromptLength) {
143
130
  throw new PromptValidationError(`Prompt is too short (<${minPromptLength} chars). This was likely accidental; please provide more detail.`, { minPromptLength, promptLength });
144
131
  }
145
- const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? (openRouterApiKey ?? apiKey) : undefined;
132
+ const resolverOpenRouterApiKey = openRouterFallback || isOpenRouterBaseUrl(baseUrl) ? apiKey : undefined;
146
133
  const modelConfig = await resolveModelConfig(options.model, {
147
134
  baseUrl,
148
135
  openRouterApiKey: resolverOpenRouterApiKey,
@@ -199,14 +186,17 @@ export async function runOracle(options, deps = {}) {
199
186
  : DEFAULT_TIMEOUT_NON_PRO_MS / 1000
200
187
  : options.timeoutSeconds;
201
188
  const timeoutMs = timeoutSeconds * 1000;
202
- const azureDeploymentName = isAzureOpenAI ? options.azure?.deployment?.trim() : undefined;
189
+ const httpTimeoutMs = typeof options.httpTimeoutMs === "number" &&
190
+ Number.isFinite(options.httpTimeoutMs) &&
191
+ options.httpTimeoutMs > 0
192
+ ? options.httpTimeoutMs
193
+ : timeoutMs;
203
194
  // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
204
- const effectiveModelId = options.effectiveModelId ??
205
- (azureDeploymentName
206
- ? azureDeploymentName
207
- : options.model.startsWith("gemini")
208
- ? resolveGeminiModelId(options.model)
209
- : (modelConfig.apiModel ?? modelConfig.model));
195
+ const effectiveModelId = azureDeploymentName ??
196
+ options.effectiveModelId ??
197
+ (options.model.startsWith("gemini")
198
+ ? resolveGeminiModelId(options.model)
199
+ : (modelConfig.apiModel ?? modelConfig.model));
210
200
  if (!isPreview && options.previousResponseId) {
211
201
  log(dim(`Continuing from response ${options.previousResponseId}`));
212
202
  }
@@ -238,6 +228,7 @@ export async function runOracle(options, deps = {}) {
238
228
  if (!isPreview) {
239
229
  if (!options.suppressHeader) {
240
230
  log(headerLine);
231
+ log(dim(formatProviderRouteLogLine(route, envVar)));
241
232
  }
242
233
  const maskedKey = maskApiKey(apiKey);
243
234
  if (maskedKey && options.verbose) {
@@ -249,9 +240,6 @@ export async function runOracle(options, deps = {}) {
249
240
  effectiveModelId === "gpt-5.5-pro") {
250
241
  log(dim(`Note: \`${modelConfig.model}\` is a stable CLI alias; OpenAI API uses \`gpt-5.5-pro\`.`));
251
242
  }
252
- if (baseUrl) {
253
- log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
254
- }
255
243
  if (effectiveModelId !== modelConfig.model) {
256
244
  log(dim(`Resolved model: ${modelConfig.model} → ${effectiveModelId}`));
257
245
  }
@@ -269,6 +257,11 @@ export async function runOracle(options, deps = {}) {
269
257
  if (isLongRunningModel) {
270
258
  log(dim("This model can take up to 60 minutes (usually replies much faster)."));
271
259
  }
260
+ if (options.verbose || isLongRunningModel || httpTimeoutMs < timeoutMs) {
261
+ const timeoutLine = `Timeouts | overall: ${formatElapsed(timeoutMs)} | transport: ${formatElapsed(httpTimeoutMs)}`;
262
+ const timeoutNote = httpTimeoutMs < timeoutMs ? " | note: transport can fail before overall timeout" : "";
263
+ log(dim(`${timeoutLine}${timeoutNote}`));
264
+ }
272
265
  if (options.verbose || isLongRunningModel) {
273
266
  log(dim("Press Ctrl+C to cancel."));
274
267
  }
@@ -300,25 +293,29 @@ export async function runOracle(options, deps = {}) {
300
293
  inputTokenBudget,
301
294
  };
302
295
  }
303
- const proxyCompatibleBaseUrl = baseUrl && (isOpenRouterBaseUrl(baseUrl) || isCustomBaseUrl(baseUrl)) ? baseUrl : undefined;
304
- const apiEndpoint = modelConfig.model.startsWith("gemini")
305
- ? proxyCompatibleBaseUrl
306
- : proxyCompatibleBaseUrl
296
+ const proxyCompatibleBaseUrl = !isAzureOpenAI && baseUrl && (isOpenRouterBaseUrl(baseUrl) || isCustomBaseUrl(baseUrl))
297
+ ? baseUrl
298
+ : undefined;
299
+ const apiEndpoint = isAzureOpenAI
300
+ ? undefined
301
+ : modelConfig.model.startsWith("gemini")
307
302
  ? proxyCompatibleBaseUrl
308
- : modelConfig.model.startsWith("claude")
309
- ? (process.env.ANTHROPIC_BASE_URL ?? baseUrl)
310
- : baseUrl;
303
+ : proxyCompatibleBaseUrl
304
+ ? proxyCompatibleBaseUrl
305
+ : modelConfig.model.startsWith("claude")
306
+ ? baseUrl
307
+ : baseUrl;
311
308
  const clientInstance = client ??
312
309
  clientFactory(apiKey, {
313
310
  baseUrl: apiEndpoint,
314
- azure: options.azure,
311
+ azure: isAzureOpenAI ? options.azure : undefined,
315
312
  model: options.model,
316
313
  resolvedModelId: modelConfig.model.startsWith("claude")
317
314
  ? resolveClaudeModelId(effectiveModelId)
318
315
  : modelConfig.model.startsWith("gemini")
319
316
  ? resolveGeminiModelId(effectiveModelId)
320
317
  : effectiveModelId,
321
- httpTimeoutMs: options.httpTimeoutMs,
318
+ httpTimeoutMs,
322
319
  });
323
320
  logVerbose("Dispatching request to API...");
324
321
  if (options.verbose) {
@@ -10,3 +10,4 @@ export { OracleResponseError, OracleTransportError, OracleUserError, FileValidat
10
10
  export { createDefaultClientFactory } from "./oracle/client.js";
11
11
  export { runOracle, extractTextOutput } from "./oracle/run.js";
12
12
  export { resolveGeminiModelId } from "./oracle/gemini.js";
13
+ export { classifyProviderFailure } from "./oracle/providerFailures.js";
@@ -8,10 +8,18 @@ export function createRemoteBrowserExecutor({ host, token }) {
8
8
  const payload = {
9
9
  prompt: options.prompt,
10
10
  attachments: await serializeAttachments(options.attachments ?? []),
11
+ fallbackSubmission: options.fallbackSubmission
12
+ ? {
13
+ prompt: options.fallbackSubmission.prompt,
14
+ attachments: await serializeAttachments(options.fallbackSubmission.attachments ?? []),
15
+ }
16
+ : undefined,
11
17
  browserConfig: options.config ?? {},
12
18
  options: {
13
19
  heartbeatIntervalMs: options.heartbeatIntervalMs,
14
20
  verbose: options.verbose,
21
+ sessionId: options.sessionId,
22
+ followUpPrompts: options.followUpPrompts,
15
23
  },
16
24
  };
17
25
  const body = Buffer.from(JSON.stringify(payload));
@@ -119,6 +119,7 @@ export async function createRemoteServer(options = {}, deps = {}) {
119
119
  res.write(`${JSON.stringify(event)}\n`);
120
120
  };
121
121
  const attachments = [];
122
+ let fallbackSubmission;
122
123
  try {
123
124
  const attachmentsPayload = Array.isArray(payload.attachments) ? payload.attachments : [];
124
125
  for (const [index, attachment] of attachmentsPayload.entries()) {
@@ -131,6 +132,28 @@ export async function createRemoteServer(options = {}, deps = {}) {
131
132
  sizeBytes: attachment.sizeBytes,
132
133
  });
133
134
  }
135
+ if (payload.fallbackSubmission) {
136
+ const fallbackAttachmentDir = path.join(runDir, "fallback-attachments");
137
+ await mkdir(fallbackAttachmentDir, { recursive: true });
138
+ const fallbackAttachments = [];
139
+ const fallbackPayload = Array.isArray(payload.fallbackSubmission.attachments)
140
+ ? payload.fallbackSubmission.attachments
141
+ : [];
142
+ for (const [index, attachment] of fallbackPayload.entries()) {
143
+ const safeName = sanitizeName(attachment.fileName ?? `fallback-attachment-${index + 1}`);
144
+ const filePath = path.join(fallbackAttachmentDir, safeName);
145
+ await writeFile(filePath, Buffer.from(attachment.contentBase64, "base64"));
146
+ fallbackAttachments.push({
147
+ path: filePath,
148
+ displayPath: attachment.displayPath,
149
+ sizeBytes: attachment.sizeBytes,
150
+ });
151
+ }
152
+ fallbackSubmission = {
153
+ prompt: payload.fallbackSubmission.prompt,
154
+ attachments: fallbackAttachments,
155
+ };
156
+ }
134
157
  // Reuse the existing browser logger surface so clients see the same log stream.
135
158
  const automationLogger = ((message) => {
136
159
  if (typeof message === "string") {
@@ -159,10 +182,13 @@ export async function createRemoteServer(options = {}, deps = {}) {
159
182
  const result = await runBrowser({
160
183
  prompt: payload.prompt,
161
184
  attachments,
185
+ fallbackSubmission,
162
186
  config: payload.browserConfig,
163
187
  log: automationLogger,
164
188
  heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
165
189
  verbose: payload.options.verbose,
190
+ sessionId: payload.options.sessionId,
191
+ followUpPrompts: payload.options.followUpPrompts,
166
192
  });
167
193
  sendEvent({ type: "result", result: sanitizeResult(result) });
168
194
  logger(`[serve] Run ${runId} completed in ${Date.now() - runStartedAt}ms`);
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs/promises";
3
- import { createWriteStream } from "node:fs";
3
+ import { createWriteStream, mkdirSync } from "node:fs";
4
4
  import net from "node:net";
5
- import { DEFAULT_MODEL, formatElapsed } from "./oracle.js";
5
+ import { DEFAULT_MODEL } from "./oracle/config.js";
6
+ import { formatElapsed } from "./oracle/format.js";
6
7
  import { safeModelSlug } from "./oracle/modelResolver.js";
7
8
  import { getOracleHomeDir } from "./oracleHome.js";
8
9
  export function getSessionsDir() {
@@ -85,14 +86,26 @@ async function fileExists(targetPath) {
85
86
  return false;
86
87
  }
87
88
  }
88
- async function ensureUniqueSessionId(baseSlug) {
89
+ function isFileExistsError(error) {
90
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
91
+ }
92
+ async function reserveUniqueSessionDir(baseSlug) {
89
93
  let candidate = baseSlug;
90
94
  let suffix = 2;
91
- while (await fileExists(sessionDir(candidate))) {
95
+ for (;;) {
96
+ const dir = sessionDir(candidate);
97
+ try {
98
+ await fs.mkdir(dir, { recursive: false });
99
+ return candidate;
100
+ }
101
+ catch (error) {
102
+ if (!isFileExistsError(error)) {
103
+ throw error;
104
+ }
105
+ }
92
106
  candidate = `${baseSlug}-${suffix}`;
93
107
  suffix += 1;
94
108
  }
95
- return candidate;
96
109
  }
97
110
  async function listModelRunFiles(sessionId) {
98
111
  const dir = modelsDir(sessionId);
@@ -152,9 +165,7 @@ export async function readModelRunMetadata(sessionId, model) {
152
165
  export async function initializeSession(options, cwd, notifications, baseSlugOverride) {
153
166
  await ensureSessionStorage();
154
167
  const baseSlug = baseSlugOverride || createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
155
- const sessionId = await ensureUniqueSessionId(baseSlug);
156
- const dir = sessionDir(sessionId);
157
- await ensureDir(dir);
168
+ const sessionId = await reserveUniqueSessionDir(baseSlug);
158
169
  const mode = options.mode ?? "api";
159
170
  const browserConfig = options.browserConfig;
160
171
  const modelList = Array.isArray(options.models) && options.models.length > 0
@@ -199,8 +210,10 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
199
210
  browserAttachments: options.browserAttachments,
200
211
  browserInlineFiles: options.browserInlineFiles,
201
212
  browserBundleFiles: options.browserBundleFiles,
213
+ browserBundleFormat: options.browserBundleFormat,
202
214
  background: options.background,
203
215
  search: options.search,
216
+ provider: options.provider,
204
217
  baseUrl: options.baseUrl,
205
218
  azure: options.azure,
206
219
  timeoutSeconds: options.timeoutSeconds,
@@ -208,6 +221,7 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
208
221
  zombieTimeoutMs: options.zombieTimeoutMs,
209
222
  zombieUseLastActivity: options.zombieUseLastActivity,
210
223
  writeOutputPath: options.writeOutputPath,
224
+ partialMode: options.partialMode,
211
225
  waitPreference: options.waitPreference,
212
226
  youtube: options.youtube,
213
227
  generateImage: options.generateImage,
@@ -235,25 +249,25 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
235
249
  return metadata;
236
250
  }
237
251
  export async function readSessionMetadata(sessionId) {
238
- const modern = await readModernSessionMetadata(sessionId);
252
+ const modern = await readModernSessionMetadata(sessionId, { reconcile: true, persist: false });
239
253
  if (modern) {
240
254
  return modern;
241
255
  }
242
- const legacy = await readLegacySessionMetadata(sessionId);
256
+ const legacy = await readLegacySessionMetadata(sessionId, { reconcile: true, persist: false });
243
257
  if (legacy) {
244
258
  return legacy;
245
259
  }
246
260
  return null;
247
261
  }
248
262
  export async function updateSessionMetadata(sessionId, updates) {
249
- const existing = (await readModernSessionMetadata(sessionId)) ??
250
- (await readLegacySessionMetadata(sessionId)) ??
263
+ const existing = (await readModernSessionMetadata(sessionId, { reconcile: false, persist: false })) ??
264
+ (await readLegacySessionMetadata(sessionId, { reconcile: false, persist: false })) ??
251
265
  { id: sessionId };
252
266
  const next = { ...existing, ...updates };
253
267
  await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), "utf8");
254
268
  return next;
255
269
  }
256
- async function readModernSessionMetadata(sessionId) {
270
+ async function readModernSessionMetadata(sessionId, options) {
257
271
  try {
258
272
  const raw = await fs.readFile(metaPath(sessionId), "utf8");
259
273
  const parsed = JSON.parse(raw);
@@ -261,25 +275,31 @@ async function readModernSessionMetadata(sessionId) {
261
275
  return null;
262
276
  }
263
277
  const enriched = await attachModelRuns(parsed, sessionId);
264
- const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
265
- return await markZombie(runtimeChecked, { persist: false });
278
+ return options.reconcile ? reconcileSessionMetadata(enriched, options) : enriched;
266
279
  }
267
280
  catch {
268
281
  return null;
269
282
  }
270
283
  }
271
- async function readLegacySessionMetadata(sessionId) {
284
+ async function readLegacySessionMetadata(sessionId, options) {
272
285
  try {
273
286
  const raw = await fs.readFile(legacySessionPath(sessionId), "utf8");
274
287
  const parsed = JSON.parse(raw);
275
288
  const enriched = await attachModelRuns(parsed, sessionId);
276
- const runtimeChecked = await markDeadBrowser(enriched, { persist: false });
277
- return await markZombie(runtimeChecked, { persist: false });
289
+ return options.reconcile ? reconcileSessionMetadata(enriched, options) : enriched;
278
290
  }
279
291
  catch {
280
292
  return null;
281
293
  }
282
294
  }
295
+ async function readRawSessionMetadata(sessionId) {
296
+ return ((await readModernSessionMetadata(sessionId, { reconcile: false, persist: false })) ??
297
+ (await readLegacySessionMetadata(sessionId, { reconcile: false, persist: false })));
298
+ }
299
+ async function reconcileSessionMetadata(meta, { persist }) {
300
+ const runtimeChecked = await markDeadBrowser(meta, { persist });
301
+ return await markZombie(runtimeChecked, { persist });
302
+ }
283
303
  function isSessionMetadataRecord(value) {
284
304
  return Boolean(value && typeof value.id === "string" && value.status);
285
305
  }
@@ -293,7 +313,7 @@ async function attachModelRuns(meta, sessionId) {
293
313
  export function createSessionLogWriter(sessionId, model) {
294
314
  const targetPath = model ? modelLogPath(sessionId, model) : logPath(sessionId);
295
315
  if (model) {
296
- void ensureDir(modelsDir(sessionId));
316
+ mkdirSync(modelsDir(sessionId), { recursive: true });
297
317
  }
298
318
  const stream = createWriteStream(targetPath, { flags: "a" });
299
319
  const logLine = (line = "") => {
@@ -310,10 +330,10 @@ export async function listSessionsMetadata() {
310
330
  const entries = await fs.readdir(getSessionsDir()).catch(() => []);
311
331
  const metas = [];
312
332
  for (const entry of entries) {
313
- let meta = await readSessionMetadata(entry);
333
+ let meta = await readRawSessionMetadata(entry);
314
334
  if (meta) {
315
- meta = await markDeadBrowser(meta, { persist: true });
316
- meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
335
+ // Keep stored metadata consistent with status reconciliation done by `oracle status`.
336
+ meta = await reconcileSessionMetadata(meta, { persist: true });
317
337
  metas.push(meta);
318
338
  }
319
339
  }
@@ -384,7 +404,7 @@ export async function readModelLog(sessionId, model) {
384
404
  }
385
405
  }
386
406
  export async function readSessionRequest(sessionId) {
387
- const modern = await readModernSessionMetadata(sessionId);
407
+ const modern = await readModernSessionMetadata(sessionId, { reconcile: false, persist: false });
388
408
  if (modern?.options) {
389
409
  return modern.options;
390
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/steipete/oracle#readme",
@@ -30,6 +30,7 @@
30
30
  "main": "dist/bin/oracle-cli.js",
31
31
  "scripts": {
32
32
  "docs:list": "tsx scripts/docs-list.ts",
33
+ "docs:check": "node --no-deprecation --import tsx bin/oracle-cli.ts docs check",
33
34
  "docs:site": "node scripts/build-docs-site.mjs",
34
35
  "build": "tsgo -p tsconfig.build.json && pnpm run build:vendor",
35
36
  "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const vendorRoot=path.join('dist','vendor'); fs.rmSync(vendorRoot,{recursive:true,force:true}); const vendors=[['oracle-notifier']]; vendors.forEach(([name])=>{const src=path.join('vendor',name); const dest=path.join(vendorRoot,name); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){fs.cpSync(src,dest,{recursive:true,force:true});}});\"",
@@ -45,8 +46,9 @@
45
46
  "test": "vitest run",
46
47
  "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
47
48
  "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
48
- "test:mcp:mcporter": "pnpm dlx mcporter list oracle-local --schema --config config/mcporter.json && pnpm dlx mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
49
+ "test:mcp:mcporter": "CHROME_DEVTOOLS_URL=http://127.0.0.1:0 pnpm dlx mcporter list oracle-local --schema --config config/mcporter.json && CHROME_DEVTOOLS_URL=http://127.0.0.1:0 pnpm dlx mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
49
50
  "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
51
+ "test:packed-cli": "node scripts/packed-cli-smoke.mjs",
50
52
  "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
51
53
  "test:live:fast": "ORACLE_LIVE_TEST=1 ORACLE_LIVE_TEST_FAST=1 vitest run tests/live/browser-fast-live.test.ts",
52
54
  "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
@@ -59,7 +61,7 @@
59
61
  "@google/genai": "^2.0.1",
60
62
  "@google/generative-ai": "^0.24.1",
61
63
  "@modelcontextprotocol/sdk": "^1.29.0",
62
- "@steipete/sweet-cookie": "^0.2.0",
64
+ "@steipete/sweet-cookie": "^0.3.0",
63
65
  "chalk": "^5.6.2",
64
66
  "chrome-launcher": "^1.2.1",
65
67
  "chrome-remote-interface": "^0.34.0",
@@ -68,11 +70,11 @@
68
70
  "dotenv": "^17.4.2",
69
71
  "fast-glob": "^3.3.3",
70
72
  "gpt-tokenizer": "^3.4.0",
71
- "inquirer": "13.4.2",
73
+ "inquirer": "13.4.3",
72
74
  "json5": "^2.2.3",
73
75
  "kleur": "^4.1.5",
74
76
  "markdansi": "0.2.1",
75
- "openai": "^6.35.0",
77
+ "openai": "^6.38.0",
76
78
  "osc-progress": "^0.3.0",
77
79
  "qs": "^6.15.1",
78
80
  "shiki": "^4.0.2",
@@ -82,17 +84,17 @@
82
84
  },
83
85
  "devDependencies": {
84
86
  "@anthropic-ai/tokenizer": "^0.0.4",
85
- "@types/chrome-remote-interface": "^0.33.0",
87
+ "@types/chrome-remote-interface": "^0.34.0",
86
88
  "@types/inquirer": "^9.0.9",
87
89
  "@types/node": "^25.6.0",
88
- "@typescript/native-preview": "7.0.0-dev.20260508.1",
89
- "@vitest/coverage-v8": "4.1.5",
90
- "devtools-protocol": "0.0.1627472",
90
+ "@typescript/native-preview": "7.0.0-dev.20260516.1",
91
+ "@vitest/coverage-v8": "4.1.6",
92
+ "devtools-protocol": "0.0.1629771",
91
93
  "es-toolkit": "^1.46.1",
92
94
  "esbuild": "^0.28.0",
93
- "oxfmt": "0.48.0",
95
+ "oxfmt": "0.50.0",
94
96
  "oxlint": "^1.62.0",
95
- "puppeteer-core": "^24.42.0",
97
+ "puppeteer-core": "^25.0.2",
96
98
  "tsx": "^4.21.0",
97
99
  "typescript": "^6.0.3",
98
100
  "vitest": "^4.1.5"
@@ -111,7 +113,8 @@
111
113
  "packageManager": "pnpm@10.33.2",
112
114
  "pnpm": {
113
115
  "overrides": {
114
- "devtools-protocol": "0.0.1627472"
116
+ "devtools-protocol": "0.0.1629771",
117
+ "vite": "7.3.2"
115
118
  },
116
119
  "onlyBuiltDependencies": [
117
120
  "esbuild"