@steipete/oracle 0.20.1 → 0.20.2
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/dist/bin/oracle-cli.js +5 -9
- package/dist/src/browser/actions/modelSelection.js +36 -4
- package/dist/src/browser/browserConnection.js +59 -0
- package/dist/src/browser/chatgptImages.js +1 -1
- package/dist/src/browser/chromeLifecycle.js +82 -24
- package/dist/src/browser/index.js +8 -2
- package/dist/src/browser/liveTabs.js +86 -29
- package/dist/src/cli/browserTabs.js +71 -18
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/mcp/tools/consult.js +1 -7
- package/dist/src/remote/client.js +71 -14
- package/dist/src/remote/health.js +1 -0
- package/dist/src/remote/server.js +51 -10
- package/dist/src/remote/types.js +13 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +2 -2
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -25,7 +25,7 @@ import { warnIfOversizeBundle } from "../src/cli/bundleWarnings.js";
|
|
|
25
25
|
import { formatRenderedMarkdown } from "../src/cli/renderOutput.js";
|
|
26
26
|
import { resolveRenderFlag, resolveRenderPlain } from "../src/cli/renderFlags.js";
|
|
27
27
|
import { resolveGeminiModelId } from "../src/oracle/geminiModels.js";
|
|
28
|
-
import { isErrorLogged } from "../src/cli/errorUtils.js";
|
|
28
|
+
import { formatCliError, isErrorLogged } from "../src/cli/errorUtils.js";
|
|
29
29
|
import { resolveOutputPath } from "../src/cli/writeOutputPath.js";
|
|
30
30
|
import { getCliVersion } from "../src/version.js";
|
|
31
31
|
import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, } from "../src/cli/notifier.js";
|
|
@@ -1358,6 +1358,8 @@ async function runRootCommand(options) {
|
|
|
1358
1358
|
browserRequestedModel: cliModelArg,
|
|
1359
1359
|
browserModelLabel: resolveBrowserModelLabel(cliModelArg, activeModel),
|
|
1360
1360
|
});
|
|
1361
|
+
config.modelIsImplicitDefault =
|
|
1362
|
+
optionUsesDefault("model") && !userConfig.model && !options.browserModelLabel;
|
|
1361
1363
|
return resolvedOptions.browserResumeConversationUrl
|
|
1362
1364
|
? { ...config, resumeConversationUrl: resolvedOptions.browserResumeConversationUrl }
|
|
1363
1365
|
: config;
|
|
@@ -2106,13 +2108,7 @@ async function main() {
|
|
|
2106
2108
|
}
|
|
2107
2109
|
}
|
|
2108
2110
|
void main().catch((error) => {
|
|
2109
|
-
if (error
|
|
2110
|
-
|
|
2111
|
-
console.error(chalk.red("✖"), error.message);
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
else {
|
|
2115
|
-
console.error(chalk.red("✖"), error);
|
|
2116
|
-
}
|
|
2111
|
+
if (!isErrorLogged(error))
|
|
2112
|
+
console.error(chalk.red("✖"), formatCliError(error));
|
|
2117
2113
|
process.exitCode = 1;
|
|
2118
2114
|
});
|
|
@@ -15,10 +15,28 @@ const MODEL_BUTTON_POLL_MS = 250;
|
|
|
15
15
|
export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select", options = {}) {
|
|
16
16
|
const buttonWaitMs = options.buttonWaitMs ?? MODEL_BUTTON_WAIT_MS;
|
|
17
17
|
const buttonPollMs = options.buttonPollMs ?? MODEL_BUTTON_POLL_MS;
|
|
18
|
-
const
|
|
18
|
+
const probeDeadline = Date.now() + Math.max(0, buttonWaitMs);
|
|
19
|
+
let deadline;
|
|
19
20
|
let result;
|
|
20
21
|
let announcedWait = false;
|
|
21
22
|
for (;;) {
|
|
23
|
+
if (options.implicitDefault && strategy === "select") {
|
|
24
|
+
// Wait for observable selection before a default-driven switch, just as selection waits for the picker.
|
|
25
|
+
const observed = await Runtime.evaluate({
|
|
26
|
+
expression: buildModelSelectionExpression(desiredModel, "current"),
|
|
27
|
+
awaitPromise: true,
|
|
28
|
+
returnByValue: true,
|
|
29
|
+
}).catch(() => null);
|
|
30
|
+
const label = observed?.result?.value?.label;
|
|
31
|
+
if ((typeof label !== "string" || !label.trim()) && Date.now() < probeDeadline) {
|
|
32
|
+
await delay(buttonPollMs);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof label === "string" && isNewerModelLabel(label, desiredModel)) {
|
|
36
|
+
logger(`[browser] Model selection warning: no model was specified, so Oracle's default will switch ChatGPT from "${label}" to "${desiredModel}" before submission. Pass --model explicitly or --browser-model-strategy current to keep the selected model.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
deadline ??= Date.now() + Math.max(0, buttonWaitMs);
|
|
22
40
|
const outcome = await Runtime.evaluate({
|
|
23
41
|
expression: buildModelSelectionExpression(desiredModel, strategy),
|
|
24
42
|
awaitPromise: true,
|
|
@@ -76,6 +94,18 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
|
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
}
|
|
97
|
+
function isNewerModelLabel(current, target) {
|
|
98
|
+
const latest = /^(?:Latest|最新|최신)$/i;
|
|
99
|
+
if (latest.test(current.trim()))
|
|
100
|
+
return !latest.test(target.trim());
|
|
101
|
+
const version = (label) => {
|
|
102
|
+
const match = label.match(/(?:^|gpt[- ]*|thinking\s+)(\d+)(?:\.(\d+))?/i);
|
|
103
|
+
return match ? [Number(match[1]), Number(match[2] ?? 0)] : null;
|
|
104
|
+
};
|
|
105
|
+
const from = version(current);
|
|
106
|
+
const to = version(target);
|
|
107
|
+
return Boolean(from && to && (from[0] > to[0] || (from[0] === to[0] && from[1] > to[1])));
|
|
108
|
+
}
|
|
79
109
|
function assertResolvedModelSelection(desiredModel, resolvedLabel) {
|
|
80
110
|
const desired = desiredModel.toLowerCase();
|
|
81
111
|
const resolved = resolvedLabel.toLowerCase();
|
|
@@ -85,7 +115,8 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
|
|
|
85
115
|
// The advanced radio is localized, but only the documented exact labels are
|
|
86
116
|
// evidence of GPT-6 Astra. Do not let a generic picker result verify Latest.
|
|
87
117
|
if (resolvedLabel.normalize("NFC").trim() === "Latest" ||
|
|
88
|
-
resolvedLabel.normalize("NFC").trim() === "最新"
|
|
118
|
+
resolvedLabel.normalize("NFC").trim() === "最新" ||
|
|
119
|
+
resolvedLabel.normalize("NFC").trim() === "최신") {
|
|
89
120
|
return;
|
|
90
121
|
}
|
|
91
122
|
throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-6 Astra (Latest).`);
|
|
@@ -187,7 +218,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
|
|
|
187
218
|
// Sol or an arbitrary localized menu row can never satisfy a Latest request.
|
|
188
219
|
const isLatestModelLabel = (value) => {
|
|
189
220
|
const label = String(value ?? '').normalize('NFC').trim();
|
|
190
|
-
return label === 'Latest' || label === '最新';
|
|
221
|
+
return label === 'Latest' || label === '最新' || label === '최신';
|
|
191
222
|
};
|
|
192
223
|
const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
|
|
193
224
|
.map((token) => normalizeText(token))
|
|
@@ -1442,8 +1473,9 @@ function buildModelMatchersLiteral(targetModel) {
|
|
|
1442
1473
|
testIdTokens.add("gpt56");
|
|
1443
1474
|
}
|
|
1444
1475
|
if (base === "latest") {
|
|
1445
|
-
//
|
|
1476
|
+
// Exact Japanese and Korean labels for the advanced-model Latest radio.
|
|
1446
1477
|
push("最新", labelTokens);
|
|
1478
|
+
push("최신", labelTokens);
|
|
1447
1479
|
}
|
|
1448
1480
|
// Numeric variations (5.5 <-> 55 <-> gpt-5-5)
|
|
1449
1481
|
if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { withoutBrowserCancellation } from "./cancellation.js";
|
|
2
|
+
const connections = new Map();
|
|
3
|
+
// CRI exposes no ref API. Retain approval while idle without keeping a CLI alive.
|
|
4
|
+
function refTransport(client, active) {
|
|
5
|
+
const socket = client._ws?._socket;
|
|
6
|
+
if (active)
|
|
7
|
+
socket?.ref();
|
|
8
|
+
else
|
|
9
|
+
socket?.unref();
|
|
10
|
+
}
|
|
11
|
+
export async function acquireBrowserConnection(endpoint, connect) {
|
|
12
|
+
let pending = connections.get(endpoint);
|
|
13
|
+
if (!pending) {
|
|
14
|
+
pending = Promise.resolve().then(async () => {
|
|
15
|
+
const client = await withoutBrowserCancellation(connect);
|
|
16
|
+
const connection = { client, users: 0 };
|
|
17
|
+
client.on?.("disconnect", () => {
|
|
18
|
+
if (connections.get(endpoint) === pending)
|
|
19
|
+
connections.delete(endpoint);
|
|
20
|
+
});
|
|
21
|
+
return connection;
|
|
22
|
+
});
|
|
23
|
+
connections.set(endpoint, pending);
|
|
24
|
+
}
|
|
25
|
+
let connection;
|
|
26
|
+
try {
|
|
27
|
+
connection = await pending;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (connections.get(endpoint) === pending)
|
|
31
|
+
connections.delete(endpoint);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
connection.users += 1;
|
|
35
|
+
refTransport(connection.client, true);
|
|
36
|
+
let released = false;
|
|
37
|
+
return new Proxy({}, {
|
|
38
|
+
ownKeys: () => Reflect.ownKeys(connection.client),
|
|
39
|
+
getOwnPropertyDescriptor: (_target, key) => {
|
|
40
|
+
const descriptor = Object.getOwnPropertyDescriptor(connection.client, key);
|
|
41
|
+
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
|
42
|
+
},
|
|
43
|
+
get(_target, key) {
|
|
44
|
+
const target = connection.client;
|
|
45
|
+
if (key === "close") {
|
|
46
|
+
return async () => {
|
|
47
|
+
if (released)
|
|
48
|
+
return;
|
|
49
|
+
released = true;
|
|
50
|
+
connection.users -= 1;
|
|
51
|
+
if (connection.users === 0)
|
|
52
|
+
refTransport(target, false);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const value = Reflect.get(target, key, target);
|
|
56
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -209,7 +209,7 @@ function detectImageFile(buffer) {
|
|
|
209
209
|
}
|
|
210
210
|
return null;
|
|
211
211
|
}
|
|
212
|
-
function resolveSiblingImagePath(basePath, index, extension) {
|
|
212
|
+
export function resolveSiblingImagePath(basePath, index, extension) {
|
|
213
213
|
const ext = path.extname(basePath);
|
|
214
214
|
const dir = path.dirname(basePath);
|
|
215
215
|
const stem = ext ? path.basename(basePath, ext) : path.basename(basePath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import * as childProcess from "node:child_process";
|
|
3
4
|
import net from "node:net";
|
|
4
5
|
import path from "node:path";
|
|
@@ -8,6 +9,7 @@ import { cleanupStaleProfileState } from "./profileState.js";
|
|
|
8
9
|
import { delay } from "./utils.js";
|
|
9
10
|
import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
|
|
10
11
|
import { BrowserCancellation } from "./cancellation.js";
|
|
12
|
+
import { acquireBrowserConnection } from "./browserConnection.js";
|
|
11
13
|
export async function launchChrome(config, userDataDir, logger) {
|
|
12
14
|
const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
|
|
13
15
|
const debugPort = config.debugPort ?? parseDebugPortEnv();
|
|
@@ -405,6 +407,7 @@ export async function listRemoteChromeTargets(options) {
|
|
|
405
407
|
targetId: target.targetId,
|
|
406
408
|
type: target.type,
|
|
407
409
|
url: target.url,
|
|
410
|
+
title: target.title,
|
|
408
411
|
}));
|
|
409
412
|
}
|
|
410
413
|
finally {
|
|
@@ -429,37 +432,43 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
|
|
|
429
432
|
}
|
|
430
433
|
const browser = await connectToBrowserWebSocket(host, port, options.browserWSEndpoint, logger, options.approvalWaitMs);
|
|
431
434
|
let targetId = options.targetId;
|
|
435
|
+
let createdTargetId;
|
|
432
436
|
try {
|
|
433
437
|
if (!targetId) {
|
|
434
438
|
const created = await browser.Target.createTarget({
|
|
435
439
|
url: options.targetUrl ?? "about:blank",
|
|
436
440
|
});
|
|
437
441
|
targetId = created.targetId;
|
|
442
|
+
createdTargetId = targetId;
|
|
438
443
|
logger(`Opened dedicated remote Chrome tab targeting ${options.targetUrl ?? "about:blank"}`);
|
|
439
444
|
}
|
|
440
445
|
const attached = await browser.Target.attachToTarget({ targetId, flatten: true });
|
|
441
446
|
const client = createSessionBoundChromeClient(browser, attached.sessionId);
|
|
447
|
+
let closing;
|
|
442
448
|
return {
|
|
443
449
|
client,
|
|
444
450
|
targetId,
|
|
445
451
|
browserWSEndpoint: options.browserWSEndpoint,
|
|
446
|
-
close: async (
|
|
447
|
-
await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
|
|
452
|
+
close: (closeOptions) => (closing ??= (async () => {
|
|
448
453
|
if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
|
|
449
454
|
await browser.Target.closeTarget({ targetId }).catch(() => undefined);
|
|
450
455
|
}
|
|
451
|
-
await
|
|
452
|
-
},
|
|
456
|
+
await client.close();
|
|
457
|
+
})()),
|
|
453
458
|
};
|
|
454
459
|
}
|
|
455
460
|
catch (error) {
|
|
461
|
+
if (createdTargetId) {
|
|
462
|
+
await browser.Target.closeTarget({ targetId: createdTargetId }).catch(() => undefined);
|
|
463
|
+
}
|
|
456
464
|
await browser.close().catch(() => undefined);
|
|
457
465
|
throw error;
|
|
458
466
|
}
|
|
459
467
|
}
|
|
460
468
|
async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger, approvalWaitMs) {
|
|
469
|
+
const acquire = () => acquireBrowserConnection(browserWSEndpoint, async () => (await CDP({ target: browserWSEndpoint, local: true })));
|
|
461
470
|
if (!approvalWaitMs || approvalWaitMs <= 0) {
|
|
462
|
-
return (
|
|
471
|
+
return acquire();
|
|
463
472
|
}
|
|
464
473
|
logger(`[browser] Waiting for Chrome remote debugging approval for ${host}:${port}...`);
|
|
465
474
|
const startedAt = Date.now();
|
|
@@ -474,8 +483,8 @@ async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger,
|
|
|
474
483
|
let timeout;
|
|
475
484
|
let expired = false;
|
|
476
485
|
try {
|
|
477
|
-
const connecting =
|
|
478
|
-
//
|
|
486
|
+
const connecting = acquire().then(async (client) => {
|
|
487
|
+
// Release this waiter; another request may still be awaiting the same approval.
|
|
479
488
|
if (expired)
|
|
480
489
|
await client.close().catch(() => undefined);
|
|
481
490
|
return client;
|
|
@@ -554,6 +563,37 @@ async function connectToNewTarget(host, port, url, logger, messages) {
|
|
|
554
563
|
}
|
|
555
564
|
function createSessionBoundChromeClient(browser, sessionId) {
|
|
556
565
|
const browserWithEvents = browser;
|
|
566
|
+
const events = new EventEmitter();
|
|
567
|
+
const bridges = new Map();
|
|
568
|
+
let closing;
|
|
569
|
+
const remove = (name, listener) => {
|
|
570
|
+
events.removeListener(name, listener);
|
|
571
|
+
if (events.listenerCount(name) === 0) {
|
|
572
|
+
const bridge = bridges.get(name);
|
|
573
|
+
if (bridge)
|
|
574
|
+
browserWithEvents.removeListener(name, bridge);
|
|
575
|
+
bridges.delete(name);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
const listen = (name, listener, once = false) => {
|
|
579
|
+
if (closing)
|
|
580
|
+
return () => { };
|
|
581
|
+
if (!bridges.has(name)) {
|
|
582
|
+
const bridge = (...args) => events.emit(name, ...args);
|
|
583
|
+
bridges.set(name, bridge);
|
|
584
|
+
browserWithEvents.on(name, bridge);
|
|
585
|
+
}
|
|
586
|
+
if (once)
|
|
587
|
+
events.once(name, listener);
|
|
588
|
+
else
|
|
589
|
+
events.on(name, listener);
|
|
590
|
+
return () => remove(name, listener);
|
|
591
|
+
};
|
|
592
|
+
const onDetached = (event) => {
|
|
593
|
+
if (event.sessionId === sessionId)
|
|
594
|
+
events.emit("disconnect");
|
|
595
|
+
};
|
|
596
|
+
browserWithEvents.on("Target.detachedFromTarget", onDetached);
|
|
557
597
|
const bindDomain = (domainName) => {
|
|
558
598
|
const domain = browser[domainName];
|
|
559
599
|
const eventName = (name) => `${domainName}.${name}.${sessionId}`;
|
|
@@ -561,25 +601,34 @@ function createSessionBoundChromeClient(browser, sessionId) {
|
|
|
561
601
|
get(target, prop, receiver) {
|
|
562
602
|
if (prop === "on") {
|
|
563
603
|
return (name, listener) => {
|
|
564
|
-
|
|
565
|
-
if (typeof domainEvent === "function") {
|
|
566
|
-
return domainEvent(sessionId, listener);
|
|
567
|
-
}
|
|
568
|
-
browserWithEvents.on(eventName(name), listener);
|
|
569
|
-
return () => browserWithEvents.removeListener(eventName(name), listener);
|
|
604
|
+
return listen(eventName(name), listener);
|
|
570
605
|
};
|
|
571
606
|
}
|
|
572
607
|
if (prop === "off" || prop === "removeListener") {
|
|
573
608
|
return (name, listener) => {
|
|
574
|
-
|
|
575
|
-
off(eventName(name), listener);
|
|
609
|
+
remove(eventName(name), listener);
|
|
576
610
|
};
|
|
577
611
|
}
|
|
578
612
|
const value = Reflect.get(target, prop, receiver);
|
|
579
613
|
if (typeof value !== "function") {
|
|
580
614
|
return value;
|
|
581
615
|
}
|
|
582
|
-
|
|
616
|
+
if (value.category === "event") {
|
|
617
|
+
return (listener) => listener
|
|
618
|
+
? listen(eventName(String(prop)), listener)
|
|
619
|
+
: new Promise((resolve) => listen(eventName(String(prop)), resolve, true));
|
|
620
|
+
}
|
|
621
|
+
return (...args) => {
|
|
622
|
+
if (closing)
|
|
623
|
+
return Promise.reject(new Error("Chrome page session is closed."));
|
|
624
|
+
if (typeof args[0] === "function") {
|
|
625
|
+
return value({}, sessionId, args[0]);
|
|
626
|
+
}
|
|
627
|
+
if (typeof args[1] === "function") {
|
|
628
|
+
return value(args[0], sessionId, args[1]);
|
|
629
|
+
}
|
|
630
|
+
return value(...args, sessionId);
|
|
631
|
+
};
|
|
583
632
|
},
|
|
584
633
|
});
|
|
585
634
|
};
|
|
@@ -598,14 +647,23 @@ function createSessionBoundChromeClient(browser, sessionId) {
|
|
|
598
647
|
Input: bindDomain("Input"),
|
|
599
648
|
DOM: bindDomain("DOM"),
|
|
600
649
|
Emulation: bindDomain("Emulation"),
|
|
601
|
-
on:
|
|
602
|
-
once:
|
|
603
|
-
off:
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
650
|
+
on: (name, listener) => listen(name, listener),
|
|
651
|
+
once: (name, listener) => listen(name, listener, true),
|
|
652
|
+
off: remove,
|
|
653
|
+
removeListener: remove,
|
|
654
|
+
close: () => (closing ??= (async () => {
|
|
655
|
+
for (const [name, bridge] of bridges)
|
|
656
|
+
browserWithEvents.removeListener(name, bridge);
|
|
657
|
+
bridges.clear();
|
|
658
|
+
events.removeAllListeners();
|
|
659
|
+
browserWithEvents.removeListener("Target.detachedFromTarget", onDetached);
|
|
660
|
+
try {
|
|
661
|
+
await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
await browser.close();
|
|
665
|
+
}
|
|
666
|
+
})()),
|
|
609
667
|
};
|
|
610
668
|
}
|
|
611
669
|
export async function connectWithNewTab(port, logger, initialUrl, host, options) {
|
|
@@ -1210,7 +1210,9 @@ async function runBrowserModeInternal(options, cancellation) {
|
|
|
1210
1210
|
await captureRuntimeSnapshot();
|
|
1211
1211
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
1212
1212
|
if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) {
|
|
1213
|
-
modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy
|
|
1213
|
+
modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy, {
|
|
1214
|
+
implicitDefault: config.modelIsImplicitDefault,
|
|
1215
|
+
}), {
|
|
1214
1216
|
retries: 2,
|
|
1215
1217
|
delayMs: 300,
|
|
1216
1218
|
onRetry: (attempt, error) => {
|
|
@@ -2490,6 +2492,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
2490
2492
|
const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
|
|
2491
2493
|
host,
|
|
2492
2494
|
port,
|
|
2495
|
+
browserWSEndpoint,
|
|
2496
|
+
approvalWaitMs: config.approvalWaitMs,
|
|
2493
2497
|
ref: tabRef,
|
|
2494
2498
|
}), (attached) => attached.client.close());
|
|
2495
2499
|
client = cancellation.client(attached.client);
|
|
@@ -2605,7 +2609,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
2605
2609
|
}
|
|
2606
2610
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
2607
2611
|
if (config.desiredModel && modelStrategy !== "ignore" && !config.resumeConversationUrl) {
|
|
2608
|
-
modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy
|
|
2612
|
+
modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy, {
|
|
2613
|
+
implicitDefault: config.modelIsImplicitDefault,
|
|
2614
|
+
}), {
|
|
2609
2615
|
retries: 2,
|
|
2610
2616
|
delayMs: 300,
|
|
2611
2617
|
onRetry: (attempt, error) => {
|
|
@@ -5,6 +5,7 @@ import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assis
|
|
|
5
5
|
import { buildConversationTurnListExpression } from "./conversationTurns.js";
|
|
6
6
|
import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
|
|
7
7
|
import { delay } from "./utils.js";
|
|
8
|
+
import { connectToRemoteChromeTarget, listRemoteChromeTargets } from "./chromeLifecycle.js";
|
|
8
9
|
export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
|
|
9
10
|
export const DEFAULT_REMOTE_CHROME_PORT = 9222;
|
|
10
11
|
const LOGIN_CTA_PATTERN = /\b(log in|login|sign up|sign in|continue with google|continue with microsoft)\b/i;
|
|
@@ -22,8 +23,15 @@ function normalizeHostPort(input = {}) {
|
|
|
22
23
|
return {
|
|
23
24
|
host: input.host ?? DEFAULT_REMOTE_CHROME_HOST,
|
|
24
25
|
port: input.port ?? DEFAULT_REMOTE_CHROME_PORT,
|
|
26
|
+
...(input.browserWSEndpoint
|
|
27
|
+
? { browserWSEndpoint: input.browserWSEndpoint, approvalWaitMs: input.approvalWaitMs }
|
|
28
|
+
: {}),
|
|
25
29
|
};
|
|
26
30
|
}
|
|
31
|
+
function chromeTransportError(operation, endpoint, error) {
|
|
32
|
+
const detail = error instanceof Error ? error.message.trim() : String(error ?? "").trim();
|
|
33
|
+
return new Error(`Unable to ${operation} on Chrome at ${endpoint.host}:${endpoint.port}. ${detail || "Chrome returned no error details."} Check that Chrome is running and remote debugging is enabled, then retry and allow the connection prompt.`, { cause: error });
|
|
34
|
+
}
|
|
27
35
|
function normalizeUrl(value) {
|
|
28
36
|
return String(value ?? "").trim();
|
|
29
37
|
}
|
|
@@ -164,6 +172,7 @@ function buildTabInspectionExpression() {
|
|
|
164
172
|
const lastUserText = normalize(lastUserTurn?.textContent);
|
|
165
173
|
const lastUserMessage = lastUserTurn?.matches?.('[data-message-author-role="user"]')
|
|
166
174
|
? lastUserTurn : lastUserTurn?.querySelector?.('[data-message-author-role="user"]');
|
|
175
|
+
const userContent = lastUserMessage?.querySelectorAll?.('[class~="whitespace-pre-wrap"]');
|
|
167
176
|
const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
|
|
168
177
|
return {
|
|
169
178
|
title: normalize(document.title),
|
|
@@ -181,6 +190,7 @@ function buildTabInspectionExpression() {
|
|
|
181
190
|
lastUserTurnIndex,
|
|
182
191
|
lastUserText,
|
|
183
192
|
lastUserTextRaw: lastUserMessage?.textContent,
|
|
193
|
+
lastUserContentText: userContent?.length === 1 ? userContent[0].textContent : undefined,
|
|
184
194
|
lastUserMessageId: lastUserMessage?.getAttribute?.('data-message-id'),
|
|
185
195
|
visibilityState: document.visibilityState,
|
|
186
196
|
focused: Boolean(document.hasFocus?.()),
|
|
@@ -191,26 +201,67 @@ export function buildTabInspectionExpressionForTest() {
|
|
|
191
201
|
return buildTabInspectionExpression();
|
|
192
202
|
}
|
|
193
203
|
export async function listChatGptTargets(options = {}) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
const endpoint = normalizeHostPort(options);
|
|
205
|
+
try {
|
|
206
|
+
const targets = await listRemoteChromeTargets(endpoint);
|
|
207
|
+
return targets.filter(isChatGptTarget);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw chromeTransportError("list ChatGPT tabs", endpoint, error);
|
|
211
|
+
}
|
|
197
212
|
}
|
|
198
213
|
export async function openChatGptTarget(options = {}) {
|
|
199
|
-
const
|
|
214
|
+
const endpoint = normalizeHostPort(options);
|
|
215
|
+
const { host, port } = endpoint;
|
|
200
216
|
const url = options.url ?? "https://chatgpt.com/";
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
try {
|
|
218
|
+
if (endpoint.browserWSEndpoint) {
|
|
219
|
+
const connection = await connectToRemoteChromeTarget(host, port, noopLogger, {
|
|
220
|
+
...endpoint,
|
|
221
|
+
targetUrl: url,
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
if (!connection.targetId)
|
|
225
|
+
throw new Error("Chrome did not return a target ID.");
|
|
226
|
+
return connection.targetId;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await connection.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const target = await CDP.New({ host, port, url });
|
|
233
|
+
return target.id;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw chromeTransportError("open saved ChatGPT conversation", endpoint, error);
|
|
237
|
+
}
|
|
203
238
|
}
|
|
204
|
-
async function connectToTarget(
|
|
205
|
-
const
|
|
239
|
+
async function connectToTarget(options, targetId) {
|
|
240
|
+
const endpoint = normalizeHostPort(options);
|
|
241
|
+
const { host, port } = endpoint;
|
|
242
|
+
const connection = await connectToRemoteChromeTarget(host, port, noopLogger, {
|
|
243
|
+
...endpoint,
|
|
244
|
+
targetId,
|
|
245
|
+
}).catch((error) => {
|
|
246
|
+
throw chromeTransportError("inspect ChatGPT tab", endpoint, error);
|
|
247
|
+
});
|
|
248
|
+
const client = Object.create(connection.client);
|
|
249
|
+
// Session-bound clients detach only; the owning connection also closes its browser socket.
|
|
250
|
+
client.close = connection.close;
|
|
206
251
|
const { Runtime, DOM } = client;
|
|
207
|
-
|
|
208
|
-
|
|
252
|
+
try {
|
|
253
|
+
if (Runtime?.enable) {
|
|
254
|
+
await Runtime.enable();
|
|
255
|
+
}
|
|
256
|
+
if (DOM?.enable) {
|
|
257
|
+
await DOM.enable();
|
|
258
|
+
}
|
|
259
|
+
return client;
|
|
209
260
|
}
|
|
210
|
-
|
|
211
|
-
await
|
|
261
|
+
catch (error) {
|
|
262
|
+
await connection.close().catch(() => undefined);
|
|
263
|
+
throw error;
|
|
212
264
|
}
|
|
213
|
-
return client;
|
|
214
265
|
}
|
|
215
266
|
export async function inspectChatGptTab(options) {
|
|
216
267
|
const { host, port } = normalizeHostPort(options);
|
|
@@ -219,7 +270,7 @@ export async function inspectChatGptTab(options) {
|
|
|
219
270
|
if (!targetId) {
|
|
220
271
|
throw new Error("inspectChatGptTab requires a target with targetId.");
|
|
221
272
|
}
|
|
222
|
-
const client = await connectToTarget(
|
|
273
|
+
const client = await connectToTarget(options, targetId);
|
|
223
274
|
try {
|
|
224
275
|
const { Runtime } = client;
|
|
225
276
|
const evaluation = await Runtime.evaluate({
|
|
@@ -268,6 +319,7 @@ export async function inspectChatGptTab(options) {
|
|
|
268
319
|
lastAssistantSnippet: trimToSnippet(lastAssistantText),
|
|
269
320
|
lastUserText,
|
|
270
321
|
lastUserTextRaw: info.lastUserTextRaw,
|
|
322
|
+
lastUserContentText: info.lastUserContentText,
|
|
271
323
|
lastUserMessageId: info.lastUserMessageId,
|
|
272
324
|
lastUserSnippet: trimToSnippet(lastUserText),
|
|
273
325
|
focused: Boolean(info.focused),
|
|
@@ -305,11 +357,11 @@ export function classifyTabState(summary) {
|
|
|
305
357
|
}
|
|
306
358
|
export async function collectChatGptTabs(options = {}) {
|
|
307
359
|
const { host, port } = normalizeHostPort(options);
|
|
308
|
-
const targets = await listChatGptTargets(
|
|
360
|
+
const targets = await listChatGptTargets(options);
|
|
309
361
|
const summaries = [];
|
|
310
362
|
for (const target of targets) {
|
|
311
363
|
try {
|
|
312
|
-
const summary = await inspectChatGptTab({
|
|
364
|
+
const summary = await inspectChatGptTab({ ...options, target });
|
|
313
365
|
summaries.push(summary);
|
|
314
366
|
}
|
|
315
367
|
catch (error) {
|
|
@@ -383,28 +435,33 @@ export function resolveChatGptTabFromSummariesForTest(summaries, ref) {
|
|
|
383
435
|
return resolveChatGptTabFromSummaries(summaries, ref);
|
|
384
436
|
}
|
|
385
437
|
export async function resolveChatGptTab(options = {}) {
|
|
386
|
-
const
|
|
387
|
-
|
|
438
|
+
const ref = options.ref?.trim();
|
|
439
|
+
if (ref && ref.toLowerCase() !== "current") {
|
|
440
|
+
const targets = await listChatGptTargets(options);
|
|
441
|
+
const exact = targets.find((target) => extractTargetId(target) === ref ||
|
|
442
|
+
target.url === ref ||
|
|
443
|
+
extractConversationIdFromUrl(target.url ?? "") === ref);
|
|
444
|
+
if (exact)
|
|
445
|
+
return inspectChatGptTab({ ...options, target: exact });
|
|
446
|
+
}
|
|
447
|
+
const summaries = await collectChatGptTabs(options);
|
|
388
448
|
return resolveChatGptTabFromSummaries(summaries, options.ref);
|
|
389
449
|
}
|
|
390
450
|
export async function connectToExistingChatGptTab(options = {}) {
|
|
391
|
-
const
|
|
392
|
-
const
|
|
393
|
-
const client = await connectToTarget(host, port, tab.targetId);
|
|
451
|
+
const tab = await resolveChatGptTab(options);
|
|
452
|
+
const client = await connectToTarget(options, tab.targetId);
|
|
394
453
|
return { client, targetId: tab.targetId, tab };
|
|
395
454
|
}
|
|
396
455
|
export async function harvestChatGptTab(options = {}) {
|
|
397
|
-
const { host, port } = normalizeHostPort(options);
|
|
398
456
|
const resolved = options.target
|
|
399
|
-
? await inspectChatGptTab({
|
|
400
|
-
: await resolveChatGptTab(
|
|
401
|
-
const client = await connectToTarget(
|
|
457
|
+
? await inspectChatGptTab({ ...options, target: options.target })
|
|
458
|
+
: await resolveChatGptTab(options);
|
|
459
|
+
const client = await connectToTarget(options, resolved.targetId);
|
|
402
460
|
try {
|
|
403
461
|
const { Runtime } = client;
|
|
404
462
|
const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
405
463
|
const nowSummary = await inspectChatGptTab({
|
|
406
|
-
|
|
407
|
-
port,
|
|
464
|
+
...options,
|
|
408
465
|
target: {
|
|
409
466
|
targetId: resolved.targetId,
|
|
410
467
|
title: resolved.title,
|
|
@@ -448,8 +505,7 @@ export async function harvestChatGptTab(options = {}) {
|
|
|
448
505
|
const firstFingerprint = harvested.fingerprint;
|
|
449
506
|
await delay(options.stallWindowMs);
|
|
450
507
|
const followup = await inspectChatGptTab({
|
|
451
|
-
|
|
452
|
-
port,
|
|
508
|
+
...options,
|
|
453
509
|
target: {
|
|
454
510
|
targetId: harvested.targetId,
|
|
455
511
|
title: harvested.title,
|
|
@@ -468,6 +524,7 @@ export async function harvestChatGptTab(options = {}) {
|
|
|
468
524
|
harvested.loginButtonExists = followup.loginButtonExists;
|
|
469
525
|
harvested.lastUserText = followup.lastUserText;
|
|
470
526
|
harvested.lastUserTextRaw = followup.lastUserTextRaw;
|
|
527
|
+
harvested.lastUserContentText = followup.lastUserContentText;
|
|
471
528
|
harvested.lastUserMessageId = followup.lastUserMessageId;
|
|
472
529
|
harvested.lastUserSnippet = followup.lastUserSnippet;
|
|
473
530
|
harvested.assistantFollowsLatestUser = followup.assistantFollowsLatestUser;
|
|
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { sessionStore } from "../sessionStore.js";
|
|
5
5
|
import { resolveBrowserConfig } from "../browser/config.js";
|
|
6
|
+
import { formatWebSocketHost, readDevToolsActivePortInfo } from "../browser/detect.js";
|
|
6
7
|
import { browserPromptFingerprint } from "../browser/promptFingerprint.js";
|
|
7
8
|
import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
|
|
8
9
|
import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
|
|
@@ -41,7 +42,10 @@ function harvestMatchesSessionPrompt(harvested, fingerprint) {
|
|
|
41
42
|
return (fingerprint === undefined ||
|
|
42
43
|
(typeof harvested.lastUserMessageId === "string" &&
|
|
43
44
|
harvested.lastUserMessageId.trim().length > 0 &&
|
|
44
|
-
|
|
45
|
+
// ChatGPT can append transient status text outside the user's content after submission.
|
|
46
|
+
// Keep legacy full-container hashes valid and require an exact match for either form.
|
|
47
|
+
[harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserContentText].some((text) => typeof text === "string" &&
|
|
48
|
+
browserPromptFingerprint(text, harvested.lastUserMessageId) === fingerprint)));
|
|
45
49
|
}
|
|
46
50
|
async function harvestSessionPrompt(meta, options, requireSessionPrompt = true) {
|
|
47
51
|
const fingerprint = requireSessionPrompt ? meta.browser?.runtime?.submittedPromptHash : undefined;
|
|
@@ -63,7 +67,7 @@ async function harvestSessionPrompt(meta, options, requireSessionPrompt = true)
|
|
|
63
67
|
}
|
|
64
68
|
return harvested;
|
|
65
69
|
}
|
|
66
|
-
function sessionBrowserEndpoint(meta) {
|
|
70
|
+
async function sessionBrowserEndpoint(meta) {
|
|
67
71
|
const runtime = meta?.browser?.runtime ?? {};
|
|
68
72
|
const remote = meta?.browser?.config?.remoteChrome ?? {};
|
|
69
73
|
const host = runtime.chromeHost ?? remote.host;
|
|
@@ -71,20 +75,65 @@ function sessionBrowserEndpoint(meta) {
|
|
|
71
75
|
if (!host || !port) {
|
|
72
76
|
return null;
|
|
73
77
|
}
|
|
74
|
-
|
|
78
|
+
let browserWSEndpoint = runtime.chromeBrowserWSEndpoint;
|
|
79
|
+
let livePort = port;
|
|
80
|
+
if (browserWSEndpoint) {
|
|
81
|
+
const active = runtime.chromeProfileRoot
|
|
82
|
+
? await readDevToolsActivePortInfo(runtime.chromeProfileRoot, { host }).catch(() => null)
|
|
83
|
+
: null;
|
|
84
|
+
if (active) {
|
|
85
|
+
browserWSEndpoint = active.browserWSEndpoint;
|
|
86
|
+
livePort = active.port;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// A restarted Chrome can keep its port while changing its browser socket ID.
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeout = setTimeout(() => controller.abort(), 1000);
|
|
92
|
+
try {
|
|
93
|
+
const response = await fetch(`http://${formatWebSocketHost(host)}:${port}/json/version`, {
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
if (response.ok) {
|
|
97
|
+
const version = (await response.json());
|
|
98
|
+
const advertised = new URL(version.webSocketDebuggerUrl ?? "");
|
|
99
|
+
if (advertised.pathname.startsWith("/devtools/browser/")) {
|
|
100
|
+
const refreshed = new URL(browserWSEndpoint);
|
|
101
|
+
refreshed.pathname = advertised.pathname;
|
|
102
|
+
browserWSEndpoint = refreshed.toString();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Attach-running Chrome may disable HTTP discovery; keep its saved socket.
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
controller.abort();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
host,
|
|
117
|
+
port: livePort,
|
|
118
|
+
...(browserWSEndpoint
|
|
119
|
+
? {
|
|
120
|
+
browserWSEndpoint,
|
|
121
|
+
approvalWaitMs: resolveBrowserConfig(meta?.browser?.config).approvalWaitMs,
|
|
122
|
+
}
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
75
125
|
}
|
|
76
|
-
function collectUniqueEndpoints(metas) {
|
|
126
|
+
async function collectUniqueEndpoints(metas) {
|
|
77
127
|
const entries = new Map();
|
|
78
|
-
entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}`, {
|
|
128
|
+
entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}:http`, {
|
|
79
129
|
host: DEFAULT_REMOTE_CHROME_HOST,
|
|
80
130
|
port: DEFAULT_REMOTE_CHROME_PORT,
|
|
81
131
|
});
|
|
82
|
-
for (const
|
|
83
|
-
const endpoint = sessionBrowserEndpoint(meta);
|
|
132
|
+
for (const endpoint of await Promise.all(metas.map(sessionBrowserEndpoint))) {
|
|
84
133
|
if (!endpoint) {
|
|
85
134
|
continue;
|
|
86
135
|
}
|
|
87
|
-
entries.set(`${endpoint.host}:${endpoint.port}`, endpoint);
|
|
136
|
+
entries.set(`${endpoint.host}:${endpoint.port}:${endpoint.browserWSEndpoint ?? "http"}`, endpoint);
|
|
88
137
|
}
|
|
89
138
|
return Array.from(entries.values());
|
|
90
139
|
}
|
|
@@ -151,7 +200,7 @@ async function maybeWriteHarvestOutput(pathInput, cwd, content) {
|
|
|
151
200
|
}
|
|
152
201
|
export async function showBrowserTabsStatus() {
|
|
153
202
|
const metas = await sessionStore.listSessions().catch(() => []);
|
|
154
|
-
const endpoints = collectUniqueEndpoints(metas);
|
|
203
|
+
const endpoints = await collectUniqueEndpoints(metas);
|
|
155
204
|
let printedAny = false;
|
|
156
205
|
for (const endpoint of endpoints) {
|
|
157
206
|
let tabs;
|
|
@@ -188,7 +237,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
|
|
|
188
237
|
if (!meta) {
|
|
189
238
|
throw new Error(`No session found with ID ${sessionId}.`);
|
|
190
239
|
}
|
|
191
|
-
const recordedEndpoint = sessionBrowserEndpoint(meta);
|
|
240
|
+
const recordedEndpoint = await sessionBrowserEndpoint(meta);
|
|
192
241
|
const initialEndpoint = recordedEndpoint ?? {
|
|
193
242
|
host: DEFAULT_REMOTE_CHROME_HOST,
|
|
194
243
|
port: DEFAULT_REMOTE_CHROME_PORT,
|
|
@@ -200,8 +249,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
|
|
|
200
249
|
let harvested;
|
|
201
250
|
try {
|
|
202
251
|
harvested = await harvestSessionPrompt(meta, {
|
|
203
|
-
|
|
204
|
-
port: initialEndpoint.port,
|
|
252
|
+
...initialEndpoint,
|
|
205
253
|
ref,
|
|
206
254
|
stallWindowMs: options.stallWindowMs,
|
|
207
255
|
}, !options.browserTabRef);
|
|
@@ -219,6 +267,8 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
|
|
|
219
267
|
harvested = await harvestSessionPrompt(meta, {
|
|
220
268
|
host: recovered.host,
|
|
221
269
|
port: recovered.port,
|
|
270
|
+
browserWSEndpoint: recovered.browserWSEndpoint,
|
|
271
|
+
approvalWaitMs: recovered.approvalWaitMs,
|
|
222
272
|
ref: recovered.ref,
|
|
223
273
|
stallWindowMs: options.stallWindowMs,
|
|
224
274
|
});
|
|
@@ -244,7 +294,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
|
|
|
244
294
|
if (!meta) {
|
|
245
295
|
throw new Error(`No session found with ID ${sessionId}.`);
|
|
246
296
|
}
|
|
247
|
-
const recordedEndpoint = sessionBrowserEndpoint(meta);
|
|
297
|
+
const recordedEndpoint = await sessionBrowserEndpoint(meta);
|
|
248
298
|
let endpoint = recordedEndpoint ?? {
|
|
249
299
|
host: DEFAULT_REMOTE_CHROME_HOST,
|
|
250
300
|
port: DEFAULT_REMOTE_CHROME_PORT,
|
|
@@ -261,8 +311,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
|
|
|
261
311
|
// Probe once to see if the live tab is still alive; recover if not.
|
|
262
312
|
try {
|
|
263
313
|
await harvestChatGptTab({
|
|
264
|
-
|
|
265
|
-
port: endpoint.port,
|
|
314
|
+
...endpoint,
|
|
266
315
|
ref: browserTabRef,
|
|
267
316
|
});
|
|
268
317
|
}
|
|
@@ -277,15 +326,19 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
|
|
|
277
326
|
waitForReady: false,
|
|
278
327
|
});
|
|
279
328
|
recoveredChrome = recovered.chrome;
|
|
280
|
-
endpoint = {
|
|
329
|
+
endpoint = {
|
|
330
|
+
host: recovered.host,
|
|
331
|
+
port: recovered.port,
|
|
332
|
+
browserWSEndpoint: recovered.browserWSEndpoint,
|
|
333
|
+
approvalWaitMs: recovered.approvalWaitMs,
|
|
334
|
+
};
|
|
281
335
|
browserTabRef = recovered.ref;
|
|
282
336
|
requireRecoveredContent = true;
|
|
283
337
|
recoveredContentDeadlineMs = Date.now() + stallThresholdMs;
|
|
284
338
|
}
|
|
285
339
|
while (true) {
|
|
286
340
|
const harvested = await harvestChatGptTab({
|
|
287
|
-
|
|
288
|
-
port: endpoint.port,
|
|
341
|
+
...endpoint,
|
|
289
342
|
ref: browserTabRef,
|
|
290
343
|
});
|
|
291
344
|
const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
const LOGGED_SYMBOL = Symbol("oracle.alreadyLogged");
|
|
2
|
+
export function formatCliError(error) {
|
|
3
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
4
|
+
if (message.trim())
|
|
5
|
+
return message;
|
|
6
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
7
|
+
if (typeof code === "string" && code.trim())
|
|
8
|
+
return `Operation failed (${code}).`;
|
|
9
|
+
return "An unexpected error occurred. Retry with --verbose for more details.";
|
|
10
|
+
}
|
|
2
11
|
export function markErrorLogged(error) {
|
|
3
12
|
if (error instanceof Error) {
|
|
4
13
|
error[LOGGED_SYMBOL] = true;
|
|
@@ -299,6 +299,7 @@ export function buildConsultBrowserConfig({ userConfig, env, runModel, inputMode
|
|
|
299
299
|
researchMode: browserResearchMode ?? configuredBrowser.researchMode,
|
|
300
300
|
archiveConversations: browserArchive ?? configuredBrowser.archiveConversations,
|
|
301
301
|
desiredModel: desiredModelLabel || mapModelToBrowserLabel(runModel),
|
|
302
|
+
modelIsImplicitDefault: !inputModel && !userConfig.model && !browserModelLabel,
|
|
302
303
|
};
|
|
303
304
|
}
|
|
304
305
|
export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browserConfig, }) {
|
|
@@ -436,13 +437,6 @@ export async function runConsultTool(input, { log: requestLog, launchDetached =
|
|
|
436
437
|
const cwd = process.cwd();
|
|
437
438
|
const sendLog = (text, level = "info") => requestLog(level, { text, bytes: Buffer.byteLength(text, "utf8") }).catch(() => { });
|
|
438
439
|
const resolvedRemote = resolveRemoteServiceConfig({ userConfig, env: process.env });
|
|
439
|
-
const imageOutputPath = runOptions.generateImage ?? runOptions.outputPath;
|
|
440
|
-
if (resolvedEngine === "browser" && resolvedRemote.host && imageOutputPath) {
|
|
441
|
-
return {
|
|
442
|
-
isError: true,
|
|
443
|
-
content: textContent("ChatGPT image output is not supported with a remote browser service: generated files are not transferred back to the MCP caller. Unset ORACLE_REMOTE_HOST to generate images locally, or omit generateImage/outputPath."),
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
440
|
let browserConfig;
|
|
447
441
|
if (resolvedEngine === "browser") {
|
|
448
442
|
browserConfig = buildConsultBrowserConfig({
|
|
@@ -5,11 +5,12 @@ import { pipeline } from "node:stream/promises";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
7
7
|
import { appendArtifacts, computeFileSha256, resolveSessionArtifactsDir, resolveUniqueArtifactPath, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
|
|
8
|
-
import { MAX_REMOTE_ARTIFACT_BYTES, } from "./types.js";
|
|
8
|
+
import { MAX_REMOTE_ARTIFACT_BYTES, pickRemoteImageMetadata, } from "./types.js";
|
|
9
9
|
import { materializeStagedFallbackBundle } from "../browser/prompt.js";
|
|
10
10
|
import { checkRemoteHealth } from "./health.js";
|
|
11
11
|
import { parseHostPort } from "../bridge/connection.js";
|
|
12
12
|
import { BrowserRunCancelledError } from "../oracle/errors.js";
|
|
13
|
+
import { resolveSiblingImagePath } from "../browser/chatgptImages.js";
|
|
13
14
|
export function createRemoteBrowserExecutor({ host, token }) {
|
|
14
15
|
// Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
|
|
15
16
|
return async function remoteBrowserExecutor(options) {
|
|
@@ -17,13 +18,20 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
17
18
|
throw new Error("Web Search is a local browser pilot; --remote-host does not negotiate this capability yet. Use local Chrome or --browser-attach-running.");
|
|
18
19
|
}
|
|
19
20
|
const callerSignal = options.signal;
|
|
21
|
+
const imageOutputRequested = Boolean(options.generateImagePath || options.outputPath);
|
|
20
22
|
if (callerSignal?.aborted)
|
|
21
23
|
throw new BrowserRunCancelledError("Browser run cancelled before the request was sent.");
|
|
22
|
-
if (callerSignal) {
|
|
24
|
+
if (callerSignal || imageOutputRequested) {
|
|
23
25
|
const health = await checkRemoteHealth({ host, token, signal: callerSignal });
|
|
24
|
-
if (callerSignal
|
|
26
|
+
if (callerSignal?.aborted)
|
|
25
27
|
throw new BrowserRunCancelledError();
|
|
26
|
-
if (
|
|
28
|
+
if (imageOutputRequested &&
|
|
29
|
+
(!health.ok ||
|
|
30
|
+
health.capabilities?.generatedImages !== true ||
|
|
31
|
+
health.capabilities.artifactProtocolVersion !== 1)) {
|
|
32
|
+
throw new Error("Remote host cannot capture and transfer generated images; upgrade Oracle on the host and retry. The image request was not sent.");
|
|
33
|
+
}
|
|
34
|
+
if (callerSignal && health.capabilities?.runCancellation !== true)
|
|
27
35
|
throw new Error("Remote host does not support run cancellation; upgrade the host before using an AbortSignal.");
|
|
28
36
|
}
|
|
29
37
|
const payload = {
|
|
@@ -37,6 +45,7 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
37
45
|
sessionId: options.sessionId,
|
|
38
46
|
followUpPrompts: options.followUpPrompts,
|
|
39
47
|
cancelOnDisconnect: callerSignal ? true : undefined,
|
|
48
|
+
imageOutputRequested,
|
|
40
49
|
},
|
|
41
50
|
};
|
|
42
51
|
const body = Buffer.from(JSON.stringify(payload));
|
|
@@ -46,10 +55,12 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
46
55
|
reject(new Error("Browser run cancelled before the request was sent."));
|
|
47
56
|
return;
|
|
48
57
|
}
|
|
49
|
-
const
|
|
58
|
+
const transferredArtifacts = [];
|
|
50
59
|
const transferFailures = [];
|
|
51
60
|
const transferPromises = [];
|
|
52
61
|
let artifactTransferQueue = Promise.resolve();
|
|
62
|
+
const preferredImagePath = options.generateImagePath ?? options.outputPath;
|
|
63
|
+
let preferredImageIndex = 0;
|
|
53
64
|
let settled = false;
|
|
54
65
|
let resolved = null;
|
|
55
66
|
const fail = (error) => {
|
|
@@ -95,7 +106,7 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
95
106
|
resolved = result;
|
|
96
107
|
},
|
|
97
108
|
onArtifact: (artifact) => {
|
|
98
|
-
|
|
109
|
+
transferredArtifacts.push(artifact);
|
|
99
110
|
},
|
|
100
111
|
onArtifactFailure: (message) => {
|
|
101
112
|
transferFailures.push(message);
|
|
@@ -105,6 +116,12 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
105
116
|
artifactTransferQueue = queued.catch(() => undefined);
|
|
106
117
|
return queued;
|
|
107
118
|
},
|
|
119
|
+
resolvePreferredImagePath: (descriptor) => {
|
|
120
|
+
if (!preferredImagePath)
|
|
121
|
+
return undefined;
|
|
122
|
+
const extension = path.extname(descriptor.filename).slice(1) || "png";
|
|
123
|
+
return resolveSiblingImagePath(path.resolve(preferredImagePath), preferredImageIndex++, extension);
|
|
124
|
+
},
|
|
108
125
|
onError: fail,
|
|
109
126
|
});
|
|
110
127
|
if (transferPromise) {
|
|
@@ -123,9 +140,18 @@ export function createRemoteBrowserExecutor({ host, token }) {
|
|
|
123
140
|
fail(new Error("Remote browser run completed without a result."));
|
|
124
141
|
return;
|
|
125
142
|
}
|
|
143
|
+
if (preferredImagePath) {
|
|
144
|
+
const images = transferredArtifacts.filter((artifact) => artifact.kind === "image");
|
|
145
|
+
if (images.length === 0 ||
|
|
146
|
+
images.length !== preferredImageIndex ||
|
|
147
|
+
resolved.warnings?.some((warning) => warning.code === "remote-image-registration-failed")) {
|
|
148
|
+
fail(new Error("Remote image output was not fully delivered. Inspect the bridge host's generated images and the artifact transfer diagnostics before retrying."));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
126
152
|
settled = true;
|
|
127
153
|
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
128
|
-
resolve(mergeTransferredArtifacts(resolved,
|
|
154
|
+
resolve(mergeTransferredArtifacts(resolved, transferredArtifacts, transferFailures));
|
|
129
155
|
})().catch(fail);
|
|
130
156
|
});
|
|
131
157
|
res.on("error", fail);
|
|
@@ -222,12 +248,16 @@ function handleEvent(params) {
|
|
|
222
248
|
}
|
|
223
249
|
if (event.type === "artifact-ready") {
|
|
224
250
|
const displayFilename = sanitizeArtifactFilename(String(event.artifact?.filename ?? ""), "artifact.bin");
|
|
251
|
+
const preferredPath = event.artifact.kind === "image"
|
|
252
|
+
? params.resolvePreferredImagePath(event.artifact)
|
|
253
|
+
: undefined;
|
|
225
254
|
const transfer = params.enqueueArtifactTransfer(() => transferRemoteArtifact({
|
|
226
255
|
hostname: params.hostname,
|
|
227
256
|
port: params.port,
|
|
228
257
|
token: params.token,
|
|
229
258
|
descriptor: event.artifact,
|
|
230
259
|
sessionId: params.options.sessionId,
|
|
260
|
+
preferredPath,
|
|
231
261
|
signal: params.options.signal,
|
|
232
262
|
log: params.options.log,
|
|
233
263
|
})
|
|
@@ -256,9 +286,11 @@ async function transferRemoteArtifact(params) {
|
|
|
256
286
|
validateRemoteArtifactDescriptor(params.descriptor);
|
|
257
287
|
const sessionId = params.sessionId ?? params.descriptor.runId;
|
|
258
288
|
const artifactsDir = resolveSessionArtifactsDir(sessionId);
|
|
259
|
-
await mkdir(artifactsDir, { recursive: true });
|
|
260
289
|
const filename = sanitizeArtifactFilename(params.descriptor.filename, `artifact-${params.descriptor.artifactId}.bin`);
|
|
261
|
-
const finalPath =
|
|
290
|
+
const finalPath = params.preferredPath
|
|
291
|
+
? path.resolve(params.preferredPath)
|
|
292
|
+
: await resolveUniqueArtifactPath(path.join(artifactsDir, filename));
|
|
293
|
+
await mkdir(path.dirname(finalPath), { recursive: true });
|
|
262
294
|
const partPath = `${finalPath}.part-${params.descriptor.artifactId}`;
|
|
263
295
|
const artifactPath = `/runs/${encodeURIComponent(params.descriptor.runId)}/artifacts/${encodeURIComponent(params.descriptor.artifactId)}`;
|
|
264
296
|
params.log?.(`[browser] Transferring artifact ${filename} from bridge host...`);
|
|
@@ -300,8 +332,7 @@ async function transferRemoteArtifact(params) {
|
|
|
300
332
|
await rename(partPath, finalPath);
|
|
301
333
|
params.log?.(`[browser] Transferred artifact to ${finalPath}`);
|
|
302
334
|
const publishedFilename = path.basename(finalPath);
|
|
303
|
-
|
|
304
|
-
kind: "file",
|
|
335
|
+
const baseArtifact = {
|
|
305
336
|
path: finalPath,
|
|
306
337
|
label: publishedFilename,
|
|
307
338
|
mimeType: sanitizeArtifactMimeType(params.descriptor.mimeType),
|
|
@@ -313,6 +344,17 @@ async function transferRemoteArtifact(params) {
|
|
|
313
344
|
origin: { mode: "bridge" },
|
|
314
345
|
url: "bridge-artifact",
|
|
315
346
|
finalUrl: "bridge-artifact",
|
|
347
|
+
};
|
|
348
|
+
if (params.descriptor.kind === "image") {
|
|
349
|
+
return {
|
|
350
|
+
...baseArtifact,
|
|
351
|
+
kind: "image",
|
|
352
|
+
...pickRemoteImageMetadata(params.descriptor.image),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
...baseArtifact,
|
|
357
|
+
kind: "file",
|
|
316
358
|
filename: publishedFilename,
|
|
317
359
|
};
|
|
318
360
|
}
|
|
@@ -371,7 +413,7 @@ async function downloadArtifactToFile(params) {
|
|
|
371
413
|
function validateRemoteArtifactDescriptor(descriptor) {
|
|
372
414
|
if (!descriptor ||
|
|
373
415
|
typeof descriptor !== "object" ||
|
|
374
|
-
descriptor.kind !== "file" ||
|
|
416
|
+
(descriptor.kind !== "file" && descriptor.kind !== "image") ||
|
|
375
417
|
typeof descriptor.runId !== "string" ||
|
|
376
418
|
!/^[a-zA-Z0-9_-]{1,128}$/.test(descriptor.runId) ||
|
|
377
419
|
typeof descriptor.artifactId !== "string" ||
|
|
@@ -385,9 +427,12 @@ function validateRemoteArtifactDescriptor(descriptor) {
|
|
|
385
427
|
throw new Error("invalid bridge artifact descriptor");
|
|
386
428
|
}
|
|
387
429
|
}
|
|
388
|
-
function mergeTransferredArtifacts(result,
|
|
389
|
-
const
|
|
430
|
+
function mergeTransferredArtifacts(result, transferredArtifacts, transferFailures) {
|
|
431
|
+
const transferredFiles = transferredArtifacts.filter((artifact) => artifact.kind === "file");
|
|
432
|
+
const transferredImages = transferredArtifacts.filter((artifact) => artifact.kind === "image");
|
|
433
|
+
const artifacts = appendArtifacts(result.artifacts, transferredArtifacts);
|
|
390
434
|
const savedFiles = appendSavedFiles(result.savedFiles, transferredFiles);
|
|
435
|
+
const savedImages = appendSavedImages(result.savedImages, transferredImages);
|
|
391
436
|
const warnings = [
|
|
392
437
|
...(result.warnings ?? []),
|
|
393
438
|
...transferFailures.map((message) => ({
|
|
@@ -400,9 +445,21 @@ function mergeTransferredArtifacts(result, transferredFiles, transferFailures) {
|
|
|
400
445
|
...result,
|
|
401
446
|
artifacts,
|
|
402
447
|
savedFiles,
|
|
448
|
+
savedImages,
|
|
403
449
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
404
450
|
};
|
|
405
451
|
}
|
|
452
|
+
function appendSavedImages(existing, additions) {
|
|
453
|
+
const merged = new Map();
|
|
454
|
+
for (const artifact of existing ?? []) {
|
|
455
|
+
merged.set(artifact.path, artifact);
|
|
456
|
+
}
|
|
457
|
+
for (const artifact of additions) {
|
|
458
|
+
merged.set(artifact.path, artifact);
|
|
459
|
+
}
|
|
460
|
+
const values = Array.from(merged.values());
|
|
461
|
+
return values.length > 0 ? values : undefined;
|
|
462
|
+
}
|
|
406
463
|
function appendSavedFiles(existing, additions) {
|
|
407
464
|
const merged = new Map();
|
|
408
465
|
for (const artifact of existing ?? []) {
|
|
@@ -93,6 +93,7 @@ function parseCapabilities(value) {
|
|
|
93
93
|
return {
|
|
94
94
|
...(raw.deferredFallbackBundling === true ? { deferredFallbackBundling: true } : {}),
|
|
95
95
|
...(raw.runCancellation === true ? { runCancellation: true } : {}),
|
|
96
|
+
...(raw.generatedImages === true ? { generatedImages: true } : {}),
|
|
96
97
|
artifactTransfer: true,
|
|
97
98
|
artifactProtocolVersion,
|
|
98
99
|
maxArtifactBytes: Math.min(maxArtifactBytes, MAX_REMOTE_ARTIFACT_BYTES),
|
|
@@ -14,20 +14,21 @@ import { resolveBrowserConfig } from "../browser/config.js";
|
|
|
14
14
|
import { RunSlots } from "./runSlots.js";
|
|
15
15
|
export { RunSlots } from "./runSlots.js";
|
|
16
16
|
import { loadUserConfig } from "../config.js";
|
|
17
|
-
import { MAX_REMOTE_ARTIFACT_BYTES } from "./types.js";
|
|
17
|
+
import { MAX_REMOTE_ARTIFACT_BYTES, pickRemoteImageMetadata } from "./types.js";
|
|
18
18
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
19
19
|
import { CHATGPT_URL } from "../browser/constants.js";
|
|
20
20
|
import { getCliVersion } from "../version.js";
|
|
21
21
|
import { getOracleHomeDir } from "../oracleHome.js";
|
|
22
22
|
import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
|
|
23
23
|
import { normalizeChatgptUrl } from "../browser/utils.js";
|
|
24
|
-
import { computeFileSha256, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
|
|
24
|
+
import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
|
|
25
25
|
const ARTIFACT_PROTOCOL_VERSION = 1;
|
|
26
26
|
const REMOTE_ARTIFACT_TTL_MS = 30 * 60 * 1000;
|
|
27
27
|
const ARTIFACT_CAPABILITIES = {
|
|
28
28
|
runCancellation: true,
|
|
29
29
|
deferredFallbackBundling: true,
|
|
30
30
|
artifactTransfer: true,
|
|
31
|
+
generatedImages: true,
|
|
31
32
|
artifactProtocolVersion: ARTIFACT_PROTOCOL_VERSION,
|
|
32
33
|
maxArtifactBytes: MAX_REMOTE_ARTIFACT_BYTES,
|
|
33
34
|
};
|
|
@@ -334,6 +335,9 @@ export async function createRemoteServer(options = {}, deps = {}) {
|
|
|
334
335
|
? payload.options.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 32)
|
|
335
336
|
: "remote";
|
|
336
337
|
payload.options.sessionId = `${clientSession || "remote"}-${runId}`;
|
|
338
|
+
const hostImageOutputPath = payload.options.imageOutputRequested === true
|
|
339
|
+
? path.join(resolveSessionArtifactsDir(payload.options.sessionId), "generated.png")
|
|
340
|
+
: undefined;
|
|
337
341
|
if (browserTabCap !== undefined)
|
|
338
342
|
payload.browserConfig.maxConcurrentTabs = browserTabCap;
|
|
339
343
|
signal?.throwIfAborted();
|
|
@@ -362,6 +366,7 @@ export async function createRemoteServer(options = {}, deps = {}) {
|
|
|
362
366
|
heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
|
|
363
367
|
verbose: payload.options.verbose,
|
|
364
368
|
sessionId: payload.options.sessionId,
|
|
369
|
+
generateImagePath: hostImageOutputPath,
|
|
365
370
|
followUpPrompts: payload.options.followUpPrompts,
|
|
366
371
|
});
|
|
367
372
|
signal?.throwIfAborted();
|
|
@@ -615,13 +620,14 @@ function remoteArtifactKey(runId, artifactId) {
|
|
|
615
620
|
async function registerRemoteArtifacts(params) {
|
|
616
621
|
pruneExpiredArtifacts(params.artifactRegistry);
|
|
617
622
|
const seen = new Set();
|
|
618
|
-
const
|
|
623
|
+
const transferableArtifacts = [
|
|
619
624
|
...(params.result.savedFiles ?? []),
|
|
620
|
-
...(params.result.
|
|
625
|
+
...(params.result.savedImages ?? []),
|
|
626
|
+
...(params.result.artifacts ?? []).filter((artifact) => artifact.kind === "file" || artifact.kind === "image"),
|
|
621
627
|
];
|
|
622
628
|
const descriptors = [];
|
|
623
629
|
const warnings = [];
|
|
624
|
-
for (const artifact of
|
|
630
|
+
for (const artifact of transferableArtifacts) {
|
|
625
631
|
if (!artifact?.path || seen.has(artifact.path)) {
|
|
626
632
|
continue;
|
|
627
633
|
}
|
|
@@ -630,7 +636,9 @@ async function registerRemoteArtifacts(params) {
|
|
|
630
636
|
const filename = sanitizeArtifactFilename(path.basename(artifact.path), "artifact.bin");
|
|
631
637
|
params.logger(`[serve] Skipping remote artifact descriptor: ${error instanceof Error ? error.message : String(error)}`);
|
|
632
638
|
warnings.push({
|
|
633
|
-
code:
|
|
639
|
+
code: artifact.kind === "image"
|
|
640
|
+
? "remote-image-registration-failed"
|
|
641
|
+
: "remote-artifact-registration-failed",
|
|
634
642
|
severity: "warning",
|
|
635
643
|
message: `Oracle captured the browser text response, but the bridge host could not prepare ${filename} for transfer. ` +
|
|
636
644
|
"Open the ChatGPT browser on the bridge host, download the ZIP/file shown in the current response, and copy it to a cloud-readable path.",
|
|
@@ -675,7 +683,12 @@ async function buildRemoteArtifactRegistration(runId, artifact) {
|
|
|
675
683
|
descriptor: {
|
|
676
684
|
artifactId: randomUUID(),
|
|
677
685
|
runId,
|
|
678
|
-
kind: "file",
|
|
686
|
+
kind: artifact.kind === "image" ? "image" : "file",
|
|
687
|
+
...(artifact.kind === "image"
|
|
688
|
+
? {
|
|
689
|
+
image: pickRemoteImageMetadata(artifact),
|
|
690
|
+
}
|
|
691
|
+
: {}),
|
|
679
692
|
filename,
|
|
680
693
|
mimeType,
|
|
681
694
|
byteSize: fileStat.size,
|
|
@@ -740,6 +753,7 @@ const CLIENT_BROWSER_CONFIG_FIELDS = [
|
|
|
740
753
|
"chatgptUrl",
|
|
741
754
|
"url",
|
|
742
755
|
"desiredModel",
|
|
756
|
+
"modelIsImplicitDefault",
|
|
743
757
|
"modelStrategy",
|
|
744
758
|
"thinkingTime",
|
|
745
759
|
"researchMode",
|
|
@@ -805,10 +819,37 @@ async function stageRemoteAttachments(payload, directory, defaultPrefix) {
|
|
|
805
819
|
}
|
|
806
820
|
// Return conversation identity and observed selection; keep process/profile details on the host.
|
|
807
821
|
function sanitizeResult(result, warnings = []) {
|
|
822
|
+
const hostArtifactPaths = [
|
|
823
|
+
...(result.savedFiles ?? []),
|
|
824
|
+
...(result.savedImages ?? []),
|
|
825
|
+
...(result.artifacts ?? []),
|
|
826
|
+
]
|
|
827
|
+
.map((artifact) => artifact.path)
|
|
828
|
+
.filter((artifactPath) => Boolean(artifactPath));
|
|
829
|
+
const savedImagePaths = [
|
|
830
|
+
...(result.savedImages ?? []),
|
|
831
|
+
...(result.artifacts ?? []).filter((artifact) => artifact.kind === "image"),
|
|
832
|
+
].map((artifact) => artifact.path);
|
|
833
|
+
const imageCount = new Set(savedImagePaths).size;
|
|
834
|
+
const sanitizeAnswer = (value) => {
|
|
835
|
+
let sanitized = value;
|
|
836
|
+
// Local save notices describe the host filesystem, not the client's transferred files.
|
|
837
|
+
for (const imagePath of savedImagePaths) {
|
|
838
|
+
sanitized = sanitized
|
|
839
|
+
?.split(` Saved to: ${imagePath}`)
|
|
840
|
+
.join("")
|
|
841
|
+
.split(` Saved ${imageCount} file(s) starting at: ${imagePath}`)
|
|
842
|
+
.join("");
|
|
843
|
+
}
|
|
844
|
+
for (const artifactPath of hostArtifactPaths) {
|
|
845
|
+
sanitized = sanitized?.split(artifactPath).join(path.basename(artifactPath));
|
|
846
|
+
}
|
|
847
|
+
return sanitized;
|
|
848
|
+
};
|
|
808
849
|
return {
|
|
809
|
-
answerText: result.answerText,
|
|
810
|
-
answerMarkdown: result.answerMarkdown,
|
|
811
|
-
answerHtml: result.answerHtml,
|
|
850
|
+
answerText: sanitizeAnswer(result.answerText) ?? "",
|
|
851
|
+
answerMarkdown: sanitizeAnswer(result.answerMarkdown) ?? "",
|
|
852
|
+
answerHtml: sanitizeAnswer(result.answerHtml),
|
|
812
853
|
tookMs: result.tookMs,
|
|
813
854
|
answerTokens: result.answerTokens,
|
|
814
855
|
answerChars: result.answerChars,
|
package/dist/src/remote/types.js
CHANGED
|
@@ -1 +1,14 @@
|
|
|
1
1
|
export const MAX_REMOTE_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
2
|
+
export function pickRemoteImageMetadata(image = {}) {
|
|
3
|
+
return {
|
|
4
|
+
...(Number.isSafeInteger(image.width) && Number(image.width) > 0
|
|
5
|
+
? { width: Number(image.width) }
|
|
6
|
+
: {}),
|
|
7
|
+
...(Number.isSafeInteger(image.height) && Number(image.height) > 0
|
|
8
|
+
? { height: Number(image.height) }
|
|
9
|
+
: {}),
|
|
10
|
+
...(typeof image.fileId === "string" && /^file[-_][a-z0-9_-]{1,200}$/i.test(image.fileId)
|
|
11
|
+
? { fileId: image.fileId }
|
|
12
|
+
: {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steipete/oracle",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
4
4
|
"description": "CLI wrapper around OpenAI Responses API with GPT-5.6 Sol, GPT-5.6, 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://askoracle.sh",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"shiki": "^4.4.3",
|
|
81
81
|
"toasted-notifier": "^10.1.0",
|
|
82
82
|
"tokentally": "^0.1.6",
|
|
83
|
-
"zod": "^4.6.
|
|
83
|
+
"zod": "^4.6.1"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
86
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
|
Binary file
|
|
Binary file
|