@steipete/oracle 0.12.0 → 0.13.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.
@@ -7,14 +7,17 @@ import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
7
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
8
8
  import { isAzureOpenAICandidateModel } from "../oracle/providerRouting.js";
9
9
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, models, engine, userConfig, env = process.env, }) {
10
- const resolvedEngine = resolveEngineWithConfig({
10
+ const resolvedEngine = resolveEngine({
11
11
  engine,
12
12
  configEngine: userConfig?.engine,
13
13
  env,
14
14
  });
15
+ const envEnginePreference = (env.ORACLE_ENGINE ?? "").trim().toLowerCase();
15
16
  const browserRequested = engine === "browser";
16
- const browserConfigured = userConfig?.engine === "browser";
17
- const envBrowserConfigured = (env.ORACLE_ENGINE ?? "").trim().toLowerCase() === "browser";
17
+ const explicitApiEngineRequested = engine === "api" || (!engine && envEnginePreference === "api");
18
+ const browserConfigured = userConfig?.engine === "browser" && !explicitApiEngineRequested;
19
+ const envBrowserConfigured = !engine && envEnginePreference === "browser";
20
+ const browserEngineRequested = browserRequested || browserConfigured || envBrowserConfigured;
18
21
  const requestedModelList = Array.isArray(models) ? models : [];
19
22
  const normalizedRequestedModels = requestedModelList
20
23
  .map((entry) => normalizeModelOption(entry))
@@ -32,20 +35,17 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
32
35
  : [apiModel];
33
36
  const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel];
34
37
  const includesGeminiApiOnly = allModels.some((m) => m === "gemini-3.1-pro");
35
- if ((browserRequested || browserConfigured) && includesGeminiApiOnly) {
38
+ if (browserEngineRequested && includesGeminiApiOnly) {
36
39
  throw new PromptValidationError("gemini-3.1-pro is API-only today. Use --engine api or switch to gemini-3-pro for Gemini web.", { engine: "browser", models: allModels });
37
40
  }
38
41
  const isBrowserCompatible = (m) => m.startsWith("gpt-") || m.startsWith("gemini");
39
- const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) &&
40
- browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
42
+ const hasNonBrowserCompatibleTarget = browserEngineRequested && browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
41
43
  if (hasNonBrowserCompatibleTarget) {
42
44
  throw new PromptValidationError("Browser engine only supports GPT and Gemini models. Re-run with --engine api for Grok, Claude, or other models.", { engine: "browser", models: allModels });
43
45
  }
44
46
  const azure = resolveAzureOptions(userConfig, env);
45
47
  const azureAutoApi = Boolean(azure?.endpoint) &&
46
- !browserRequested &&
47
- !browserConfigured &&
48
- !envBrowserConfigured &&
48
+ !browserEngineRequested &&
49
49
  allModels.some(isAzureOpenAICandidateModel);
50
50
  const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || isGeminiApiOnly || azureAutoApi);
51
51
  const fixedEngine = isCodex ||
@@ -89,17 +89,6 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
89
89
  };
90
90
  return { runOptions, resolvedEngine: fixedEngine, engineCoercedToApi };
91
91
  }
