@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.
- package/README.md +55 -10
- package/dist/bin/oracle-cli.js +440 -98
- package/dist/src/browser/actions/modelSelection.js +74 -20
- package/dist/src/browser/actions/navigation.js +5 -3
- package/dist/src/browser/actions/promptComposer.js +76 -18
- package/dist/src/browser/actions/thinkingTime.js +133 -19
- package/dist/src/browser/constants.js +1 -1
- package/dist/src/browser/index.js +78 -9
- package/dist/src/browser/manualLoginProfile.js +54 -0
- package/dist/src/browser/projectSourcesRunner.js +16 -5
- package/dist/src/browser/prompt.js +56 -37
- package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
- package/dist/src/browser/reattachability.js +22 -0
- package/dist/src/browser/sessionRunner.js +73 -1
- package/dist/src/browser/utils.js +1 -47
- package/dist/src/browser/zipBundle.js +152 -0
- package/dist/src/cli/browserConfig.js +13 -11
- package/dist/src/cli/browserDefaults.js +2 -1
- package/dist/src/cli/docsCheck.js +186 -0
- package/dist/src/cli/engine.js +11 -4
- package/dist/src/cli/options.js +12 -6
- package/dist/src/cli/perfTrace.js +242 -0
- package/dist/src/cli/promptRequirement.js +2 -0
- package/dist/src/cli/providerDoctor.js +85 -0
- package/dist/src/cli/runOptions.js +46 -16
- package/dist/src/cli/sessionDisplay.js +47 -4
- package/dist/src/cli/sessionLifecycle.js +38 -0
- package/dist/src/cli/sessionRunner.js +272 -3
- package/dist/src/cli/sessionTable.js +2 -1
- package/dist/src/duration.js +47 -0
- package/dist/src/mcp/tools/consult.js +19 -3
- package/dist/src/mcp/types.js +1 -0
- package/dist/src/mcp/utils.js +4 -1
- package/dist/src/oracle/baseUrl.js +17 -0
- package/dist/src/oracle/client.js +1 -22
- package/dist/src/oracle/config.js +17 -4
- package/dist/src/oracle/gemini.js +2 -22
- package/dist/src/oracle/geminiModels.js +21 -0
- package/dist/src/oracle/modelResolver.js +7 -1
- package/dist/src/oracle/multiModelRunner.js +20 -2
- package/dist/src/oracle/providerFailures.js +204 -0
- package/dist/src/oracle/providerRoutePlan.js +308 -0
- package/dist/src/oracle/providerRouting.js +92 -0
- package/dist/src/oracle/run.js +104 -107
- package/dist/src/oracle.js +1 -0
- package/dist/src/remote/client.js +8 -0
- package/dist/src/remote/server.js +26 -0
- package/dist/src/sessionManager.js +43 -23
- package/package.json +15 -12
|
@@ -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";
|
|
@@ -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;
|
|
@@ -82,6 +83,8 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
|
|
|
82
83
|
config: browserConfig,
|
|
83
84
|
runtime: result.runtime,
|
|
84
85
|
archive: result.archive,
|
|
86
|
+
modelSelection: result.modelSelection,
|
|
87
|
+
warnings: result.warnings,
|
|
85
88
|
},
|
|
86
89
|
artifacts: mergeArtifacts(sessionMeta.artifacts, result.artifacts),
|
|
87
90
|
response: undefined,
|
|
@@ -245,8 +248,13 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
|
|
|
245
248
|
});
|
|
246
249
|
log(statusColor(line1));
|
|
247
250
|
const hasFailure = summary.rejected.length > 0;
|
|
251
|
+
const allowPartial = runOptions.partialMode === "ok" && summary.fulfilled.length > 0;
|
|
252
|
+
if (hasFailure) {
|
|
253
|
+
const resultLabel = summary.fulfilled.length > 0 ? "partial success" : "failed";
|
|
254
|
+
log(statusColor(`Multi-model result: ${resultLabel}, ${summary.fulfilled.length}/${multiModels.length} succeeded`));
|
|
255
|
+
}
|
|
248
256
|
await sessionStore.updateSession(sessionMeta.id, {
|
|
249
|
-
status: hasFailure ? "error" : "completed",
|
|
257
|
+
status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
|
|
250
258
|
completedAt: new Date().toISOString(),
|
|
251
259
|
usage: aggregateUsage,
|
|
252
260
|
elapsedMs: summary.elapsedMs,
|
|
@@ -273,15 +281,52 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
|
|
|
273
281
|
savedOutputs.push({ model: entry.model, path: savedPath });
|
|
274
282
|
}
|
|
275
283
|
}
|
|
284
|
+
const sessionWithRuns = (await readSessionForManifest(sessionMeta.id)) ?? {
|
|
285
|
+
...sessionMeta,
|
|
286
|
+
models: sessionMeta.models,
|
|
287
|
+
};
|
|
288
|
+
const runLogs = await collectMultiModelRunLogs(sessionMeta.id, sessionWithRuns.models, summary);
|
|
289
|
+
const manifestPath = await writeMultiModelOutputManifest({
|
|
290
|
+
baseOutputPath: runOptions.writeOutputPath,
|
|
291
|
+
sessionId: sessionMeta.id,
|
|
292
|
+
status: hasFailure ? (allowPartial ? "partial" : "error") : "completed",
|
|
293
|
+
summary,
|
|
294
|
+
savedOutputs,
|
|
295
|
+
modelRuns: sessionWithRuns.models,
|
|
296
|
+
runLogs,
|
|
297
|
+
runOptions,
|
|
298
|
+
log,
|
|
299
|
+
});
|
|
276
300
|
if (savedOutputs.length > 0) {
|
|
277
301
|
log(dim("Saved outputs:"));
|
|
278
302
|
for (const item of savedOutputs) {
|
|
279
303
|
log(dim(`- ${item.model} -> ${item.path}`));
|
|
280
304
|
}
|
|
281
305
|
}
|
|
306
|
+
if (manifestPath) {
|
|
307
|
+
log(dim(`Output manifest: ${manifestPath}`));
|
|
308
|
+
}
|
|
309
|
+
if (runLogs.length > 0) {
|
|
310
|
+
log(dim(""));
|
|
311
|
+
log(dim("Run logs:"));
|
|
312
|
+
for (const item of runLogs) {
|
|
313
|
+
log(dim(`- ${item.model} -> ${item.path}`));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
282
316
|
}
|
|
283
317
|
if (hasFailure) {
|
|
284
|
-
|
|
318
|
+
log(dim("Failures:"));
|
|
319
|
+
for (const item of summary.rejected) {
|
|
320
|
+
const providerContext = providerFailureContextForModel(item.model, runOptions);
|
|
321
|
+
log(dim(`- ${item.model}: ${formatMultiModelFailure(item.reason, providerContext)}`));
|
|
322
|
+
for (const line of formatMultiModelFailureDetails(item.reason, providerContext)) {
|
|
323
|
+
log(dim(line));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (hasFailure && !allowPartial) {
|
|
328
|
+
const firstFailure = summary.rejected[0];
|
|
329
|
+
throw sanitizeMultiModelFailureForThrow(firstFailure.reason, providerFailureContextForModel(firstFailure.model, runOptions));
|
|
285
330
|
}
|
|
286
331
|
return;
|
|
287
332
|
}
|
|
@@ -346,6 +391,40 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
|
|
|
346
391
|
if (connectionLost && mode === "browser") {
|
|
347
392
|
const runtime = userError.details
|
|
348
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
|
+
}
|
|
349
428
|
log(dim("Chrome disconnected before completion; keeping session running for reattach."));
|
|
350
429
|
if (modelForStatus) {
|
|
351
430
|
await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
|
|
@@ -483,6 +562,196 @@ function mergeArtifacts(existing, additions) {
|
|
|
483
562
|
function formatError(error) {
|
|
484
563
|
return error instanceof Error ? error.message : String(error);
|
|
485
564
|
}
|
|
565
|
+
function providerFailureContextForModel(model, runOptions) {
|
|
566
|
+
return {
|
|
567
|
+
model,
|
|
568
|
+
providerMode: runOptions.provider,
|
|
569
|
+
azure: runOptions.azure,
|
|
570
|
+
baseUrl: runOptions.baseUrl,
|
|
571
|
+
apiKey: runOptions.apiKey,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function formatMultiModelFailure(error, context) {
|
|
575
|
+
const userError = asOracleUserError(error);
|
|
576
|
+
if (userError) {
|
|
577
|
+
return `${userError.category}, ${userError.message}`;
|
|
578
|
+
}
|
|
579
|
+
const providerFailure = classifyProviderFailure(error, context);
|
|
580
|
+
if (providerFailure) {
|
|
581
|
+
return providerFailure.label;
|
|
582
|
+
}
|
|
583
|
+
if (error instanceof OracleTransportError) {
|
|
584
|
+
return `${error.reason}, ${error.message}`;
|
|
585
|
+
}
|
|
586
|
+
if (error instanceof OracleResponseError) {
|
|
587
|
+
return error.message;
|
|
588
|
+
}
|
|
589
|
+
return formatError(error);
|
|
590
|
+
}
|
|
591
|
+
function formatMultiModelFailureDetails(error, context) {
|
|
592
|
+
const providerFailure = classifyProviderFailure(error, context);
|
|
593
|
+
if (!providerFailure) {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
const lines = [];
|
|
597
|
+
if (providerFailure.keyEnv) {
|
|
598
|
+
lines.push(` key: ${providerFailure.keyEnv}`);
|
|
599
|
+
}
|
|
600
|
+
lines.push(` provider said: ${providerFailure.providerMessage}`);
|
|
601
|
+
lines.push(` fix: ${providerFailure.fix}`);
|
|
602
|
+
return lines;
|
|
603
|
+
}
|
|
604
|
+
function sanitizeMultiModelFailureForThrow(error, context) {
|
|
605
|
+
const providerFailure = classifyProviderFailure(error, context);
|
|
606
|
+
if (!providerFailure) {
|
|
607
|
+
return error;
|
|
608
|
+
}
|
|
609
|
+
const modelPrefix = typeof context === "object" && context?.model ? `${context.model}: ` : "";
|
|
610
|
+
const message = `${modelPrefix}${providerFailure.label}: ${providerFailure.providerMessage}`;
|
|
611
|
+
if (!(error instanceof Error)) {
|
|
612
|
+
return new Error(message);
|
|
613
|
+
}
|
|
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
|
+
}
|
|
625
|
+
if (error.stack) {
|
|
626
|
+
const [, ...rest] = error.stack.split("\n");
|
|
627
|
+
sanitized.stack = [sanitized.name ? `${sanitized.name}: ${message}` : message, ...rest].join("\n");
|
|
628
|
+
}
|
|
629
|
+
return sanitized;
|
|
630
|
+
}
|
|
631
|
+
export function deriveOutputManifestPath(basePath) {
|
|
632
|
+
const ext = path.extname(basePath);
|
|
633
|
+
const stem = path.basename(basePath, ext);
|
|
634
|
+
const dir = path.dirname(basePath);
|
|
635
|
+
return path.join(dir, `${stem}.oracle.json`);
|
|
636
|
+
}
|
|
637
|
+
async function collectMultiModelRunLogs(sessionId, modelRuns, summary) {
|
|
638
|
+
const sessionDir = await resolveSessionDir(sessionId);
|
|
639
|
+
const logsByModel = new Map();
|
|
640
|
+
for (const run of modelRuns ?? []) {
|
|
641
|
+
if (run.log?.path) {
|
|
642
|
+
logsByModel.set(run.model, resolveSessionPath(sessionDir, run.log.path));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const entry of summary.fulfilled) {
|
|
646
|
+
if (!logsByModel.has(entry.model)) {
|
|
647
|
+
logsByModel.set(entry.model, entry.logPath);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return [...logsByModel.entries()].map(([model, logPath]) => ({ model, path: logPath }));
|
|
651
|
+
}
|
|
652
|
+
async function writeMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, log, }) {
|
|
653
|
+
const manifestPath = deriveOutputManifestPath(baseOutputPath);
|
|
654
|
+
const normalizedTarget = path.resolve(manifestPath);
|
|
655
|
+
const normalizedSessionsDir = path.resolve(sessionStore.sessionsDir());
|
|
656
|
+
if (normalizedTarget === normalizedSessionsDir ||
|
|
657
|
+
normalizedTarget.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
|
|
658
|
+
log(dim(`output manifest skipped: refusing to write inside session storage (${normalizedSessionsDir}).`));
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
const manifest = buildMultiModelOutputManifest({
|
|
662
|
+
baseOutputPath,
|
|
663
|
+
sessionId,
|
|
664
|
+
status,
|
|
665
|
+
summary,
|
|
666
|
+
savedOutputs,
|
|
667
|
+
modelRuns,
|
|
668
|
+
runLogs,
|
|
669
|
+
runOptions,
|
|
670
|
+
});
|
|
671
|
+
try {
|
|
672
|
+
await fs.mkdir(path.dirname(normalizedTarget), { recursive: true });
|
|
673
|
+
await fs.writeFile(normalizedTarget, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
674
|
+
return normalizedTarget;
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
678
|
+
log(dim(`output manifest failed (${reason}); session completed anyway.`));
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function buildMultiModelOutputManifest({ baseOutputPath, sessionId, status, summary, savedOutputs, modelRuns, runLogs, runOptions, }) {
|
|
683
|
+
const outputByModel = new Map(savedOutputs.map((entry) => [entry.model, entry.path]));
|
|
684
|
+
const logsByModel = new Map(runLogs.map((entry) => [entry.model, entry.path]));
|
|
685
|
+
const runsByModel = new Map((modelRuns ?? []).map((run) => [run.model, run]));
|
|
686
|
+
const fulfilledByModel = new Map(summary.fulfilled.map((entry) => [entry.model, entry]));
|
|
687
|
+
const rejectedByModel = new Map(summary.rejected.map((entry) => [entry.model, entry.reason]));
|
|
688
|
+
const orderedModels = [
|
|
689
|
+
...summary.fulfilled.map((entry) => entry.model),
|
|
690
|
+
...summary.rejected.map((entry) => entry.model),
|
|
691
|
+
];
|
|
692
|
+
return {
|
|
693
|
+
version: 1,
|
|
694
|
+
sessionId,
|
|
695
|
+
status,
|
|
696
|
+
outputBasePath: path.resolve(baseOutputPath),
|
|
697
|
+
createdAt: new Date().toISOString(),
|
|
698
|
+
models: orderedModels.map((model) => {
|
|
699
|
+
const run = runsByModel.get(model);
|
|
700
|
+
const fulfilled = fulfilledByModel.get(model);
|
|
701
|
+
const reason = rejectedByModel.get(model);
|
|
702
|
+
const userError = reason ? asOracleUserError(reason) : undefined;
|
|
703
|
+
const providerFailure = reason
|
|
704
|
+
? classifyProviderFailure(reason, providerFailureContextForModel(model, runOptions))
|
|
705
|
+
: undefined;
|
|
706
|
+
return {
|
|
707
|
+
model,
|
|
708
|
+
status: fulfilled ? "completed" : reason ? "error" : (run?.status ?? "error"),
|
|
709
|
+
outputPath: outputByModel.get(model),
|
|
710
|
+
logPath: logsByModel.get(model),
|
|
711
|
+
errorCategory: run?.error?.category ?? userError?.category ?? providerFailure?.category,
|
|
712
|
+
errorMessage: run?.error?.message ??
|
|
713
|
+
userError?.message ??
|
|
714
|
+
providerFailure?.label ??
|
|
715
|
+
(reason ? formatError(reason) : undefined),
|
|
716
|
+
elapsedMs: calculateModelElapsedMs(run),
|
|
717
|
+
usage: run?.usage ?? fulfilled?.usage,
|
|
718
|
+
};
|
|
719
|
+
}),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
function calculateModelElapsedMs(run) {
|
|
723
|
+
if (!run?.startedAt || !run.completedAt) {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
const startedMs = Date.parse(run.startedAt);
|
|
727
|
+
const completedMs = Date.parse(run.completedAt);
|
|
728
|
+
if (!Number.isFinite(startedMs) || !Number.isFinite(completedMs) || completedMs < startedMs) {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
return completedMs - startedMs;
|
|
732
|
+
}
|
|
733
|
+
async function readSessionForManifest(sessionId) {
|
|
734
|
+
try {
|
|
735
|
+
return (await sessionStore.readSession(sessionId)) ?? null;
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function resolveSessionDir(sessionId) {
|
|
742
|
+
try {
|
|
743
|
+
return (await sessionStore.getPaths(sessionId)).dir;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function resolveSessionPath(sessionDir, targetPath) {
|
|
750
|
+
if (path.isAbsolute(targetPath) || !sessionDir) {
|
|
751
|
+
return targetPath;
|
|
752
|
+
}
|
|
753
|
+
return path.join(sessionDir, targetPath);
|
|
754
|
+
}
|
|
486
755
|
async function writeAssistantOutput(targetPath, content, log) {
|
|
487
756
|
if (!targetPath)
|
|
488
757
|
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
|
|
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
|
|
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,
|
package/dist/src/mcp/types.js
CHANGED
|
@@ -11,6 +11,7 @@ export const consultInputSchema = z
|
|
|
11
11
|
browserModelLabel: z.string().optional(),
|
|
12
12
|
browserAttachments: z.enum(["auto", "never", "always"]).optional(),
|
|
13
13
|
browserBundleFiles: z.boolean().optional(),
|
|
14
|
+
browserBundleFormat: z.enum(["text", "zip"]).optional(),
|
|
14
15
|
browserThinkingTime: z.enum(["light", "standard", "extended", "heavy"]).optional(),
|
|
15
16
|
browserModelStrategy: z.enum(["select", "current", "ignore"]).optional(),
|
|
16
17
|
browserResearchMode: z.enum(["deep"]).optional(),
|
package/dist/src/mcp/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveRunOptionsFromConfig } from "../cli/runOptions.js";
|
|
2
2
|
import { Launcher } from "chrome-launcher";
|
|
3
|
-
export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, browserFollowUps, userConfig, env = process.env, }) {
|
|
3
|
+
export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, browserBundleFormat, browserFollowUps, userConfig, env = process.env, }) {
|
|
4
4
|
// Normalize CLI-style inputs through the shared resolver so config/env defaults apply,
|
|
5
5
|
// then overlay MCP-only overrides such as explicit search toggles.
|
|
6
6
|
const mergedModels = Array.isArray(models) && models.length > 0
|
|
@@ -24,6 +24,9 @@ export function mapConsultToRunOptions({ prompt, files, model, models, engine, s
|
|
|
24
24
|
if (typeof browserBundleFiles === "boolean") {
|
|
25
25
|
result.runOptions.browserBundleFiles = browserBundleFiles;
|
|
26
26
|
}
|
|
27
|
+
if (browserBundleFormat) {
|
|
28
|
+
result.runOptions.browserBundleFormat = browserBundleFormat;
|
|
29
|
+
}
|
|
27
30
|
if (Array.isArray(browserFollowUps)) {
|
|
28
31
|
result.runOptions.browserFollowUps = browserFollowUps
|
|
29
32
|
.map((entry) => entry.trim())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const NATIVE_API_HOSTS = [
|
|
2
|
+
"api.openai.com",
|
|
3
|
+
"api.anthropic.com",
|
|
4
|
+
"generativelanguage.googleapis.com",
|
|
5
|
+
"api.x.ai",
|
|
6
|
+
];
|
|
7
|
+
export function isCustomBaseUrl(baseUrl) {
|
|
8
|
+
if (!baseUrl)
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(baseUrl);
|
|
12
|
+
return !NATIVE_API_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -4,28 +4,7 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { createGeminiClient } from "./gemini.js";
|
|
5
5
|
import { createClaudeClient } from "./claude.js";
|
|
6
6
|
import { isOpenRouterBaseUrl } from "./modelResolver.js";
|
|
7
|
-
|
|
8
|
-
* Known native API base URLs that should still use their dedicated SDKs.
|
|
9
|
-
* Any other custom base URL is treated as an OpenAI-compatible proxy and
|
|
10
|
-
* all models are routed through the chat/completions adapter.
|
|
11
|
-
*/
|
|
12
|
-
const NATIVE_API_HOSTS = [
|
|
13
|
-
"api.openai.com",
|
|
14
|
-
"api.anthropic.com",
|
|
15
|
-
"generativelanguage.googleapis.com",
|
|
16
|
-
"api.x.ai",
|
|
17
|
-
];
|
|
18
|
-
export function isCustomBaseUrl(baseUrl) {
|
|
19
|
-
if (!baseUrl)
|
|
20
|
-
return false;
|
|
21
|
-
try {
|
|
22
|
-
const url = new URL(baseUrl);
|
|
23
|
-
return !NATIVE_API_HOSTS.some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`));
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
7
|
+
import { isCustomBaseUrl } from "./baseUrl.js";
|
|
29
8
|
export function buildAzureResponsesBaseUrl(endpoint) {
|
|
30
9
|
return `${endpoint.replace(/\/+$/, "")}/openai/v1`;
|
|
31
10
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { countTokens as countTokensGpt5Pro } from "gpt-tokenizer/model/gpt-5-pro";
|
|
3
|
-
import { countTokens as countTokensAnthropicRaw } from "@anthropic-ai/tokenizer";
|
|
1
|
+
import { createRequire } from "node:module";
|
|
4
2
|
import { stringifyTokenizerInput } from "./tokenStringifier.js";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
let countTokensGpt5Impl;
|
|
5
|
+
let countTokensGpt5ProImpl;
|
|
6
|
+
let countTokensAnthropicImpl;
|
|
5
7
|
export const DEFAULT_MODEL = "gpt-5.5-pro";
|
|
6
8
|
export const PRO_MODELS = new Set([
|
|
7
9
|
"gpt-5.5-pro",
|
|
@@ -12,7 +14,18 @@ export const PRO_MODELS = new Set([
|
|
|
12
14
|
"claude-4.6-sonnet",
|
|
13
15
|
"claude-4.1-opus",
|
|
14
16
|
]);
|
|
15
|
-
const
|
|
17
|
+
const countTokensGpt5 = (input, options) => {
|
|
18
|
+
countTokensGpt5Impl ??= require("gpt-tokenizer/model/gpt-5").countTokens;
|
|
19
|
+
return countTokensGpt5Impl(input, options);
|
|
20
|
+
};
|
|
21
|
+
const countTokensGpt5Pro = (input, options) => {
|
|
22
|
+
countTokensGpt5ProImpl ??= require("gpt-tokenizer/model/gpt-5-pro").countTokens;
|
|
23
|
+
return countTokensGpt5ProImpl(input, options);
|
|
24
|
+
};
|
|
25
|
+
const countTokensAnthropic = (input) => {
|
|
26
|
+
countTokensAnthropicImpl ??= require("@anthropic-ai/tokenizer").countTokens;
|
|
27
|
+
return countTokensAnthropicImpl(stringifyTokenizerInput(input));
|
|
28
|
+
};
|
|
16
29
|
export const MODEL_CONFIGS = {
|
|
17
30
|
"gpt-5.5-pro": {
|
|
18
31
|
model: "gpt-5.5-pro",
|
|
@@ -1,26 +1,6 @@
|
|
|
1
1
|
import { GoogleGenAI, HarmCategory, HarmBlockThreshold, } from "@google/genai";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
"gemini-3-pro": "gemini-3-pro-preview",
|
|
5
|
-
"gpt-5.5": "gpt-5.5",
|
|
6
|
-
"gpt-5.5-pro": "gpt-5.5-pro",
|
|
7
|
-
"gpt-5.4": "gpt-5.4",
|
|
8
|
-
"gpt-5.4-pro": "gpt-5.4-pro",
|
|
9
|
-
"gpt-5.1-pro": "gpt-5.1-pro",
|
|
10
|
-
"gpt-5-pro": "gpt-5-pro",
|
|
11
|
-
"gpt-5.1": "gpt-5.1",
|
|
12
|
-
"gpt-5.1-codex": "gpt-5.1-codex",
|
|
13
|
-
"gpt-5.2": "gpt-5.2",
|
|
14
|
-
"gpt-5.2-instant": "gpt-5.2-instant",
|
|
15
|
-
"gpt-5.2-pro": "gpt-5.2-pro",
|
|
16
|
-
"claude-4.6-sonnet": "claude-4.6-sonnet",
|
|
17
|
-
"claude-4.1-opus": "claude-4.1-opus",
|
|
18
|
-
"grok-4.1": "grok-4.1",
|
|
19
|
-
};
|
|
20
|
-
export function resolveGeminiModelId(modelName) {
|
|
21
|
-
// Map our logical Gemini names to the exact model ids expected by the SDK.
|
|
22
|
-
return MODEL_ID_MAP[modelName] ?? modelName;
|
|
23
|
-
}
|
|
2
|
+
import { resolveGeminiModelId } from "./geminiModels.js";
|
|
3
|
+
export { resolveGeminiModelId } from "./geminiModels.js";
|
|
24
4
|
export function createGeminiClient(apiKey, modelName = "gemini-3-pro", resolvedModelId) {
|
|
25
5
|
const modelId = resolvedModelId ?? resolveGeminiModelId(modelName);
|
|
26
6
|
const genAI = new GoogleGenAI({ apiKey });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MODEL_ID_MAP = {
|
|
2
|
+
"gemini-3.1-pro": "gemini-3.1-pro-preview",
|
|
3
|
+
"gemini-3-pro": "gemini-3-pro-preview",
|
|
4
|
+
"gpt-5.5": "gpt-5.5",
|
|
5
|
+
"gpt-5.5-pro": "gpt-5.5-pro",
|
|
6
|
+
"gpt-5.4": "gpt-5.4",
|
|
7
|
+
"gpt-5.4-pro": "gpt-5.4-pro",
|
|
8
|
+
"gpt-5.1-pro": "gpt-5.1-pro",
|
|
9
|
+
"gpt-5-pro": "gpt-5-pro",
|
|
10
|
+
"gpt-5.1": "gpt-5.1",
|
|
11
|
+
"gpt-5.1-codex": "gpt-5.1-codex",
|
|
12
|
+
"gpt-5.2": "gpt-5.2",
|
|
13
|
+
"gpt-5.2-instant": "gpt-5.2-instant",
|
|
14
|
+
"gpt-5.2-pro": "gpt-5.2-pro",
|
|
15
|
+
"claude-4.6-sonnet": "claude-4.6-sonnet",
|
|
16
|
+
"claude-4.1-opus": "claude-4.1-opus",
|
|
17
|
+
"grok-4.1": "grok-4.1",
|
|
18
|
+
};
|
|
19
|
+
export function resolveGeminiModelId(modelName) {
|
|
20
|
+
return MODEL_ID_MAP[modelName] ?? modelName;
|
|
21
|
+
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { MODEL_CONFIGS, PRO_MODELS } from "./config.js";
|
|
2
|
-
import { countTokens as countTokensGpt5Pro } from "gpt-tokenizer/model/gpt-5-pro";
|
|
3
3
|
import { pricingFromUsdPerMillion } from "tokentally";
|
|
4
4
|
const OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1";
|
|
5
5
|
const OPENROUTER_MODELS_ENDPOINT = "https://openrouter.ai/api/v1/models";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
let countTokensGpt5ProImpl;
|
|
8
|
+
const countTokensGpt5Pro = (input, options) => {
|
|
9
|
+
countTokensGpt5ProImpl ??= require("gpt-tokenizer/model/gpt-5-pro").countTokens;
|
|
10
|
+
return countTokensGpt5ProImpl(input, options);
|
|
11
|
+
};
|
|
6
12
|
export function isKnownModel(model) {
|
|
7
13
|
return Object.hasOwn(MODEL_CONFIGS, model);
|
|
8
14
|
}
|