@steipete/oracle 0.11.1 → 0.12.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 (47) 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 +53 -15
  4. package/dist/src/browser/actions/navigation.js +5 -3
  5. package/dist/src/browser/actions/promptComposer.js +75 -18
  6. package/dist/src/browser/actions/thinkingTime.js +23 -8
  7. package/dist/src/browser/constants.js +1 -1
  8. package/dist/src/browser/index.js +41 -7
  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/sessionRunner.js +72 -1
  13. package/dist/src/browser/utils.js +1 -47
  14. package/dist/src/browser/zipBundle.js +152 -0
  15. package/dist/src/cli/browserConfig.js +13 -11
  16. package/dist/src/cli/browserDefaults.js +2 -1
  17. package/dist/src/cli/docsCheck.js +186 -0
  18. package/dist/src/cli/engine.js +11 -4
  19. package/dist/src/cli/options.js +12 -6
  20. package/dist/src/cli/perfTrace.js +242 -0
  21. package/dist/src/cli/promptRequirement.js +2 -0
  22. package/dist/src/cli/providerDoctor.js +85 -0
  23. package/dist/src/cli/runOptions.js +46 -16
  24. package/dist/src/cli/sessionDisplay.js +39 -4
  25. package/dist/src/cli/sessionLifecycle.js +38 -0
  26. package/dist/src/cli/sessionRunner.js +228 -3
  27. package/dist/src/cli/sessionTable.js +2 -1
  28. package/dist/src/duration.js +47 -0
  29. package/dist/src/mcp/tools/consult.js +19 -3
  30. package/dist/src/mcp/types.js +1 -0
  31. package/dist/src/mcp/utils.js +4 -1
  32. package/dist/src/oracle/baseUrl.js +17 -0
  33. package/dist/src/oracle/client.js +1 -22
  34. package/dist/src/oracle/config.js +17 -4
  35. package/dist/src/oracle/gemini.js +2 -22
  36. package/dist/src/oracle/geminiModels.js +21 -0
  37. package/dist/src/oracle/modelResolver.js +7 -1
  38. package/dist/src/oracle/multiModelRunner.js +20 -2
  39. package/dist/src/oracle/providerFailures.js +204 -0
  40. package/dist/src/oracle/providerRoutePlan.js +281 -0
  41. package/dist/src/oracle/providerRouting.js +92 -0
  42. package/dist/src/oracle/run.js +157 -54
  43. package/dist/src/oracle.js +1 -0
  44. package/dist/src/remote/client.js +8 -0
  45. package/dist/src/remote/server.js +26 -0
  46. package/dist/src/sessionManager.js +5 -1
  47. package/package.json +3 -1
@@ -5,41 +5,59 @@ import { resolveGeminiModelId } from "../oracle/gemini.js";
5
5
  import { PromptValidationError } from "../oracle/errors.js";
6
6
  import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
7
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
8
+ import { isAzureOpenAICandidateModel } from "../oracle/providerRouting.js";
8
9
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, models, engine, userConfig, env = process.env, }) {
9
- const resolvedEngine = resolveEngineWithConfig({ engine, configEngine: userConfig?.engine, env });
10
+ const resolvedEngine = resolveEngineWithConfig({
11
+ engine,
12
+ configEngine: userConfig?.engine,
13
+ env,
14
+ });
10
15
  const browserRequested = engine === "browser";
11
16
  const browserConfigured = userConfig?.engine === "browser";
17
+ const envBrowserConfigured = (env.ORACLE_ENGINE ?? "").trim().toLowerCase() === "browser";
12
18
  const requestedModelList = Array.isArray(models) ? models : [];
13
19
  const normalizedRequestedModels = requestedModelList
14
20
  .map((entry) => normalizeModelOption(entry))
15
21
  .filter(Boolean);
16
22
  const cliModelArg = normalizeModelOption(model ?? userConfig?.model) || DEFAULT_MODEL;
17
- const inferredModel = resolvedEngine === "browser" && normalizedRequestedModels.length === 0
18
- ? inferModelFromLabel(cliModelArg)
19
- : resolveApiModel(cliModelArg);
20
- // Browser engine maps Pro/legacy aliases to the latest ChatGPT picker targets.
21
- const resolvedModel = resolvedEngine === "browser" ? normalizeChatGptModelForBrowser(inferredModel) : inferredModel;
22
- const isCodex = resolvedModel.startsWith("gpt-5.1-codex");
23
- const isClaude = resolvedModel.startsWith("claude");
24
- const isGrok = resolvedModel.startsWith("grok");
25
- const isGeminiApiOnly = resolvedModel === "gemini-3.1-pro";
23
+ const apiModel = resolveApiModel(cliModelArg);
24
+ const browserModel = normalizeChatGptModelForBrowser(inferModelFromLabel(cliModelArg));
25
+ const isCodex = apiModel.startsWith("gpt-5.1-codex");
26
+ const isClaude = apiModel.startsWith("claude");
27
+ const isGrok = apiModel.startsWith("grok");
28
+ const isGeminiApiOnly = apiModel === "gemini-3.1-pro";
26
29
  const engineWasBrowser = resolvedEngine === "browser";
27
30
  const allModels = normalizedRequestedModels.length > 0
28
31
  ? Array.from(new Set(normalizedRequestedModels.map((entry) => resolveApiModel(entry))))
29
- : [resolvedModel];
32
+ : [apiModel];
33
+ const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel];
30
34
  const includesGeminiApiOnly = allModels.some((m) => m === "gemini-3.1-pro");