92
- function resolveEngineWithConfig({ engine, configEngine, apiProviderRequested, env, }) {
93
- if (engine)
94
- return engine;
95
- const envOverride = (env.ORACLE_ENGINE ?? "").trim().toLowerCase();
96
- if (envOverride === "api" || envOverride === "browser") {
97
- return envOverride;
98
- }
99
- if (configEngine)
100
- return configEngine;
101
- return resolveEngine({ engine: undefined, apiProviderRequested, env });
102
- }
103
92
  function resolveAzureOptions(userConfig, env) {
104
93
  const endpoint = env.AZURE_OPENAI_ENDPOINT ?? userConfig?.azure?.endpoint;
105
94
  if (!endpoint?.trim()) {
@@ -6,6 +6,7 @@ import { formatFinishLine } from "../oracle/finishLine.js";
6
6
  import { sessionStore, wait } from "../sessionStore.js";
7
7
  import { formatTokenCount, formatTokenValue } from "../oracle/runUtils.js";
8
8
  import { resumeBrowserSession } from "../browser/reattach.js";
9
+ import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
9
10
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "../browser/artifacts.js";
10
11
  import { estimateTokenCount } from "../browser/utils.js";
11
12
  import { formatSessionTableHeader, formatSessionTableRow, resolveSessionCost, } from "./sessionTable.js";
@@ -174,9 +175,16 @@ export async function attachSession(sessionId, options) {
174
175
  hasFallbackSessionInfo &&
175
176
  isDeepResearchPlaceholderCapture(metadata, await sessionStore.readLog(sessionId).catch(() => ""));
176
177
  const completedDeepResearchPlaceholder = metadata.status === "completed" && deepResearchPlaceholderCapture;
178
+ const hasRecoverableConversation = hasRecoverableChatGptConversation(runtime);
179
+ const hasLiveChromeFallback = Boolean((metadata.status === "running" || hasIncompleteCapture || completedDeepResearchPlaceholder) &&
180
+ (runtime?.chromePort || runtime?.chromeBrowserWSEndpoint || runtime?.chromeProfileRoot));
177
181
  const canReattach = (statusAllowsReattach || completedDeepResearchPlaceholder) &&
178
182
  metadata.mode === "browser" &&
179
183
  hasFallbackSessionInfo &&
184
+ (hasRecoverableConversation ||
185
+ runtime?.promptSubmitted ||
186
+ hasLiveChromeFallback ||
187
+ completedDeepResearchPlaceholder) &&
180
188
  (hasChromeDisconnect ||
181
189
  hasIncompleteCapture ||
182
190
  completedDeepResearchPlaceholder ||
@@ -21,6 +21,7 @@ import { sanitizeOscProgress } from "./oscUtils.js";
21
21
  import { readFiles } from "../oracle/files.js";
22
22
  import { cwd as getCwd } from "node:process";
23
23
  import { resumeBrowserSession } from "../browser/reattach.js";
24
+ import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
24
25
  import { estimateTokenCount } from "../browser/utils.js";
25
26
  import { formatElapsed } from "../oracle/format.js";
26
27
  const isTty = process.stdout.isTTY;
@@ -390,6 +391,40 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
390
391
  if (connectionLost && mode === "browser") {
391
392
  const runtime = userError.details
392
393
  ?.runtime;
394
+ const recoverableRuntime = runtime ?? sessionMeta.browser?.runtime;
395
+ if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
396
+ recoverableRuntime?.promptSubmitted !== true) {
397
+ log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
398
+ if (modelForStatus) {
399
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
400
+ status: "error",
401
+ completedAt: new Date().toISOString(),
402
+ response: { status: "error", incompleteReason: "chrome-disconnected" },
403
+ error: {
404
+ category: userError.category,
405
+ message: userError.message,
406
+ details: userError.details,
407
+ },
408
+ });
409
+ }
410
+ await sessionStore.updateSession(sessionMeta.id, {
411
+ status: "error",
412
+ completedAt: new Date().toISOString(),
413
+ errorMessage: message,
414
+ mode,
415
+ browser: {
416
+ config: browserConfig,
417
+ runtime: recoverableRuntime,
418
+ },
419
+ response: { status: "error", incompleteReason: "chrome-disconnected" },
420
+ error: {
421
+ category: userError.category,
422
+ message: userError.message,
423
+ details: userError.details,
424
+ },
425
+ });
426
+ throw error;
427
+ }
393
428
  log(dim("Chrome disconnected before completion; keeping session running for reattach."));
394
429
  if (modelForStatus) {
395
430
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
@@ -576,13 +611,22 @@ function sanitizeMultiModelFailureForThrow(error, context) {
576
611
  if (!(error instanceof Error)) {
577
612
  return new Error(message);
578
613
  }
579
- error.message = message;
614
+ let sanitized;
615
+ if (error instanceof OracleTransportError) {
616
+ sanitized = new OracleTransportError(error.reason, message);
617
+ }
618
+ else if (error instanceof OracleResponseError) {
619
+ sanitized = new OracleResponseError(message, error.response);
620
+ }
621
+ else {
622
+ sanitized = new Error(message);
623
+ sanitized.name = error.name;
624
+ }
580
625
  if (error.stack) {
581
- const [firstLine, ...rest] = error.stack.split("\n");
582
- const prefix = firstLine.includes(":") ? firstLine.split(":", 1)[0] : error.name;
583
- error.stack = [prefix ? `${prefix}: ${message}` : message, ...rest].join("\n");
626
+ const [, ...rest] = error.stack.split("\n");
627
+ sanitized.stack = [sanitized.name ? `${sanitized.name}: ${message}` : message, ...rest].join("\n");
584
628
  }
585
- return error;
629
+ return sanitized;
586
630
  }
587
631
  export function deriveOutputManifestPath(basePath) {
588
632
  const ext = path.extname(basePath);
@@ -1,26 +1,181 @@
1
1
  import fs from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import JSON5 from "json5";
4
5
  import { getOracleHomeDir } from "./oracleHome.js";
5
- function resolveConfigPath() {
6
+ export const PROJECT_CONFIG_RELATIVE_PATH = path.join(".oracle", "config.json");
7
+ function resolveUserConfigPath() {
6
8
  return path.join(getOracleHomeDir(), "config.json");
7
9
  }
8
- export async function loadUserConfig() {
9
- const CONFIG_PATH = resolveConfigPath();
10
+ export async function loadUserConfig(options = {}) {
11
+ const userConfigPath = resolveUserConfigPath();
12
+ const userConfig = await readConfigFile(userConfigPath);
13
+ const projectConfigPaths = options.includeProject === false
14
+ ? []
15
+ : await discoverProjectConfigPaths({
16
+ cwd: options.cwd ?? process.cwd(),
17
+ userConfigPath,
18
+ });
19
+ const loadedConfigs = [];
20
+ if (userConfig.loaded) {
21
+ loadedConfigs.push(userConfig);
22
+ }
23
+ let merged = userConfig.loaded ? userConfig.config : {};
24
+ for (const projectConfigPath of projectConfigPaths) {
25
+ const projectConfig = await readConfigFile(projectConfigPath);
26
+ if (!projectConfig.loaded)
27
+ continue;
28
+ loadedConfigs.push(projectConfig);
29
+ merged = mergeUserConfig(merged, sanitizeProjectConfig(projectConfig.config));
30
+ }
31
+ const loadedPaths = loadedConfigs.map((entry) => entry.path);
32
+ return {
33
+ config: merged,
34
+ path: userConfigPath,
35
+ paths: loadedPaths,
36
+ loaded: userConfig.loaded,
37
+ };
38
+ }
39
+ async function readConfigFile(configPath) {
10
40
  try {
11
- const raw = await fs.readFile(CONFIG_PATH, "utf8");
41
+ const raw = await fs.readFile(configPath, "utf8");
12
42
  const parsed = JSON5.parse(raw);
13
- return { config: parsed ?? {}, path: CONFIG_PATH, loaded: true };
43
+ return { config: parsed ?? {}, path: configPath, loaded: true };
14
44
  }
15
45
  catch (error) {
16
46
  const code = error.code;
17
47
  if (code === "ENOENT") {
18
- return { config: {}, path: CONFIG_PATH, loaded: false };
48
+ return { config: {}, path: configPath, loaded: false };
19
49
  }
20
- console.warn(`Failed to read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
21
- return { config: {}, path: CONFIG_PATH, loaded: false };
50
+ console.warn(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
51
+ return { config: {}, path: configPath, loaded: false };
22
52
  }
23
53
  }
24
54
  export function configPath() {
25
- return resolveConfigPath();
55
+ return resolveUserConfigPath();
56
+ }
57
+ async function discoverProjectConfigPaths({ cwd, userConfigPath, }) {
58
+ const start = path.resolve(cwd);
59
+ const home = os.homedir();
60
+ const candidates = [];
61
+ const seen = new Set([path.resolve(userConfigPath)]);
62
+ let current = start;
63
+ while (true) {
64
+ if (current === home) {
65
+ break;
66
+ }
67
+ const candidate = path.join(current, PROJECT_CONFIG_RELATIVE_PATH);
68
+ const resolved = path.resolve(candidate);
69
+ if (!seen.has(resolved)) {
70
+ try {
71
+ const stat = await fs.stat(resolved);
72
+ if (stat.isFile()) {
73
+ candidates.unshift(resolved);
74
+ seen.add(resolved);
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (error.code !== "ENOENT") {
79
+ console.warn(`Failed to inspect ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
82
+ }
83
+ const parent = path.dirname(current);
84
+ if (parent === current) {
85
+ break;
86
+ }
87
+ current = parent;
88
+ }
89
+ return candidates;
90
+ }
91
+ function mergeUserConfig(base, override) {
92
+ return deepMerge(base, override);
93
+ }
94
+ function isRecord(value) {
95
+ return typeof value === "object" && value !== null && !Array.isArray(value);
96
+ }
97
+ function deepMerge(base, override) {
98
+ if (!isRecord(base) || !isRecord(override)) {
99
+ return override;
100
+ }
101
+ const result = { ...base };
102
+ for (const [key, value] of Object.entries(override)) {
103
+ const existing = result[key];
104
+ result[key] = isRecord(existing) && isRecord(value) ? deepMerge(existing, value) : value;
105
+ }
106
+ return result;
107
+ }
108
+ function sanitizeProjectConfig(config) {
109
+ const sanitized = {};
110
+ if (config.engine !== undefined)
111
+ sanitized.engine = config.engine;
112
+ if (config.model !== undefined)
113
+ sanitized.model = config.model;
114
+ if (config.search !== undefined)
115
+ sanitized.search = config.search;
116
+ if (config.maxFileSizeBytes !== undefined)
117
+ sanitized.maxFileSizeBytes = config.maxFileSizeBytes;
118
+ if (config.notify !== undefined)
119
+ sanitized.notify = config.notify;
120
+ if (config.heartbeatSeconds !== undefined)
121
+ sanitized.heartbeatSeconds = config.heartbeatSeconds;
122
+ if (config.filesReport !== undefined)
123
+ sanitized.filesReport = config.filesReport;
124
+ if (config.background !== undefined)
125
+ sanitized.background = config.background;
126
+ if (config.promptSuffix !== undefined)
127
+ sanitized.promptSuffix = config.promptSuffix;
128
+ if (config.browser) {
129
+ sanitized.browser = {};
130
+ const browser = config.browser;
131
+ const allowedBrowserKeys = [
132
+ "attachRunning",
133
+ "timeoutMs",
134
+ "inputTimeoutMs",
135
+ "attachmentTimeoutMs",
136
+ "assistantRecheckDelayMs",
137
+ "assistantRecheckTimeoutMs",
138
+ "reuseChromeWaitMs",
139
+ "profileLockTimeoutMs",
140
+ "maxConcurrentTabs",
141
+ "autoReattachDelayMs",
142
+ "autoReattachIntervalMs",
143
+ "autoReattachTimeoutMs",
144
+ "cookieSyncWaitMs",
145
+ "hideWindow",
146
+ "keepBrowser",
147
+ "modelStrategy",
148
+ "thinkingTime",
149
+ "researchMode",
150
+ "archiveConversations",
151
+ "manualLogin",
152
+ ];
153
+ for (const key of allowedBrowserKeys) {
154
+ if (browser[key] !== undefined) {
155
+ sanitized.browser[key] = browser[key];
156
+ }
157
+ }
158
+ const chatgptUrl = browser.chatgptUrl ?? browser.url;
159
+ if (chatgptUrl === null ||
160
+ (chatgptUrl !== undefined && isTrustedProjectChatgptUrl(chatgptUrl))) {
161
+ sanitized.browser.chatgptUrl = chatgptUrl;
162
+ sanitized.browser.url = chatgptUrl;
163
+ }
164
+ }
165
+ return sanitized;
166
+ }
167
+ function isTrustedProjectChatgptUrl(rawUrl) {
168
+ if (!rawUrl) {
169
+ return false;
170
+ }
171
+ try {
172
+ const parsed = new URL(rawUrl);
173
+ if (parsed.protocol !== "https:") {
174
+ return false;
175
+ }
176
+ return parsed.hostname === "chatgpt.com" || parsed.hostname === "chat.openai.com";
177
+ }
178
+ catch {
179
+ return false;
180
+ }
26
181
  }
@@ -8,7 +8,14 @@ const DEFAULT_PROVIDER_HOSTS = {
8
8
  openai: "api.openai.com",
9
9
  xai: "api.x.ai",
10
10
  };
11
+ export function resolveProviderRoute(input) {
12
+ return buildResolvedProviderRoute(input);
13
+ }
11
14
  export function buildProviderRoutePlan(input) {
15
+ const { apiKey: _apiKey, baseUrl: _baseUrl, nativeProvider: _nativeProvider, openRouterFallback: _openRouterFallback, azureEndpoint: _azureEndpoint, ...plan } = buildResolvedProviderRoute(input);
16
+ return plan;
17
+ }
18
+ function buildResolvedProviderRoute(input) {
12
19
  const env = input.env ?? process.env;
13
20
  const providerMode = input.providerMode ?? "auto";
14
21
  const azureConfigured = Boolean(input.azure?.endpoint?.trim());
@@ -49,7 +56,12 @@ export function buildProviderRoutePlan(input) {
49
56
  keySource: key.source,
50
57
  keyPreview: key.preview,
51
58
  keyPresent: key.present,
59
+ apiKey: key.value,
60
+ nativeProvider: provider,
61
+ baseUrl: input.baseUrl,
62
+ openRouterFallback: false,
52
63
  isAzureOpenAI,
64
+ azureEndpoint: state?.azureEndpoint ?? input.azure?.endpoint,
53
65
  azureConfigured,
54
66
  azureDeploymentName: state?.azureDeploymentName,
55
67
  azureNote: azureNote(providerMode, azureConfigured, isAzureOpenAI),
@@ -145,7 +157,12 @@ export function buildProviderRoutePlan(input) {
145
157
  keySource: key.source,
146
158
  keyPreview: key.preview,
147
159
  keyPresent: key.present,
160
+ apiKey: key.value,
161
+ nativeProvider: provider,
162
+ baseUrl,
163
+ openRouterFallback,
148
164
  isAzureOpenAI,
165
+ azureEndpoint: state.azureEndpoint,
149
166
  azureConfigured,
150
167
  azureDeploymentName: state.azureDeploymentName,
151
168
  azureNote: azureNote(providerMode, azureConfigured, isAzureOpenAI),
@@ -166,7 +183,12 @@ function getNativeKey({ model, provider, providerMode, isAzureOpenAI, apiKey, en
166
183
  }
167
184
  function getKeyForRoute({ model, provider, providerMode, isAzureOpenAI, baseUrl, openRouterFallback, apiKey, env, }) {
168
185
  if (apiKey) {
169
- return { source: "apiKey option", preview: maskApiKey(apiKey) ?? "set", present: true };
186
+ return {
187
+ source: "apiKey option",
188
+ preview: maskApiKey(apiKey) ?? "set",
189
+ present: true,
190
+ value: apiKey,
191
+ };
170
192
  }
171
193
  if (isAzureOpenAI) {
172
194
  return readKey(["AZURE_OPENAI_API_KEY", "OPENAI_API_KEY"], env);
@@ -201,7 +223,12 @@ function readKey(names, env) {
201
223
  for (const name of names) {
202
224
  const value = env[name]?.trim();
203
225
  if (value) {
204
- return { source: name, preview: `${name}=${maskApiKey(value) ?? "set"}`, present: true };
226
+ return {
227
+ source: name,
228
+ preview: `${name}=${maskApiKey(value) ?? "set"}`,
229
+ present: true,
230
+ value,
231
+ };
205
232
  }
206
233
  }
207
234
  return { source: names.join("|"), preview: "missing", present: false };