@steipete/oracle 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/dist/bin/oracle-cli.js +104 -16
- package/dist/src/browser/actions/archiveConversation.js +224 -0
- package/dist/src/browser/actions/assistantResponse.js +26 -0
- package/dist/src/browser/actions/deepResearch.js +662 -0
- package/dist/src/browser/actions/modelSelection.js +78 -13
- package/dist/src/browser/actions/navigation.js +22 -0
- package/dist/src/browser/actions/projectSources.js +491 -0
- package/dist/src/browser/actions/promptComposer.js +52 -27
- package/dist/src/browser/actions/thinkingStatus.js +391 -0
- package/dist/src/browser/artifacts.js +150 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptImages.js +315 -0
- package/dist/src/browser/chromeLifecycle.js +214 -3
- package/dist/src/browser/config.js +26 -2
- package/dist/src/browser/constants.js +8 -0
- package/dist/src/browser/controlPlan.js +81 -0
- package/dist/src/browser/detect.js +206 -33
- package/dist/src/browser/domDebug.js +49 -0
- package/dist/src/browser/index.js +1257 -485
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/profileState.js +83 -3
- package/dist/src/browser/projectSourcesRunner.js +366 -0
- package/dist/src/browser/reattach.js +117 -45
- package/dist/src/browser/reattachHelpers.js +1 -1
- package/dist/src/browser/sessionRunner.js +53 -1
- package/dist/src/browser/tabLeaseRegistry.js +182 -0
- package/dist/src/cli/bridge/claudeConfig.js +12 -8
- package/dist/src/cli/bridge/codexConfig.js +2 -2
- package/dist/src/cli/browserConfig.js +40 -0
- package/dist/src/cli/browserDefaults.js +31 -7
- package/dist/src/cli/browserTabs.js +228 -0
- package/dist/src/cli/dryRun.js +33 -1
- package/dist/src/cli/duplicatePromptGuard.js +10 -2
- package/dist/src/cli/help.js +1 -1
- package/dist/src/cli/options.js +4 -0
- package/dist/src/cli/projectSources.js +116 -0
- package/dist/src/cli/sessionCommand.js +51 -0
- package/dist/src/cli/sessionDisplay.js +121 -9
- package/dist/src/cli/sessionRunner.js +51 -7
- package/dist/src/mcp/consultPresets.js +19 -0
- package/dist/src/mcp/server.js +2 -0
- package/dist/src/mcp/tools/consult.js +201 -26
- package/dist/src/mcp/tools/projectSources.js +123 -0
- package/dist/src/mcp/types.js +7 -0
- package/dist/src/mcp/utils.js +6 -1
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/projectSources/plan.js +27 -0
- package/dist/src/projectSources/types.js +1 -0
- package/dist/src/projectSources/url.js +23 -0
- package/dist/src/sessionManager.js +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
|
|
4
|
+
import { delay } from "./utils.js";
|
|
5
|
+
import { readAssistantSnapshot } from "./pageActions.js";
|
|
6
|
+
import { getOracleHomeDir } from "../oracleHome.js";
|
|
7
|
+
import { resolveSessionArtifactsDir } from "./artifacts.js";
|
|
8
|
+
const GENERATED_IMAGE_WAIT_MIN_MS = 15_000;
|
|
9
|
+
const GENERATED_IMAGE_WAIT_MAX_MS = 15 * 60_000;
|
|
10
|
+
function extractFileId(url) {
|
|
11
|
+
try {
|
|
12
|
+
return new URL(url).searchParams.get("id") ?? undefined;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function dedupeImages(images) {
|
|
19
|
+
const best = new Map();
|
|
20
|
+
for (const image of images) {
|
|
21
|
+
const key = image.fileId ?? image.url;
|
|
22
|
+
const currentArea = (image.width ?? 0) * (image.height ?? 0);
|
|
23
|
+
const existing = best.get(key);
|
|
24
|
+
const existingArea = existing ? (existing.width ?? 0) * (existing.height ?? 0) : -1;
|
|
25
|
+
if (!existing || currentArea >= existingArea) {
|
|
26
|
+
best.set(key, image);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...best.values()];
|
|
30
|
+
}
|
|
31
|
+
function buildAssistantImageExpression(minTurnIndex) {
|
|
32
|
+
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
33
|
+
? Math.floor(minTurnIndex)
|
|
34
|
+
: -1;
|
|
35
|
+
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
36
|
+
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
37
|
+
return `(() => {
|
|
38
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
39
|
+
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
40
|
+
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
41
|
+
const isGeneratedImage = (img) => {
|
|
42
|
+
const url = img?.src || '';
|
|
43
|
+
if (!url.includes('/backend-api/estuary/content?id=file_')) return false;
|
|
44
|
+
const alt = String(img.alt || '').toLowerCase();
|
|
45
|
+
if (alt.includes('generated image')) return true;
|
|
46
|
+
let node = img;
|
|
47
|
+
while (node instanceof HTMLElement) {
|
|
48
|
+
if (String(node.id || '').startsWith('image-')) return true;
|
|
49
|
+
if (String(node.className || '').includes('imagegen-image')) return true;
|
|
50
|
+
node = node.parentElement;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
const serializeImages = (root) =>
|
|
55
|
+
Array.from(root.querySelectorAll('img')).filter(isGeneratedImage).map((img) => ({
|
|
56
|
+
url: img.src || '',
|
|
57
|
+
alt: img.alt || '',
|
|
58
|
+
width: img.naturalWidth || 0,
|
|
59
|
+
height: img.naturalHeight || 0,
|
|
60
|
+
}));
|
|
61
|
+
const isAssistantTurn = (node) => {
|
|
62
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
63
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
64
|
+
if (turnAttr === 'assistant') return true;
|
|
65
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
66
|
+
if (role === 'assistant') return true;
|
|
67
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
68
|
+
if (testId.includes('assistant')) return true;
|
|
69
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
70
|
+
};
|
|
71
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
72
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
73
|
+
const turn = turns[index];
|
|
74
|
+
if (!isAssistantTurn(turn)) continue;
|
|
75
|
+
if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
|
|
76
|
+
const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) || turn;
|
|
77
|
+
const images = serializeImages(messageRoot);
|
|
78
|
+
if (images.length > 0) return images;
|
|
79
|
+
}
|
|
80
|
+
const boundary =
|
|
81
|
+
MIN_TURN_INDEX > 0 && turns.length > 0
|
|
82
|
+
? turns[Math.min(MIN_TURN_INDEX - 1, turns.length - 1)]
|
|
83
|
+
: null;
|
|
84
|
+
return Array.from(document.querySelectorAll('img'))
|
|
85
|
+
.filter(isGeneratedImage)
|
|
86
|
+
.filter((img) => {
|
|
87
|
+
if (!boundary) return true;
|
|
88
|
+
return Boolean(boundary.compareDocumentPosition(img) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
89
|
+
})
|
|
90
|
+
.map((img) => ({
|
|
91
|
+
url: img.src || '',
|
|
92
|
+
alt: img.alt || '',
|
|
93
|
+
width: img.naturalWidth || 0,
|
|
94
|
+
height: img.naturalHeight || 0,
|
|
95
|
+
}));
|
|
96
|
+
})()`;
|
|
97
|
+
}
|
|
98
|
+
export async function readAssistantGeneratedImages(Runtime, minTurnIndex) {
|
|
99
|
+
const { result } = await Runtime.evaluate({
|
|
100
|
+
expression: buildAssistantImageExpression(minTurnIndex),
|
|
101
|
+
returnByValue: true,
|
|
102
|
+
});
|
|
103
|
+
const raw = Array.isArray(result?.value) ? result.value : [];
|
|
104
|
+
const normalized = raw
|
|
105
|
+
.map((item) => ({
|
|
106
|
+
url: typeof item?.url === "string" ? item.url : "",
|
|
107
|
+
alt: typeof item?.alt === "string" ? item.alt : undefined,
|
|
108
|
+
width: typeof item?.width === "number" ? item.width : undefined,
|
|
109
|
+
height: typeof item?.height === "number" ? item.height : undefined,
|
|
110
|
+
fileId: typeof item?.url === "string" ? extractFileId(item.url) : undefined,
|
|
111
|
+
}))
|
|
112
|
+
.filter((item) => item.url.length > 0);
|
|
113
|
+
return dedupeImages(normalized);
|
|
114
|
+
}
|
|
115
|
+
async function readAssistantGeneratedImagesWithFallback(Runtime, minTurnIndex) {
|
|
116
|
+
const filteredImages = await readAssistantGeneratedImages(Runtime, minTurnIndex ?? undefined).catch(() => []);
|
|
117
|
+
if (filteredImages.length > 0 ||
|
|
118
|
+
typeof minTurnIndex !== "number" ||
|
|
119
|
+
!Number.isFinite(minTurnIndex)) {
|
|
120
|
+
return filteredImages;
|
|
121
|
+
}
|
|
122
|
+
const [fallbackImages, fallbackSnapshot] = await Promise.all([
|
|
123
|
+
readAssistantGeneratedImages(Runtime).catch(() => []),
|
|
124
|
+
readAssistantSnapshot(Runtime).catch(() => null),
|
|
125
|
+
]);
|
|
126
|
+
const fallbackTurnIndex = typeof fallbackSnapshot?.turnIndex === "number" ? fallbackSnapshot.turnIndex : null;
|
|
127
|
+
const nearBoundary = fallbackTurnIndex !== null && fallbackTurnIndex + 1 >= Math.floor(minTurnIndex);
|
|
128
|
+
return fallbackImages.length > 0 && nearBoundary ? fallbackImages : [];
|
|
129
|
+
}
|
|
130
|
+
function resolveGeneratedImageWaitTimeoutMs(waitTimeoutMs) {
|
|
131
|
+
const requestedTimeout = typeof waitTimeoutMs === "number" && Number.isFinite(waitTimeoutMs)
|
|
132
|
+
? waitTimeoutMs
|
|
133
|
+
: GENERATED_IMAGE_WAIT_MAX_MS;
|
|
134
|
+
return Math.max(GENERATED_IMAGE_WAIT_MIN_MS, Math.min(requestedTimeout, GENERATED_IMAGE_WAIT_MAX_MS));
|
|
135
|
+
}
|
|
136
|
+
export function resolveGeneratedImageWaitTimeoutMsForTest(waitTimeoutMs) {
|
|
137
|
+
return resolveGeneratedImageWaitTimeoutMs(waitTimeoutMs);
|
|
138
|
+
}
|
|
139
|
+
function contentTypeToExtension(contentType) {
|
|
140
|
+
const value = String(contentType ?? "").toLowerCase();
|
|
141
|
+
if (value.includes("png"))
|
|
142
|
+
return "png";
|
|
143
|
+
if (value.includes("jpeg") || value.includes("jpg"))
|
|
144
|
+
return "jpg";
|
|
145
|
+
if (value.includes("webp"))
|
|
146
|
+
return "webp";
|
|
147
|
+
if (value.includes("gif"))
|
|
148
|
+
return "gif";
|
|
149
|
+
if (value.includes("svg"))
|
|
150
|
+
return "svg";
|
|
151
|
+
return "bin";
|
|
152
|
+
}
|
|
153
|
+
function resolveSiblingImagePath(basePath, index, extension) {
|
|
154
|
+
const ext = path.extname(basePath);
|
|
155
|
+
const dir = path.dirname(basePath);
|
|
156
|
+
const stem = ext ? path.basename(basePath, ext) : path.basename(basePath);
|
|
157
|
+
if (index === 0) {
|
|
158
|
+
return ext ? basePath : path.join(dir, `${stem}.${extension}`);
|
|
159
|
+
}
|
|
160
|
+
const suffix = ext ? `${stem}.${index + 1}${ext}` : `${stem}.${index + 1}.${extension}`;
|
|
161
|
+
return path.join(dir, suffix);
|
|
162
|
+
}
|
|
163
|
+
function sanitizeGeneratedImageStem(value) {
|
|
164
|
+
return value
|
|
165
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
|
166
|
+
.replace(/-+/g, "-")
|
|
167
|
+
.replace(/^-|-$/g, "")
|
|
168
|
+
.slice(0, 48);
|
|
169
|
+
}
|
|
170
|
+
function resolveDefaultGeneratedImagePath(images, sessionId) {
|
|
171
|
+
const primary = images[0];
|
|
172
|
+
const stemSource = primary?.fileId || primary?.alt || primary?.url || `generated-${Date.now().toString(36)}`;
|
|
173
|
+
const stem = sanitizeGeneratedImageStem(stemSource) || `generated-${Date.now().toString(36)}`;
|
|
174
|
+
const baseDir = sessionId
|
|
175
|
+
? resolveSessionArtifactsDir(sessionId)
|
|
176
|
+
: path.join(getOracleHomeDir(), ".temp");
|
|
177
|
+
return path.join(baseDir, `${stem}.png`);
|
|
178
|
+
}
|
|
179
|
+
async function buildCookieHeader(Network) {
|
|
180
|
+
const response = await Network.getCookies({ urls: ["https://chatgpt.com/"] });
|
|
181
|
+
return (response.cookies ?? [])
|
|
182
|
+
.filter((cookie) => cookie.name && typeof cookie.value === "string")
|
|
183
|
+
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
184
|
+
.join("; ");
|
|
185
|
+
}
|
|
186
|
+
export async function saveChatGptGeneratedImages(params) {
|
|
187
|
+
const { Network, images, outputPath, logger } = params;
|
|
188
|
+
if (!images.length)
|
|
189
|
+
return { saved: false, imageCount: 0, savedImages: [], errors: [] };
|
|
190
|
+
const cookieHeader = await buildCookieHeader(Network);
|
|
191
|
+
if (!cookieHeader) {
|
|
192
|
+
return {
|
|
193
|
+
saved: false,
|
|
194
|
+
imageCount: images.length,
|
|
195
|
+
savedImages: [],
|
|
196
|
+
errors: ["Missing ChatGPT cookies for image download."],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const savedImages = [];
|
|
200
|
+
const errors = [];
|
|
201
|
+
await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
202
|
+
for (let index = 0; index < images.length; index += 1) {
|
|
203
|
+
const image = images[index];
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(image.url, {
|
|
206
|
+
headers: {
|
|
207
|
+
cookie: cookieHeader,
|
|
208
|
+
"user-agent": "Mozilla/5.0",
|
|
209
|
+
},
|
|
210
|
+
redirect: "follow",
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`download failed: ${response.status} ${response.statusText}`);
|
|
214
|
+
}
|
|
215
|
+
const contentType = response.headers.get("content-type");
|
|
216
|
+
const extension = contentTypeToExtension(contentType);
|
|
217
|
+
const targetPath = resolveSiblingImagePath(path.resolve(outputPath), index, extension);
|
|
218
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
219
|
+
await fs.writeFile(targetPath, buffer);
|
|
220
|
+
savedImages.push({
|
|
221
|
+
kind: "image",
|
|
222
|
+
path: targetPath,
|
|
223
|
+
label: index === 0 ? "Generated image" : `Generated image ${index + 1}`,
|
|
224
|
+
mimeType: contentType ?? undefined,
|
|
225
|
+
sizeBytes: buffer.length,
|
|
226
|
+
sourceUrl: image.url,
|
|
227
|
+
url: image.url,
|
|
228
|
+
finalUrl: response.url,
|
|
229
|
+
alt: image.alt,
|
|
230
|
+
width: image.width,
|
|
231
|
+
height: image.height,
|
|
232
|
+
fileId: image.fileId,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
237
|
+
errors.push(`${image.fileId ?? image.url}: ${message}`);
|
|
238
|
+
logger?.(`[browser] Failed to save generated image ${index + 1}/${images.length}: ${message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
saved: savedImages.length > 0,
|
|
243
|
+
imageCount: images.length,
|
|
244
|
+
savedImages,
|
|
245
|
+
errors,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export async function collectGeneratedImageArtifacts(params) {
|
|
249
|
+
const explicitTargetPath = params.generateImagePath ?? params.outputPath;
|
|
250
|
+
let generatedImages = await readAssistantGeneratedImagesWithFallback(params.Runtime, params.minTurnIndex ?? undefined);
|
|
251
|
+
let latestAnswerText = params.answerText;
|
|
252
|
+
if (explicitTargetPath && generatedImages.length === 0) {
|
|
253
|
+
const deadline = Date.now() + resolveGeneratedImageWaitTimeoutMs(params.waitTimeoutMs);
|
|
254
|
+
while (Date.now() < deadline) {
|
|
255
|
+
await delay(1500);
|
|
256
|
+
generatedImages = await readAssistantGeneratedImagesWithFallback(params.Runtime, params.minTurnIndex ?? undefined);
|
|
257
|
+
if (generatedImages.length > 0) {
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
const latestSnapshot = await readAssistantSnapshot(params.Runtime, params.minTurnIndex ?? undefined).catch(() => null);
|
|
261
|
+
const snapshotText = typeof latestSnapshot?.text === "string" ? latestSnapshot.text.trim() : "";
|
|
262
|
+
if (snapshotText) {
|
|
263
|
+
latestAnswerText = snapshotText;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const imageCount = generatedImages.length;
|
|
268
|
+
if (explicitTargetPath && imageCount === 0) {
|
|
269
|
+
throw new Error(`No images generated. Response text:\n${latestAnswerText || "(empty response)"}`);
|
|
270
|
+
}
|
|
271
|
+
if (imageCount === 0) {
|
|
272
|
+
return {
|
|
273
|
+
generatedImages,
|
|
274
|
+
savedImages: [],
|
|
275
|
+
imageCount,
|
|
276
|
+
markdownSuffix: "",
|
|
277
|
+
answerText: latestAnswerText,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const targetPath = explicitTargetPath ?? resolveDefaultGeneratedImagePath(generatedImages, params.sessionId);
|
|
281
|
+
if (!explicitTargetPath) {
|
|
282
|
+
params.logger?.(`[browser] Auto-saving generated images to ${targetPath}`);
|
|
283
|
+
}
|
|
284
|
+
const saved = await saveChatGptGeneratedImages({
|
|
285
|
+
Network: params.Network,
|
|
286
|
+
images: generatedImages,
|
|
287
|
+
outputPath: targetPath,
|
|
288
|
+
logger: params.logger,
|
|
289
|
+
});
|
|
290
|
+
if (!saved.saved) {
|
|
291
|
+
const detail = saved.errors.length > 0 ? `\n${saved.errors.join("\n")}` : "";
|
|
292
|
+
if (explicitTargetPath) {
|
|
293
|
+
throw new Error(`No images generated. Response text:\n${latestAnswerText || "(empty response)"}${detail}`);
|
|
294
|
+
}
|
|
295
|
+
params.logger?.(`[browser] Auto-save for generated images failed; returning metadata only.${detail}`);
|
|
296
|
+
return {
|
|
297
|
+
generatedImages,
|
|
298
|
+
savedImages: [],
|
|
299
|
+
imageCount,
|
|
300
|
+
markdownSuffix: `\n\n*Generated ${imageCount} image(s).*`,
|
|
301
|
+
answerText: latestAnswerText,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const primaryPath = saved.savedImages[0]?.path ?? targetPath;
|
|
305
|
+
const suffix = saved.savedImages.length > 1
|
|
306
|
+
? `\n\n*Generated ${saved.imageCount} image(s). Saved ${saved.savedImages.length} file(s) starting at: ${primaryPath}*`
|
|
307
|
+
: `\n\n*Generated ${saved.imageCount} image(s). Saved to: ${primaryPath}*`;
|
|
308
|
+
return {
|
|
309
|
+
generatedImages,
|
|
310
|
+
savedImages: saved.savedImages,
|
|
311
|
+
imageCount: saved.imageCount,
|
|
312
|
+
markdownSuffix: suffix,
|
|
313
|
+
answerText: latestAnswerText,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -123,7 +123,15 @@ export async function connectToChrome(port, logger, host) {
|
|
|
123
123
|
logger("Connected to Chrome DevTools protocol");
|
|
124
124
|
return client;
|
|
125
125
|
}
|
|
126
|
-
export async function connectToRemoteChrome(host, port, logger, targetUrl) {
|
|
126
|
+
export async function connectToRemoteChrome(host, port, logger, targetUrl, browserWSEndpoint, options) {
|
|
127
|
+
if (browserWSEndpoint) {
|
|
128
|
+
return await connectToRemoteChromeTarget(host, port, logger, {
|
|
129
|
+
browserWSEndpoint,
|
|
130
|
+
targetUrl: targetUrl ?? "about:blank",
|
|
131
|
+
closeTargetOnDispose: true,
|
|
132
|
+
approvalWaitMs: options?.approvalWaitMs,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
127
135
|
if (targetUrl) {
|
|
128
136
|
const targetConnection = await connectToNewTarget(host, port, targetUrl, logger, {
|
|
129
137
|
opened: () => `Opened dedicated remote Chrome tab targeting ${targetUrl}`,
|
|
@@ -132,12 +140,24 @@ export async function connectToRemoteChrome(host, port, logger, targetUrl) {
|
|
|
132
140
|
closeFailed: (targetId, message) => `Failed to close unused remote Chrome tab ${targetId}: ${message}`,
|
|
133
141
|
});
|
|
134
142
|
if (targetConnection) {
|
|
135
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
client: targetConnection.client,
|
|
145
|
+
targetId: targetConnection.targetId,
|
|
146
|
+
close: async () => {
|
|
147
|
+
await targetConnection.client.close().catch(() => undefined);
|
|
148
|
+
await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
136
151
|
}
|
|
137
152
|
}
|
|
138
153
|
const fallbackClient = await CDP({ host, port });
|
|
139
154
|
logger(`Connected to remote Chrome DevTools protocol at ${host}:${port}`);
|
|
140
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
client: fallbackClient,
|
|
157
|
+
close: async () => {
|
|
158
|
+
await fallbackClient.close().catch(() => undefined);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
141
161
|
}
|
|
142
162
|
export async function closeRemoteChromeTarget(host, port, targetId, logger) {
|
|
143
163
|
if (!targetId) {
|
|
@@ -154,6 +174,111 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
|
|
|
154
174
|
logger(`Failed to close remote Chrome tab ${targetId}: ${message}`);
|
|
155
175
|
}
|
|
156
176
|
}
|
|
177
|
+
export async function listRemoteChromeTargets(options) {
|
|
178
|
+
if (!options.browserWSEndpoint) {
|
|
179
|
+
const targets = await CDP.List({ host: options.host, port: options.port });
|
|
180
|
+
return targets;
|
|
181
|
+
}
|
|
182
|
+
const browser = await CDP({ target: options.browserWSEndpoint, local: true });
|
|
183
|
+
try {
|
|
184
|
+
const result = await browser.Target.getTargets();
|
|
185
|
+
return (result.targetInfos ?? []).map((target) => ({
|
|
186
|
+
targetId: target.targetId,
|
|
187
|
+
type: target.type,
|
|
188
|
+
url: target.url,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
await browser.close().catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function connectToRemoteChromeTarget(host, port, logger, options) {
|
|
196
|
+
if (!options.browserWSEndpoint) {
|
|
197
|
+
const client = await CDP({ host, port, target: options.targetId });
|
|
198
|
+
return {
|
|
199
|
+
client,
|
|
200
|
+
targetId: options.targetId,
|
|
201
|
+
close: async () => {
|
|
202
|
+
await client.close().catch(() => undefined);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const browser = await connectToBrowserWebSocket(host, port, options.browserWSEndpoint, logger, options.approvalWaitMs);
|
|
207
|
+
let targetId = options.targetId;
|
|
208
|
+
try {
|
|
209
|
+
if (!targetId) {
|
|
210
|
+
const created = await browser.Target.createTarget({
|
|
211
|
+
url: options.targetUrl ?? "about:blank",
|
|
212
|
+
});
|
|
213
|
+
targetId = created.targetId;
|
|
214
|
+
logger(`Opened dedicated remote Chrome tab targeting ${options.targetUrl ?? "about:blank"}`);
|
|
215
|
+
}
|
|
216
|
+
const attached = await browser.Target.attachToTarget({ targetId, flatten: true });
|
|
217
|
+
const client = createSessionBoundChromeClient(browser, attached.sessionId);
|
|
218
|
+
return {
|
|
219
|
+
client,
|
|
220
|
+
targetId,
|
|
221
|
+
browserWSEndpoint: options.browserWSEndpoint,
|
|
222
|
+
close: async () => {
|
|
223
|
+
await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
|
|
224
|
+
if (options.closeTargetOnDispose && targetId) {
|
|
225
|
+
await browser.Target.closeTarget({ targetId }).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
await browser.close().catch(() => undefined);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
await browser.close().catch(() => undefined);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger, approvalWaitMs) {
|
|
237
|
+
if (!approvalWaitMs || approvalWaitMs <= 0) {
|
|
238
|
+
return (await CDP({ target: browserWSEndpoint, local: true }));
|
|
239
|
+
}
|
|
240
|
+
logger(`Waiting for Chrome remote debugging approval for ${host}:${port}...`);
|
|
241
|
+
const deadline = Date.now() + approvalWaitMs;
|
|
242
|
+
let lastApprovalError;
|
|
243
|
+
while (Date.now() < deadline) {
|
|
244
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
245
|
+
try {
|
|
246
|
+
return await Promise.race([
|
|
247
|
+
CDP({ target: browserWSEndpoint, local: true }),
|
|
248
|
+
new Promise((_, reject) => {
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
reject(new Error("__oracle_remote_debugging_approval_timeout__"));
|
|
251
|
+
}, remainingMs);
|
|
252
|
+
}),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof Error &&
|
|
257
|
+
error.message === "__oracle_remote_debugging_approval_timeout__") {
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
if (!isRemoteDebuggingApprovalError(error)) {
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
lastApprovalError = error;
|
|
264
|
+
await delay(Math.min(500, Math.max(0, deadline - Date.now())));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const suffix = lastApprovalError instanceof Error && lastApprovalError.message
|
|
268
|
+
? ` Last Chrome response: ${lastApprovalError.message}`
|
|
269
|
+
: "";
|
|
270
|
+
throw new Error(`Oracle waited ${formatApprovalWait(approvalWaitMs)} for Chrome remote debugging approval at ${host}:${port}. Allow the Chrome prompt or retry after toggling remote debugging.${suffix}`);
|
|
271
|
+
}
|
|
272
|
+
function isRemoteDebuggingApprovalError(error) {
|
|
273
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
274
|
+
return /unexpected server response:\s*403|remote debugging|forbidden/i.test(message);
|
|
275
|
+
}
|
|
276
|
+
function formatApprovalWait(waitMs) {
|
|
277
|
+
if (waitMs % 1000 === 0) {
|
|
278
|
+
return `${waitMs / 1000}s`;
|
|
279
|
+
}
|
|
280
|
+
return `${waitMs}ms`;
|
|
281
|
+
}
|
|
157
282
|
async function connectToNewTarget(host, port, url, logger, messages) {
|
|
158
283
|
try {
|
|
159
284
|
const target = await CDP.New({ host, port, url });
|
|
@@ -182,6 +307,54 @@ async function connectToNewTarget(host, port, url, logger, messages) {
|
|
|
182
307
|
}
|
|
183
308
|
return null;
|
|
184
309
|
}
|
|
310
|
+
function createSessionBoundChromeClient(browser, sessionId) {
|
|
311
|
+
const browserWithEvents = browser;
|
|
312
|
+
const bindDomain = (domainName) => {
|
|
313
|
+
const domain = browser[domainName];
|
|
314
|
+
const eventName = (name) => `${domainName}.${name}.${sessionId}`;
|
|
315
|
+
return new Proxy((domain ?? {}), {
|
|
316
|
+
get(target, prop, receiver) {
|
|
317
|
+
if (prop === "on") {
|
|
318
|
+
return (name, listener) => {
|
|
319
|
+
const domainEvent = target[name];
|
|
320
|
+
if (typeof domainEvent === "function") {
|
|
321
|
+
return domainEvent(sessionId, listener);
|
|
322
|
+
}
|
|
323
|
+
browserWithEvents.on(eventName(name), listener);
|
|
324
|
+
return () => browserWithEvents.removeListener(eventName(name), listener);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (prop === "off" || prop === "removeListener") {
|
|
328
|
+
return (name, listener) => {
|
|
329
|
+
const off = browserWithEvents.off ?? browserWithEvents.removeListener.bind(browserWithEvents);
|
|
330
|
+
off(eventName(name), listener);
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const value = Reflect.get(target, prop, receiver);
|
|
334
|
+
if (typeof value !== "function") {
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
return (...args) => value(...args, sessionId);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
...browser,
|
|
343
|
+
Network: bindDomain("Network"),
|
|
344
|
+
Page: bindDomain("Page"),
|
|
345
|
+
Runtime: bindDomain("Runtime"),
|
|
346
|
+
Input: bindDomain("Input"),
|
|
347
|
+
DOM: bindDomain("DOM"),
|
|
348
|
+
on: browserWithEvents.on.bind(browserWithEvents),
|
|
349
|
+
once: browserWithEvents.once.bind(browserWithEvents),
|
|
350
|
+
off: browserWithEvents.off?.bind(browserWithEvents) ??
|
|
351
|
+
browserWithEvents.removeListener.bind(browserWithEvents),
|
|
352
|
+
removeListener: browserWithEvents.removeListener.bind(browserWithEvents),
|
|
353
|
+
close: async () => {
|
|
354
|
+
await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
185
358
|
export async function connectWithNewTab(port, logger, initialUrl, host, options) {
|
|
186
359
|
const effectiveHost = host ?? "127.0.0.1";
|
|
187
360
|
const url = initialUrl ?? "about:blank";
|
|
@@ -225,6 +398,44 @@ export async function closeTab(port, targetId, logger, host) {
|
|
|
225
398
|
logger(`Failed to close browser tab ${targetId}: ${message}`);
|
|
226
399
|
}
|
|
227
400
|
}
|
|
401
|
+
export async function closeBlankChromeTabs(port, logger, host, options) {
|
|
402
|
+
const effectiveHost = host ?? "127.0.0.1";
|
|
403
|
+
const excluded = new Set([...(options?.excludeTargetIds ?? [])].filter((targetId) => typeof targetId === "string" && targetId.length > 0));
|
|
404
|
+
let targets;
|
|
405
|
+
try {
|
|
406
|
+
targets = (await CDP.List({ host: effectiveHost, port }));
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
410
|
+
logger(`Failed to inspect blank Chrome tabs: ${message}`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let closed = 0;
|
|
414
|
+
for (const target of targets) {
|
|
415
|
+
const targetId = target.targetId ?? target.id;
|
|
416
|
+
if (!targetId || excluded.has(targetId) || !isBlankPageTarget(target)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
await CDP.Close({ host: effectiveHost, port, id: targetId });
|
|
421
|
+
closed += 1;
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
logger(`Failed to close blank Chrome tab ${targetId}: ${message}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (closed > 0) {
|
|
429
|
+
logger(`Closed ${closed} blank Chrome tab${closed === 1 ? "" : "s"}.`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function isBlankPageTarget(target) {
|
|
433
|
+
if (target.type && target.type !== "page") {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
const url = (target.url ?? "").trim().toLowerCase();
|
|
437
|
+
return url === "about:blank" || url === "chrome://newtab/" || url === "chrome://new-tab-page/";
|
|
438
|
+
}
|
|
228
439
|
function buildChromeFlags(headless, debugBindAddress) {
|
|
229
440
|
const flags = [
|
|
230
441
|
"--disable-background-networking",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
|
|
1
|
+
import { CHATGPT_URL, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, } from "./constants.js";
|
|
2
2
|
import { normalizeBrowserModelStrategy } from "./modelStrategy.js";
|
|
3
|
+
import { DEFAULT_MAX_CONCURRENT_CHATGPT_TABS, normalizeMaxConcurrentTabs, } from "./tabLeaseRegistry.js";
|
|
3
4
|
import { isTemporaryChatUrl, normalizeChatgptUrl } from "./utils.js";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -18,6 +19,8 @@ export const DEFAULT_BROWSER_CONFIG = {
|
|
|
18
19
|
chromeProfile: null,
|
|
19
20
|
chromePath: null,
|
|
20
21
|
chromeCookiePath: null,
|
|
22
|
+
attachRunning: false,
|
|
23
|
+
browserTabRef: null,
|
|
21
24
|
url: CHATGPT_URL,
|
|
22
25
|
chatgptUrl: CHATGPT_URL,
|
|
23
26
|
timeoutMs: 1_200_000,
|
|
@@ -27,6 +30,7 @@ export const DEFAULT_BROWSER_CONFIG = {
|
|
|
27
30
|
assistantRecheckTimeoutMs: 120_000,
|
|
28
31
|
reuseChromeWaitMs: 10_000,
|
|
29
32
|
profileLockTimeoutMs: 300_000,
|
|
33
|
+
maxConcurrentTabs: DEFAULT_MAX_CONCURRENT_CHATGPT_TABS,
|
|
30
34
|
autoReattachDelayMs: 0,
|
|
31
35
|
autoReattachIntervalMs: 0,
|
|
32
36
|
autoReattachTimeoutMs: 120_000,
|
|
@@ -43,9 +47,13 @@ export const DEFAULT_BROWSER_CONFIG = {
|
|
|
43
47
|
debug: false,
|
|
44
48
|
allowCookieErrors: false,
|
|
45
49
|
remoteChrome: null,
|
|
50
|
+
remoteChromeBrowserWSEndpoint: null,
|
|
51
|
+
remoteChromeProfileRoot: null,
|
|
46
52
|
manualLogin: false,
|
|
47
53
|
manualLoginProfileDir: null,
|
|
48
54
|
manualLoginCookieSync: false,
|
|
55
|
+
researchMode: "off",
|
|
56
|
+
archiveConversations: "auto",
|
|
49
57
|
};
|
|
50
58
|
export function resolveBrowserConfig(config) {
|
|
51
59
|
const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
|
|
@@ -67,18 +75,22 @@ export function resolveBrowserConfig(config) {
|
|
|
67
75
|
const manualLogin = config?.manualLogin ?? (isWindows ? true : DEFAULT_BROWSER_CONFIG.manualLogin);
|
|
68
76
|
const cookieSyncDefault = isWindows ? false : DEFAULT_BROWSER_CONFIG.cookieSync;
|
|
69
77
|
const resolvedProfileDir = resolveManualLoginProfileDir(config?.manualLoginProfileDir, process.env.ORACLE_BROWSER_PROFILE_DIR);
|
|
78
|
+
const researchMode = normalizeResearchMode(config?.researchMode);
|
|
79
|
+
const archiveConversations = normalizeArchiveMode(config?.archiveConversations);
|
|
80
|
+
const defaultTimeoutMs = researchMode === "deep" ? DEEP_RESEARCH_DEFAULT_TIMEOUT_MS : DEFAULT_BROWSER_CONFIG.timeoutMs;
|
|
70
81
|
return {
|
|
71
82
|
...DEFAULT_BROWSER_CONFIG,
|
|
72
83
|
...config,
|
|
73
84
|
url: normalizedUrl,
|
|
74
85
|
chatgptUrl: normalizedUrl,
|
|
75
|
-
timeoutMs: config?.timeoutMs ??
|
|
86
|
+
timeoutMs: config?.timeoutMs ?? defaultTimeoutMs,
|
|
76
87
|
debugPort: config?.debugPort ?? debugPortEnv ?? DEFAULT_BROWSER_CONFIG.debugPort,
|
|
77
88
|
inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
|
|
78
89
|
assistantRecheckDelayMs: config?.assistantRecheckDelayMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckDelayMs,
|
|
79
90
|
assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
|
|
80
91
|
reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
|
|
81
92
|
profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
|
|
93
|
+
maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
|
|
82
94
|
autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
|
|
83
95
|
autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
|
|
84
96
|
autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
|
|
@@ -95,14 +107,26 @@ export function resolveBrowserConfig(config) {
|
|
|
95
107
|
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
|
|
96
108
|
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
97
109
|
chromeCookiePath: config?.chromeCookiePath ?? DEFAULT_BROWSER_CONFIG.chromeCookiePath,
|
|
110
|
+
attachRunning: config?.attachRunning ?? DEFAULT_BROWSER_CONFIG.attachRunning,
|
|
111
|
+
browserTabRef: config?.browserTabRef ?? DEFAULT_BROWSER_CONFIG.browserTabRef,
|
|
98
112
|
debug: config?.debug ?? DEFAULT_BROWSER_CONFIG.debug,
|
|
99
113
|
allowCookieErrors: config?.allowCookieErrors ?? envAllowCookieErrors ?? DEFAULT_BROWSER_CONFIG.allowCookieErrors,
|
|
114
|
+
remoteChromeBrowserWSEndpoint: config?.remoteChromeBrowserWSEndpoint ?? DEFAULT_BROWSER_CONFIG.remoteChromeBrowserWSEndpoint,
|
|
115
|
+
remoteChromeProfileRoot: config?.remoteChromeProfileRoot ?? DEFAULT_BROWSER_CONFIG.remoteChromeProfileRoot,
|
|
100
116
|
thinkingTime: config?.thinkingTime,
|
|
117
|
+
researchMode,
|
|
118
|
+
archiveConversations,
|
|
101
119
|
manualLogin,
|
|
102
120
|
manualLoginProfileDir: manualLogin ? resolvedProfileDir : null,
|
|
103
121
|
manualLoginCookieSync: config?.manualLoginCookieSync ?? DEFAULT_BROWSER_CONFIG.manualLoginCookieSync,
|
|
104
122
|
};
|
|
105
123
|
}
|
|
124
|
+
function normalizeResearchMode(value) {
|
|
125
|
+
return value === "deep" ? "deep" : "off";
|
|
126
|
+
}
|
|
127
|
+
function normalizeArchiveMode(value) {
|
|
128
|
+
return value === "always" || value === "never" ? value : "auto";
|
|
129
|
+
}
|
|
106
130
|
function parseDebugPort(raw) {
|
|
107
131
|
if (!raw)
|
|
108
132
|
return null;
|
|
@@ -9,11 +9,13 @@ export const COOKIE_URLS = [
|
|
|
9
9
|
export const INPUT_SELECTORS = [
|
|
10
10
|
'textarea[data-id="prompt-textarea"]',
|
|
11
11
|
'textarea[placeholder*="Send a message"]',
|
|
12
|
+
'textarea[aria-label="Chat with ChatGPT"]',
|
|
12
13
|
'textarea[aria-label="Message ChatGPT"]',
|
|
13
14
|
"textarea:not([disabled])",
|
|
14
15
|
'textarea[name="prompt-textarea"]',
|
|
15
16
|
"#prompt-textarea",
|
|
16
17
|
".ProseMirror",
|
|
18
|
+
'[contenteditable="true"][role="textbox"]',
|
|
17
19
|
'[contenteditable="true"][data-virtualkeyboard="true"]',
|
|
18
20
|
];
|
|
19
21
|
export const ANSWER_SELECTORS = [
|
|
@@ -73,4 +75,10 @@ export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-butt
|
|
|
73
75
|
export const COMPOSER_MODEL_SIGNAL_SELECTOR = '[data-testid="composer-footer-actions"]';
|
|
74
76
|
export const COPY_BUTTON_SELECTOR = 'button[data-testid="copy-turn-action-button"]';
|
|
75
77
|
// Action buttons that only appear once a turn has finished rendering.
|
|
78
|
+
export const DEEP_RESEARCH_PLUS_BUTTON = '[data-testid="composer-plus-btn"]';
|
|
79
|
+
export const DEEP_RESEARCH_DROPDOWN_ITEM_TEXT = "Deep research";
|
|
80
|
+
export const DEEP_RESEARCH_PILL_LABEL = "Deep research";
|
|
81
|
+
export const DEEP_RESEARCH_POLL_INTERVAL_MS = 5_000;
|
|
82
|
+
export const DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS = 70_000;
|
|
83
|
+
export const DEEP_RESEARCH_DEFAULT_TIMEOUT_MS = 2_400_000;
|
|
76
84
|
export const FINISHED_ACTIONS_SELECTOR = 'button[data-testid="copy-turn-action-button"], button[data-testid="good-response-turn-action-button"], button[data-testid="bad-response-turn-action-button"], button[aria-label="Share"]';
|