31
35
  if ((browserRequested || browserConfigured) && includesGeminiApiOnly) {
32
36
  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 });
33
37
  }
34
38
  const isBrowserCompatible = (m) => m.startsWith("gpt-") || m.startsWith("gemini");
35
- const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) && allModels.some((m) => !isBrowserCompatible(m));
39
+ const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) &&
40
+ browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
36
41
  if (hasNonBrowserCompatibleTarget) {
37
42
  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 });
38
43
  }
39
- const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || isGeminiApiOnly);
40
- const fixedEngine = isCodex || isClaude || isGrok || isGeminiApiOnly || normalizedRequestedModels.length > 0
44
+ const azure = resolveAzureOptions(userConfig, env);
45
+ const azureAutoApi = Boolean(azure?.endpoint) &&
46
+ !browserRequested &&
47
+ !browserConfigured &&
48
+ !envBrowserConfigured &&
49
+ allModels.some(isAzureOpenAICandidateModel);
50
+ const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || isGeminiApiOnly || azureAutoApi);
51
+ const fixedEngine = isCodex ||
52
+ isClaude ||
53
+ isGrok ||
54
+ isGeminiApiOnly ||
55
+ azureAutoApi ||
56
+ normalizedRequestedModels.length > 0
41
57
  ? "api"
42
58
  : resolvedEngine;
59
+ // Browser runs use ChatGPT picker labels/aliases; API runs must keep API model ids intact.
60
+ const resolvedModel = fixedEngine === "browser" ? browserModel : apiModel;
43
61
  const promptWithSuffix = userConfig?.promptSuffix && userConfig.promptSuffix.trim().length > 0
44
62
  ? `${prompt.trim()}\n${userConfig.promptSuffix}`
45
63
  : prompt;
@@ -66,11 +84,12 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
66
84
  filesReport: userConfig?.filesReport,
67
85
  background: userConfig?.background,
68
86
  baseUrl,
87
+ azure,
69
88
  effectiveModelId,
70
89
  };
71
90
  return { runOptions, resolvedEngine: fixedEngine, engineCoercedToApi };
72
91
  }
73
- function resolveEngineWithConfig({ engine, configEngine, env, }) {
92
+ function resolveEngineWithConfig({ engine, configEngine, apiProviderRequested, env, }) {
74
93
  if (engine)
75
94
  return engine;
76
95
  const envOverride = (env.ORACLE_ENGINE ?? "").trim().toLowerCase();
@@ -79,7 +98,18 @@ function resolveEngineWithConfig({ engine, configEngine, env, }) {
79
98
  }
80
99
  if (configEngine)
81
100
  return configEngine;
82
- return resolveEngine({ engine: undefined, env });
101
+ return resolveEngine({ engine: undefined, apiProviderRequested, env });
102
+ }
103
+ function resolveAzureOptions(userConfig, env) {
104
+ const endpoint = env.AZURE_OPENAI_ENDPOINT ?? userConfig?.azure?.endpoint;
105
+ if (!endpoint?.trim()) {
106
+ return undefined;
107
+ }
108
+ return {
109
+ endpoint,
110
+ deployment: env.AZURE_OPENAI_DEPLOYMENT ?? userConfig?.azure?.deployment,
111
+ apiVersion: env.AZURE_OPENAI_API_VERSION ?? userConfig?.azure?.apiVersion,
112
+ };
83
113
  }
84
114
  function resolveEffectiveModelId(model) {
85
115
  if (typeof model === "string" && model.startsWith("gemini")) {
@@ -10,6 +10,7 @@ import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportA
10
10
  import { estimateTokenCount } from "../browser/utils.js";
11
11
  import { formatSessionTableHeader, formatSessionTableRow, resolveSessionCost, } from "./sessionTable.js";
12
12
  import { abbreviateResponseId, buildResponseOwnerIndex, resolveSessionLineage, } from "./sessionLineage.js";
13
+ import { formatSessionExecutionLabel } from "./sessionLifecycle.js";
13
14
  const isTty = () => Boolean(process.stdout.isTTY);
14
15
  const dim = (text) => (isTty() ? kleur.dim(text) : text);
15
16
  export const MAX_RENDER_BYTES = 200_000;
@@ -219,6 +220,8 @@ export async function attachSession(sessionId, options) {
219
220
  browser: {
220
221
  config: metadata.browser?.config,
221
222
  runtime,
223
+ modelSelection: metadata.browser?.modelSelection,
224
+ warnings: metadata.browser?.warnings,
222
225
  },
223
226
  artifacts,
224
227
  response: { status: "completed" },
@@ -266,6 +269,11 @@ export async function attachSession(sessionId, options) {
266
269
  }
267
270
  console.log(`Created: ${metadata.createdAt}`);
268
271
  console.log(`Status: ${metadata.status}`);
272
+ if (metadata.lifecycle) {
273
+ const attached = metadata.lifecycle.attached ? "attached" : "detached";
274
+ console.log(`Execution: ${formatSessionExecutionLabel(metadata)} (${attached})`);
275
+ console.log(`Reattach: ${metadata.lifecycle.reattachCommand}`);
276
+ }
269
277
  if (metadata.models && metadata.models.length > 0) {
270
278
  console.log("Models:");
271
279
  for (const run of metadata.models) {
@@ -278,6 +286,13 @@ export async function attachSession(sessionId, options) {
278
286
  else if (metadata.model) {
279
287
  console.log(`Model: ${metadata.model}`);
280
288
  }
289
+ const browserEvidence = formatBrowserEvidence(metadata);
290
+ if (browserEvidence) {
291
+ console.log("Browser evidence:");
292
+ for (const line of browserEvidence) {
293
+ console.log(dim(`- ${line}`));
294
+ }
295
+ }
281
296
  if (metadata.artifacts && metadata.artifacts.length > 0) {
282
297
  console.log("Artifacts:");
283
298
  for (const artifact of metadata.artifacts) {
@@ -299,7 +314,7 @@ export async function attachSession(sessionId, options) {
299
314
  console.log(dim(`User error: ${userErrorSummary}`));
300
315
  }
301
316
  }
302
- const shouldTrimIntro = initialStatus === "completed" || initialStatus === "error";
317
+ const shouldTrimIntro = initialStatus === "completed" || initialStatus === "partial" || initialStatus === "error";
303
318
  if (options?.renderPrompt !== false) {
304
319
  const prompt = await readStoredPrompt(sessionId);
305
320
  if (prompt) {
@@ -422,7 +437,7 @@ export async function attachSession(sessionId, options) {
422
437
  if (!latest) {
423
438
  break;
424
439
  }
425
- if (latest.status === "completed" || latest.status === "error") {
440
+ if (latest.status === "completed" || latest.status === "partial" || latest.status === "error") {
426
441
  await printNew();
427
442
  flushRemainder();
428
443
  if (!options?.suppressMetadata) {
@@ -430,10 +445,11 @@ export async function attachSession(sessionId, options) {
430
445
  console.log("\nResult:");
431
446
  console.log(`Session failed: ${latest.errorMessage}`);
432
447
  }
433
- if (latest.status === "completed" && latest.usage) {
448
+ if ((latest.status === "completed" || latest.status === "partial") && latest.usage) {
434
449
  const summary = formatCompletionSummary(latest, { includeSlug: true });
435
450
  if (summary) {
436
- console.log(`\n${chalk.green.bold(summary)}`);
451
+ const color = latest.status === "partial" ? chalk.yellow.bold : chalk.green.bold;
452
+ console.log(`\n${color(summary)}`);
437
453
  }
438
454
  else {
439
455
  const usage = latest.usage;
@@ -495,6 +511,25 @@ export function formatUserErrorMetadata(metadata) {
495
511
  }
496
512
  return parts.length > 0 ? parts.join(" | ") : null;
497
513
  }
514
+ export function formatBrowserEvidence(metadata) {
515
+ const browser = metadata.browser;
516
+ if (!browser?.modelSelection && (!browser?.warnings || browser.warnings.length === 0)) {
517
+ return null;
518
+ }
519
+ const lines = [];
520
+ const evidence = browser.modelSelection;
521
+ if (evidence) {
522
+ const requested = evidence.requestedModel ?? "(none)";
523
+ const resolved = evidence.resolvedLabel ?? "(unavailable)";
524
+ const strategy = evidence.strategy ?? "(default)";
525
+ const verified = evidence.verified ? "yes" : "no";
526
+ lines.push(`model requested=${requested}; resolved=${resolved}; status=${evidence.status}; strategy=${strategy}; verified=${verified}`);
527
+ }
528
+ for (const warning of browser.warnings ?? []) {
529
+ lines.push(`warning ${warning.code}: ${warning.message}`);
530
+ }
531
+ return lines.length > 0 ? lines : null;
532
+ }
498
533
  export function buildReattachLine(metadata) {
499
534
  if (!metadata.id) {
500
535
  return null;
@@ -0,0 +1,38 @@
1
+ export function buildSessionLifecycle({ engine, detached, reattachCommand, }) {
2
+ return {
3
+ engine,
4
+ execution: detached ? "background" : "foreground",
5
+ attached: !detached,
6
+ detached,
7
+ reattachCommand,
8
+ };
9
+ }
10
+ export function formatSessionLifecycleBlock(meta) {
11
+ const lifecycle = meta.lifecycle;
12
+ if (!lifecycle) {
13
+ return [];
14
+ }
15
+ const modelCount = meta.models?.length ?? (meta.model ? 1 : 0);
16
+ const detachValue = lifecycle.detached
17
+ ? lifecycle.execution === "background"
18
+ ? "yes, polling"
19
+ : "yes"
20
+ : "no";
21
+ const lines = [
22
+ `Session: ${meta.id}`,
23
+ `Mode: ${lifecycle.engine} ${lifecycle.execution}`,
24
+ `Models: ${modelCount > 1 ? `${modelCount} parallel` : String(modelCount || 1)}`,
25
+ `Detach: ${detachValue}`,
26
+ `Reattach: ${lifecycle.reattachCommand}`,
27
+ ];
28
+ return lines;
29
+ }
30
+ export function formatSessionExecutionLabel(meta) {
31
+ const lifecycle = meta.lifecycle;
32
+ if (!lifecycle) {
33
+ return meta.mode ?? meta.options?.mode ?? "api";
34
+ }
35
+ const engine = lifecycle.engine === "browser" ? "br" : lifecycle.engine;
36
+ const execution = lifecycle.execution === "background" ? "bg" : "fg";
37
+ return `${engine}/${execution}`;
38
+ }
@@ -1,7 +1,7 @@
1
1
  import kleur from "kleur";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, } from "../oracle.js";
4
+ import { runOracle, OracleResponseError, OracleTransportError, extractResponseMetadata, asOracleUserError, extractTextOutput, classifyProviderFailure, } from "../oracle.js";
5
5
  import { ensureSessionArtifacts, runBrowserSessionExecution, } from "../browser/sessionRunner.js";
6
6
  import { renderMarkdownAnsi } from "./markdownRenderer.js";
7
7
  import { formatResponseMetadata, formatTransportMetadata } from "./sessionDisplay.js";
@@ -82,6 +82,8 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
82
82
  config: browserConfig,
83
83
  runtime: result.runtime,
84
84
  archive: result.archive,
85
+ modelSelection: result.modelSelection,
86
+ warnings: result.warnings,
85
87
  },
86
88
  artifacts: mergeArtifacts(sessionMeta.artifacts, result.artifacts),
87
89
  response: undefined,
@@ -245,8 +247,13 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
245
247
  });
246
248
  log(statusColor(line1));
247
249
  const hasFailure = summary.rejected.length > 0;
250
+ const allowPartial = runOptions.partialMode === "ok" && summary.fulfilled.length > 0;
251
+ if (hasFailure) {
252
+ const resultLabel = summary.fulfilled.length > 0 ? "partial success" : "failed";
253
+ log(statusColor(`Multi-model result: ${resultLabel}, ${summary.fulfilled.length}/${multiModels.length} succeeded`));
254
+ }
248
255
  await sessionStore.updateSession(sessionMeta.id, {
249
- status: hasFailure ? "error" : "completed",
256
+ status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
250
257
  completedAt: new Date().toISOString(),
251
258
  usage: aggregateUsage,
252
259
  elapsedMs: summary.elapsedMs,
@@ -273,15 +280,52 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
273
280
  savedOutputs.push({ model: entry.model, path: savedPath });
274
281
  }
275
282
  }
283
+ const sessionWithRuns = (await readSessionForManifest(sessionMeta.id)) ?? {
284
+ ...sessionMeta,
285
+ models: sessionMeta.models,
286
+ };
287
+ const runLogs = await collectMultiModelRunLogs(sessionMeta.id, sessionWithRuns.models, summary);
288
+ const manifestPath = await writeMultiModelOutputManifest({
289
+ baseOutputPath: runOptions.writeOutputPath,
290
+ sessionId: sessionMeta.id,
291
+ status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
292
+ summary,
293
+ savedOutputs,
294
+ modelRuns: sessionWithRuns.models,
295
+ runLogs,
296
+ runOptions,
297
+ log,
298
+ });
276
299
  if (savedOutputs.length > 0) {
277
300
  log(dim("Saved outputs:"));
278
301
  for (const item of savedOutputs) {
279
302
  log(dim(`- ${item.model} -> ${item.path}`));
280
303
  }
281
304
  }
305
+ if (manifestPath) {
306
+ log(dim(`Output manifest: ${manifestPath}`));
307
+ }
308
+ if (runLogs.length > 0) {
309
+ log(dim(""));
310
+ log(dim("Run logs:"));
311
+ for (const item of runLogs) {
312
+ log(dim(`- ${item.model} -> ${item.path}`));
313
+ }
314
+ }
282
315
  }
283
316
  if (hasFailure) {
284
- throw summary.rejected[0].reason;
317
+ log(dim("Failures:"));
318
+ for (const item of summary.rejected) {
319
+ const providerContext = providerFailureContextForModel(item.model, runOptions);
320
+ log(dim(`- ${item.model}: ${formatMultiModelFailure(item.reason, providerContext)}`));
321
+ for (const line of formatMultiModelFailureDetails(item.reason, providerContext)) {
322
+ log(dim(line));
323
+ }
324
+ }
325
+ }
326
+ if (hasFailure && !allowPartial) {
327
+ const firstFailure = summary.rejected[0];
328
+ throw sanitizeMultiModelFailureForThrow(firstFailure.reason, providerFailureContextForModel(firstFailure.model, runOptions));
285
329
  }
286
330
  return;
287
331
  }
@@ -483,6 +527,187 @@ function mergeArtifacts(existing, additions) {
483
527
  function formatError(error) {
484
528
  return error instanceof Error ? error.message : String(error);
485
529
  }
530
+ function providerFailureContextForModel(model, runOptions) {
531
+ return {
532
+ model,
533
+ providerMode: runOptions.provider,
534
+ azure: runOptions.azure,
535
+ baseUrl: runOptions.baseUrl,
536
+ apiKey: runOptions.apiKey,
537
+ };
538
+ }
539
+ function formatMultiModelFailure(error, context) {
540
+ const userError = asOracleUserError(error);
541
+ if (userError) {
542
+ return `${userError.category}, ${userError.message}`;
543
+ }
544
+ const providerFailure = classifyProviderFailure(error, context);
545
+ if (providerFailure) {
546
+ return providerFailure.label;
547
+ }
548
+ if (error instanceof OracleTransportError) {
549
+ return `${error.reason}, ${error.message}`;
550
+ }
551
+ if (error instanceof OracleResponseError) {
552
+ return error.message;
553
+ }
554
+ return formatError(error);
555
+ }
556
+ function formatMultiModelFailureDetails(error, context) {
557
+ const providerFailure = classifyProviderFailure(error, context);
558
+ if (!providerFailure) {
559
+ return [];
560
+ }
561
+ const lines = [];
562
+ if (providerFailure.keyEnv) {
563
+ lines.push(` key: ${providerFailure.keyEnv}`);
564
+ }
565
+ lines.push(` provider said: ${providerFailure.providerMessage}`);
566
+ lines.push(` fix: ${providerFailure.fix}`);
567
+ return lines;
568
+ }
569
+ function sanitizeMultiModelFailureForThrow(error, context) {
570
+ const providerFailure = classifyProviderFailure(error, context);
571
+ if (!providerFailure) {
572
+ return error;
573
+ }
574
+ const modelPrefix = typeof context === "object" && context?.model ? `${context.model}: ` : "";
575
+ const message = `${modelPrefix}${providerFailure.label}: ${providerFailure.providerMessage}`;
576
+ if (!(error instanceof Error)) {
577
+ return new Error(message);
578
+ }
579
+ error.message = message;
580
+ 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");
584
+ }
585
+ return error;
586
+ }
587
+ export function deriveOutputManifestPath(basePath) {
588
+ const ext = path.extname(basePath);
589
+ const stem = path.basename(basePath, ext);
590
+ const dir = path.dirname(basePath);
591
+ return path.join(dir, `${stem}.oracle.json`);
592
+ }
593
+ async function collectMultiModelRunLogs(sessionId, modelRuns, summary) {
594
+ const sessionDir = await resolveSessionDir(sessionId);
595
+ const logsByModel = new Map();
596
+ for (const run of modelRuns ?? []) {
597
+ if (run.log?.path) {
598
+ logsByModel.set(run.model, resolveSessionPath(sessionDir, run.log.path));
599
+ }
600
+ }
601
+ for (const entry of summary.fulfilled) {
602
+ if (!logsByModel.has(entry.model)) {
603
+ logsByModel.set(entry.model, entry.logPath);
604
+ }
605
+ }
606
+ return [...logsByModel.entries()].map(([model, logPath]) => ({ model, path: logPath }));
607
+ }
608
+ async function writeMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, log, }) {
609
+ const manifestPath = deriveOutputManifestPath(baseOutputPath);
610
+ const normalizedTarget = path.resolve(manifestPath);
611
+ const normalizedSessionsDir = path.resolve(sessionStore.sessionsDir());
612
+ if (normalizedTarget === normalizedSessionsDir ||
613
+ normalizedTarget.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
614
+ log(dim(`output manifest skipped: refusing to write inside session storage (${normalizedSessionsDir}).`));
615
+ return undefined;
616
+ }
617
+ const manifest = buildMultiModelOutputManifest({
618
+ baseOutputPath,
619
+ sessionId,
620
+ status,
621
+ summary,
622
+ savedOutputs,
623
+ modelRuns,
624
+ runLogs,
625
+ runOptions,
626
+ });
627
+ try {
628
+ await fs.mkdir(path.dirname(normalizedTarget), { recursive: true });
629
+ await fs.writeFile(normalizedTarget, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
630
+ return normalizedTarget;
631
+ }
632
+ catch (error) {
633
+ const reason = error instanceof Error ? error.message : String(error);
634
+ log(dim(`output manifest failed (${reason}); session completed anyway.`));
635
+ return undefined;
636
+ }
637
+ }
638
+ function buildMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, }) {
639
+ const outputByModel = new Map(savedOutputs.map((entry) => [entry.model, entry.path]));
640
+ const logsByModel = new Map(runLogs.map((entry) => [entry.model, entry.path]));
641
+ const runsByModel = new Map((modelRuns ?? []).map((run) => [run.model, run]));
642
+ const fulfilledByModel = new Map(summary.fulfilled.map((entry) => [entry.model, entry]));
643
+ const rejectedByModel = new Map(summary.rejected.map((entry) => [entry.model, entry.reason]));
644
+ const orderedModels = [
645
+ ...summary.fulfilled.map((entry) => entry.model),
646
+ ...summary.rejected.map((entry) => entry.model),
647
+ ];
648
+ return {
649
+ version: 1,
650
+ sessionId,
651
+ status,
652
+ outputBasePath: path.resolve(baseOutputPath),
653
+ createdAt: new Date().toISOString(),
654
+ models: orderedModels.map((model) => {
655
+ const run = runsByModel.get(model);
656
+ const fulfilled = fulfilledByModel.get(model);
657
+ const reason = rejectedByModel.get(model);
658
+ const userError = reason ? asOracleUserError(reason) : undefined;
659
+ const providerFailure = reason
660
+ ? classifyProviderFailure(reason, providerFailureContextForModel(model, runOptions))
661
+ : undefined;
662
+ return {
663
+ model,
664
+ status: fulfilled ? "completed" : reason ? "error" : (run?.status ?? "error"),
665
+ outputPath: outputByModel.get(model),
666
+ logPath: logsByModel.get(model),
667
+ errorCategory: run?.error?.category ?? userError?.category ?? providerFailure?.category,
668
+ errorMessage: run?.error?.message ??
669
+ userError?.message ??
670
+ providerFailure?.label ??
671
+ (reason ? formatError(reason) : undefined),
672
+ elapsedMs: calculateModelElapsedMs(run),
673
+ usage: run?.usage ?? fulfilled?.usage,
674
+ };
675
+ }),
676
+ };
677
+ }
678
+ function calculateModelElapsedMs(run) {
679
+ if (!run?.startedAt || !run.completedAt) {
680
+ return undefined;
681
+ }
682
+ const startedMs = Date.parse(run.startedAt);
683
+ const completedMs = Date.parse(run.completedAt);
684
+ if (!Number.isFinite(startedMs) || !Number.isFinite(completedMs) || completedMs < startedMs) {
685
+ return undefined;
686
+ }
687
+ return completedMs - startedMs;
688
+ }
689
+ async function readSessionForManifest(sessionId) {
690
+ try {
691
+ return (await sessionStore.readSession(sessionId)) ?? null;
692
+ }
693
+ catch {
694
+ return null;
695
+ }
696
+ }
697
+ async function resolveSessionDir(sessionId) {
698
+ try {
699
+ return (await sessionStore.getPaths(sessionId)).dir;
700
+ }
701
+ catch {
702
+ return null;
703
+ }
704
+ }
705
+ function resolveSessionPath(sessionDir, targetPath) {
706
+ if (path.isAbsolute(targetPath) || !sessionDir) {
707
+ return targetPath;
708
+ }
709
+ return path.join(sessionDir, targetPath);
710
+ }
486
711
  async function writeAssistantOutput(targetPath, content, log) {
487
712
  if (!targetPath)
488
713
  return;
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import kleur from "kleur";
3
3
  import { MODEL_CONFIGS } from "../oracle.js";
4
4
  import { estimateUsdCost } from "tokentally";
5
+ import { formatSessionExecutionLabel } from "./sessionLifecycle.js";
5
6
  const isRich = (rich) => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0);
6
7
  const dim = (text, rich) => (rich ? kleur.dim(text) : text);
7
8
  export const STATUS_PAD = 9;
@@ -19,7 +20,7 @@ export function formatSessionTableRow(meta, options) {
19
20
  const status = colorStatus(meta.status ?? "unknown", rich);
20
21
  const modelLabel = (meta.model ?? "n/a").padEnd(MODEL_PAD);
21
22
  const model = rich ? chalk.white(modelLabel) : modelLabel;
22
- const modeLabel = (meta.mode ?? meta.options?.mode ?? "api").padEnd(MODE_PAD);
23
+ const modeLabel = formatSessionExecutionLabel(meta).padEnd(MODE_PAD);
23
24
  const mode = rich ? chalk.gray(modeLabel) : modeLabel;
24
25
  const timestampLabel = formatTimestampAligned(meta.createdAt).padEnd(TIMESTAMP_PAD);
25
26
  const timestamp = rich ? chalk.gray(timestampLabel) : timestampLabel;
@@ -0,0 +1,47 @@
1
+ export function parseDuration(input, fallback) {
2
+ if (!input) {
3
+ return fallback;
4
+ }
5
+ const trimmed = input.trim();
6
+ if (!trimmed) {
7
+ return fallback;
8
+ }
9
+ const lowercase = trimmed.toLowerCase();
10
+ if (/^[0-9]+$/.test(lowercase)) {
11
+ return Number(lowercase);
12
+ }
13
+ const normalized = lowercase.replace(/\s+/g, "");
14
+ const singleMatch = /^([0-9]+)(ms|s|m|h)$/i.exec(normalized);
15
+ if (singleMatch && singleMatch[0].length === normalized.length) {
16
+ const value = Number(singleMatch[1]);
17
+ return convertUnit(value, singleMatch[2]);
18
+ }
19
+ const multiDuration = /([0-9]+)(ms|h|m|s)/g;
20
+ let total = 0;
21
+ let lastIndex = 0;
22
+ let match = multiDuration.exec(normalized);
23
+ while (match !== null) {
24
+ total += convertUnit(Number(match[1]), match[2]);
25
+ lastIndex = multiDuration.lastIndex;
26
+ match = multiDuration.exec(normalized);
27
+ }
28
+ if (total > 0 && lastIndex === normalized.length) {
29
+ return total;
30
+ }
31
+ return fallback;
32
+ }
33
+ function convertUnit(value, unitRaw) {
34
+ const unit = unitRaw?.toLowerCase();
35
+ switch (unit) {
36
+ case "ms":
37
+ return value;
38
+ case "s":
39
+ return value * 1000;
40
+ case "m":
41
+ return value * 60_000;
42
+ case "h":
43
+ return value * 3_600_000;
44
+ default:
45
+ return value;
46
+ }
47
+ }
@@ -60,6 +60,10 @@ const consultInputShape = {
60
60
  .boolean()
61
61
  .optional()
62
62
  .describe("Browser-only: bundle many files into a single upload (helps with upload limits)."),
63
+ browserBundleFormat: z
64
+ .enum(["text", "zip"])
65
+ .optional()
66
+ .describe('Browser-only: bundle upload format when browserBundleFiles is true or auto-bundling is needed. Defaults to "text"; "zip" preserves individual file names in one uploaded archive.'),
63
67
  browserThinkingTime: z
64
68
  .enum(["light", "standard", "extended", "heavy"])
65
69
  .optional()
@@ -140,6 +144,7 @@ const consultDryRunResolvedShape = z.object({
140
144
  researchMode: z.string().nullable().optional(),
141
145
  attachments: z.string().optional(),
142
146
  bundleFiles: z.boolean().optional(),
147
+ bundleFormat: z.enum(["text", "zip"]).optional(),
143
148
  keepBrowser: z.boolean().optional(),
144
149
  manualLogin: z.boolean().optional(),
145
150
  profileDir: z.string().nullable().optional(),
@@ -196,7 +201,9 @@ export function buildConsultBrowserConfig({ userConfig, env, runModel, inputMode
196
201
  ? mapModelToBrowserLabel(runModel)
197
202
  : resolveBrowserModelLabel(preferredLabel, runModel);
198
203
  const configuredUrl = configuredBrowser.chatgptUrl ?? configuredBrowser.url ?? CHATGPT_URL;
199
- const manualLogin = hasProfileDir ? true : (configuredBrowser.manualLogin ?? false);
204
+ const manualLogin = hasProfileDir
205
+ ? true
206
+ : (configuredBrowser.manualLogin ?? process.platform === "win32");
200
207
  return {
201
208
  ...configuredBrowser,
202
209
  url: configuredUrl,
@@ -224,6 +231,12 @@ export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browser
224
231
  }
225
232
  if (resolvedEngine === "browser") {
226
233
  guidance.push("Browser engine uses the signed-in ChatGPT profile; run dryRun:true before live use.");
234
+ if (browserConfig?.manualLogin) {
235
+ const profile = browserConfig.manualLoginProfileDir ?? "~/.oracle/browser-profile";
236
+ guidance.push(`Manual-login browser mode uses Oracle's private Chrome profile at ${profile}, separate from your normal Chrome profile.`);
237
+ guidance.push(`First-time setup: run oracle --engine browser --browser-manual-login --browser-keep-browser --browser-manual-login-profile-dir ${JSON.stringify(profile)} -p "HI", sign into ChatGPT in that window, then retry the consult.`);
238
+ guidance.push("If this profile is not signed in, non-setup MCP/browser runs fail fast instead of waiting for the full browser timeout.");
239
+ }
227
240
  }
228
241
  const desiredModel = browserConfig?.desiredModel ?? null;
229
242
  const thinkingTime = browserConfig?.thinkingTime ?? null;
@@ -251,6 +264,7 @@ export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browser
251
264
  researchMode: browserConfig?.researchMode ?? null,
252
265
  attachments: runOptions.browserAttachments,
253
266
  bundleFiles: runOptions.browserBundleFiles,
267
+ bundleFormat: runOptions.browserBundleFormat,
254
268
  keepBrowser: browserConfig?.keepBrowser,
255
269
  manualLogin: browserConfig?.manualLogin,
256
270
  profileDir: browserConfig?.manualLoginProfileDir ?? null,
@@ -277,6 +291,7 @@ export function formatConsultDryRunResolved(details) {
277
291
  lines.push(` browser research mode: ${details.browser.researchMode ?? "off"}`);
278
292
  lines.push(` browser attachments: ${details.browser.attachments ?? "auto"}`);
279
293
  lines.push(` browser bundle files: ${details.browser.bundleFiles ? "yes" : "no"}`);
294
+ lines.push(` browser bundle format: ${details.browser.bundleFormat ?? "text"}`);
280
295
  lines.push(` browser keep browser: ${details.browser.keepBrowser ? "yes" : "no"}`);
281
296
  lines.push(` browser manual login: ${details.browser.manualLogin ? "yes" : "no"}`);
282
297
  if (details.browser.profileDir) {
@@ -295,7 +310,7 @@ export function formatConsultDryRunResolved(details) {
295
310
  export function registerConsultTool(server) {
296
311
  server.registerTool("consult", {
297
312
  title: "Run an oracle session",
298
- description: 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. For browser-based image/file uploads, set `browserAttachments:"always"`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
313
+ description: 'Run an Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. If `engine` is omitted, Oracle follows CLI defaults: config/ORACLE_ENGINE first, then API when OPENAI_API_KEY is set, otherwise browser. Browser GPT-5.5 Pro consults can take many minutes; use `dryRun:true` first when configuring an agent and inspect `sessions`/`oracle status` before retrying. Browser manual-login uses a private Oracle Chrome profile separate from the user\'s normal Chrome; dry-run output includes first-time setup guidance when that path is active. For browser-based image/file uploads, set `browserAttachments:"always"`. Browser consults can include `browserFollowUps` for a multi-turn ChatGPT review in one conversation. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
299
314
  // Cast to any to satisfy SDK typings across differing Zod versions.
300
315
  inputSchema: consultInputShape,
301
316
  outputSchema: consultOutputShape,
@@ -311,7 +326,7 @@ export function registerConsultTool(server) {
311
326
  content: textContent(error instanceof Error ? error.message : String(error)),
312
327
  };
313
328
  }
314
- const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserThinkingTime, browserModelStrategy, browserResearchMode, browserArchive, browserFollowUps, browserKeepBrowser, dryRun, slug, } = parsedInput;
329
+ const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserBundleFormat, browserThinkingTime, browserModelStrategy, browserResearchMode, browserArchive, browserFollowUps, browserKeepBrowser, dryRun, slug, } = parsedInput;
315
330
  const { config: userConfig } = await loadUserConfig();
316
331
  const { runOptions, resolvedEngine } = mapConsultToRunOptions({
317
332
  prompt,
@@ -322,6 +337,7 @@ export function registerConsultTool(server) {
322
337
  search,
323
338
  browserAttachments,
324
339
  browserBundleFiles,
340
+ browserBundleFormat,
325
341
  browserFollowUps,
326
342
  userConfig,
327
343
  env: process.env,