@steipete/oracle 0.7.6 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/bin/oracle-cli.js +4 -0
- package/dist/src/browser/actions/assistantResponse.js +437 -84
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1300 -132
- package/dist/src/browser/actions/modelSelection.js +9 -4
- package/dist/src/browser/actions/navigation.js +160 -5
- package/dist/src/browser/actions/promptComposer.js +54 -11
- package/dist/src/browser/actions/remoteFileTransfer.js +5 -156
- package/dist/src/browser/actions/thinkingTime.js +5 -0
- package/dist/src/browser/chromeLifecycle.js +9 -1
- package/dist/src/browser/config.js +11 -3
- package/dist/src/browser/constants.js +4 -1
- package/dist/src/browser/cookies.js +55 -21
- package/dist/src/browser/index.js +342 -69
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/profileState.js +16 -0
- package/dist/src/browser/reattach.js +27 -179
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/browserConfig.js +12 -5
- package/dist/src/cli/browserDefaults.js +12 -0
- package/dist/src/cli/sessionDisplay.js +7 -0
- package/dist/src/gemini-web/executor.js +107 -46
- package/dist/src/oracle/oscProgress.js +7 -0
- package/dist/src/oracle/run.js +23 -32
- package/dist/src/remote/server.js +30 -15
- package/package.json +8 -17
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { CONVERSATION_TURN_SELECTOR } from './constants.js';
|
|
2
|
+
import { delay } from './utils.js';
|
|
3
|
+
import { readAssistantSnapshot } from './pageActions.js';
|
|
4
|
+
export function pickTarget(targets, runtime) {
|
|
5
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
if (runtime.chromeTargetId) {
|
|
9
|
+
const byId = targets.find((t) => t.targetId === runtime.chromeTargetId);
|
|
10
|
+
if (byId)
|
|
11
|
+
return byId;
|
|
12
|
+
}
|
|
13
|
+
if (runtime.tabUrl) {
|
|
14
|
+
const byUrl = targets.find((t) => t.url?.startsWith(runtime.tabUrl)) ||
|
|
15
|
+
targets.find((t) => runtime.tabUrl.startsWith(t.url || ''));
|
|
16
|
+
if (byUrl)
|
|
17
|
+
return byUrl;
|
|
18
|
+
}
|
|
19
|
+
return targets.find((t) => t.type === 'page') ?? targets[0];
|
|
20
|
+
}
|
|
21
|
+
export function extractConversationIdFromUrl(url) {
|
|
22
|
+
if (!url)
|
|
23
|
+
return undefined;
|
|
24
|
+
const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
|
|
25
|
+
return match?.[1];
|
|
26
|
+
}
|
|
27
|
+
export function buildConversationUrl(runtime, baseUrl) {
|
|
28
|
+
if (runtime.tabUrl) {
|
|
29
|
+
if (runtime.tabUrl.includes('/c/')) {
|
|
30
|
+
return runtime.tabUrl;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const conversationId = runtime.conversationId;
|
|
35
|
+
if (!conversationId) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const base = new URL(baseUrl);
|
|
40
|
+
const pathRoot = base.pathname.replace(/\/$/, '');
|
|
41
|
+
const prefix = pathRoot === '/' ? '' : pathRoot;
|
|
42
|
+
return `${base.origin}${prefix}/c/${conversationId}`;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function withTimeout(task, ms, label) {
|
|
49
|
+
let timeoutId;
|
|
50
|
+
const timeout = new Promise((_, reject) => {
|
|
51
|
+
timeoutId = setTimeout(() => reject(new Error(label)), ms);
|
|
52
|
+
});
|
|
53
|
+
return Promise.race([task, timeout]).finally(() => {
|
|
54
|
+
if (timeoutId) {
|
|
55
|
+
clearTimeout(timeoutId);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function openConversationFromSidebar(Runtime, options, attempt = 0) {
|
|
60
|
+
const response = await Runtime.evaluate({
|
|
61
|
+
expression: `(() => {
|
|
62
|
+
const conversationId = ${JSON.stringify(options.conversationId ?? null)};
|
|
63
|
+
const preferProjects = ${JSON.stringify(Boolean(options.preferProjects))};
|
|
64
|
+
const promptPreview = ${JSON.stringify(options.promptPreview ?? null)};
|
|
65
|
+
const attemptIndex = ${Math.max(0, attempt)};
|
|
66
|
+
const promptNeedleFull = promptPreview ? promptPreview.trim().toLowerCase().slice(0, 100) : '';
|
|
67
|
+
const promptNeedleShort = promptNeedleFull.replace(/\\s*\\d{4,}\\s*$/, '').trim();
|
|
68
|
+
const promptNeedles = Array.from(new Set([promptNeedleFull, promptNeedleShort].filter(Boolean)));
|
|
69
|
+
const nav = document.querySelector('nav') || document.querySelector('aside') || document.body;
|
|
70
|
+
if (preferProjects) {
|
|
71
|
+
const projectLink = Array.from(nav.querySelectorAll('a,button'))
|
|
72
|
+
.find((el) => (el.textContent || '').trim().toLowerCase() === 'projects');
|
|
73
|
+
if (projectLink) {
|
|
74
|
+
projectLink.click();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const allElements = Array.from(
|
|
78
|
+
document.querySelectorAll(
|
|
79
|
+
'a,button,[role="link"],[role="button"],[data-href],[data-url],[data-conversation-id],[data-testid*="conversation"],[data-testid*="history"]',
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
const getHref = (el) =>
|
|
83
|
+
el.getAttribute('href') ||
|
|
84
|
+
el.getAttribute('data-href') ||
|
|
85
|
+
el.getAttribute('data-url') ||
|
|
86
|
+
el.dataset?.href ||
|
|
87
|
+
el.dataset?.url ||
|
|
88
|
+
'';
|
|
89
|
+
const toCandidate = (el) => {
|
|
90
|
+
const clickable = el.closest('a,button,[role="link"],[role="button"]') || el;
|
|
91
|
+
const rawText = (el.textContent || clickable.textContent || '').trim();
|
|
92
|
+
return {
|
|
93
|
+
el,
|
|
94
|
+
clickable,
|
|
95
|
+
href: getHref(clickable) || getHref(el),
|
|
96
|
+
conversationId:
|
|
97
|
+
clickable.getAttribute('data-conversation-id') ||
|
|
98
|
+
el.getAttribute('data-conversation-id') ||
|
|
99
|
+
clickable.dataset?.conversationId ||
|
|
100
|
+
el.dataset?.conversationId ||
|
|
101
|
+
'',
|
|
102
|
+
testId: clickable.getAttribute('data-testid') || el.getAttribute('data-testid') || '',
|
|
103
|
+
text: rawText.replace(/\\s+/g, ' ').slice(0, 400),
|
|
104
|
+
inNav: Boolean(clickable.closest('nav,aside')),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
const candidates = allElements.map(toCandidate);
|
|
108
|
+
const mainCandidates = candidates.filter((item) => !item.inNav);
|
|
109
|
+
const navCandidates = candidates.filter((item) => item.inNav);
|
|
110
|
+
const visible = (item) => {
|
|
111
|
+
const rect = item.clickable.getBoundingClientRect();
|
|
112
|
+
return rect.width > 0 && rect.height > 0;
|
|
113
|
+
};
|
|
114
|
+
const pick = (items) => (items.find(visible) || items[0] || null);
|
|
115
|
+
const pickWithAttempt = (items) => {
|
|
116
|
+
if (!items.length) return null;
|
|
117
|
+
const visibleItems = items.filter(visible);
|
|
118
|
+
const pool = visibleItems.length > 0 ? visibleItems : items;
|
|
119
|
+
const index = Math.min(attemptIndex, pool.length - 1);
|
|
120
|
+
return pool[index] ?? null;
|
|
121
|
+
};
|
|
122
|
+
let target = null;
|
|
123
|
+
if (conversationId) {
|
|
124
|
+
const byId = (item) =>
|
|
125
|
+
(item.href && item.href.includes('/c/' + conversationId)) ||
|
|
126
|
+
(item.conversationId && item.conversationId === conversationId);
|
|
127
|
+
target = pick(mainCandidates.filter(byId)) || pick(navCandidates.filter(byId));
|
|
128
|
+
}
|
|
129
|
+
if (!target && promptNeedles.length > 0) {
|
|
130
|
+
const byPrompt = (item) => promptNeedles.some((needle) => item.text && item.text.toLowerCase().includes(needle));
|
|
131
|
+
const sortBySpecificity = (items) =>
|
|
132
|
+
items
|
|
133
|
+
.filter(byPrompt)
|
|
134
|
+
.sort((a, b) => (a.text?.length ?? 0) - (b.text?.length ?? 0));
|
|
135
|
+
target = pickWithAttempt(sortBySpecificity(mainCandidates)) || pickWithAttempt(sortBySpecificity(navCandidates));
|
|
136
|
+
}
|
|
137
|
+
if (!target) {
|
|
138
|
+
const byHref = (item) => item.href && item.href.includes('/c/');
|
|
139
|
+
target = pickWithAttempt(mainCandidates.filter(byHref)) || pickWithAttempt(navCandidates.filter(byHref));
|
|
140
|
+
}
|
|
141
|
+
if (!target) {
|
|
142
|
+
const byTestId = (item) => /conversation|history/i.test(item.testId || '');
|
|
143
|
+
target = pickWithAttempt(mainCandidates.filter(byTestId)) || pickWithAttempt(navCandidates.filter(byTestId));
|
|
144
|
+
}
|
|
145
|
+
if (target) {
|
|
146
|
+
target.clickable.scrollIntoView({ block: 'center' });
|
|
147
|
+
target.clickable.dispatchEvent(
|
|
148
|
+
new MouseEvent('click', { bubbles: true, cancelable: true, view: window }),
|
|
149
|
+
);
|
|
150
|
+
// Fallback: some project-sidebar items don't navigate on click, force the URL.
|
|
151
|
+
if (target.href && target.href.includes('/c/')) {
|
|
152
|
+
const targetUrl = target.href.startsWith('http')
|
|
153
|
+
? target.href
|
|
154
|
+
: new URL(target.href, location.origin).toString();
|
|
155
|
+
if (targetUrl && targetUrl !== location.href) {
|
|
156
|
+
location.href = targetUrl;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
href: target.href || '',
|
|
162
|
+
count: candidates.length,
|
|
163
|
+
scope: target.inNav ? 'nav' : 'main',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, count: candidates.length };
|
|
167
|
+
})()`,
|
|
168
|
+
returnByValue: true,
|
|
169
|
+
});
|
|
170
|
+
return Boolean(response.result?.value?.ok);
|
|
171
|
+
}
|
|
172
|
+
export async function openConversationFromSidebarWithRetry(Runtime, options, timeoutMs) {
|
|
173
|
+
const start = Date.now();
|
|
174
|
+
let attempt = 0;
|
|
175
|
+
while (Date.now() - start < timeoutMs) {
|
|
176
|
+
// Retry because project list can hydrate after initial navigation.
|
|
177
|
+
const opened = await openConversationFromSidebar(Runtime, options, attempt);
|
|
178
|
+
if (opened) {
|
|
179
|
+
if (options.promptPreview) {
|
|
180
|
+
const matched = await waitForPromptPreview(Runtime, options.promptPreview, 10_000);
|
|
181
|
+
if (matched) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
attempt += 1;
|
|
190
|
+
await delay(attempt < 5 ? 250 : 500);
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
export async function waitForPromptPreview(Runtime, promptPreview, timeoutMs) {
|
|
195
|
+
const needleFull = promptPreview.trim().toLowerCase().slice(0, 120);
|
|
196
|
+
const needleShort = needleFull.replace(/\\s*\\d{4,}\\s*$/, '').trim();
|
|
197
|
+
const needles = Array.from(new Set([needleFull, needleShort].filter(Boolean)));
|
|
198
|
+
if (needles.length === 0)
|
|
199
|
+
return false;
|
|
200
|
+
const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
201
|
+
const expression = `(() => {
|
|
202
|
+
const needles = ${JSON.stringify(needles)};
|
|
203
|
+
const root =
|
|
204
|
+
document.querySelector('section[data-testid="screen-threadFlyOut"]') ||
|
|
205
|
+
document.querySelector('[data-testid="chat-thread"]') ||
|
|
206
|
+
document.querySelector('main') ||
|
|
207
|
+
document.querySelector('[role="main"]');
|
|
208
|
+
if (!root) return false;
|
|
209
|
+
const userTurns = Array.from(root.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
|
|
210
|
+
const collectText = (nodes) =>
|
|
211
|
+
nodes
|
|
212
|
+
.map((node) => (node.innerText || node.textContent || ''))
|
|
213
|
+
.join(' ')
|
|
214
|
+
.toLowerCase();
|
|
215
|
+
let text = collectText(userTurns);
|
|
216
|
+
let hasTurns = userTurns.length > 0;
|
|
217
|
+
if (!text) {
|
|
218
|
+
const turns = Array.from(root.querySelectorAll(${selectorLiteral}));
|
|
219
|
+
hasTurns = hasTurns || turns.length > 0;
|
|
220
|
+
text = collectText(turns);
|
|
221
|
+
}
|
|
222
|
+
if (!text) {
|
|
223
|
+
text = (root.innerText || root.textContent || '').toLowerCase();
|
|
224
|
+
}
|
|
225
|
+
return needles.some((needle) => text.includes(needle));
|
|
226
|
+
})()`;
|
|
227
|
+
const start = Date.now();
|
|
228
|
+
while (Date.now() - start < timeoutMs) {
|
|
229
|
+
try {
|
|
230
|
+
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
231
|
+
if (result?.value === true) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
await delay(300);
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
export async function waitForLocationChange(Runtime, timeoutMs) {
|
|
243
|
+
const start = Date.now();
|
|
244
|
+
let lastHref = '';
|
|
245
|
+
while (Date.now() - start < timeoutMs) {
|
|
246
|
+
const { result } = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
|
|
247
|
+
const href = typeof result?.value === 'string' ? result.value : '';
|
|
248
|
+
if (lastHref && href !== lastHref) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
lastHref = href;
|
|
252
|
+
await delay(200);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function readConversationTurnIndex(Runtime, logger) {
|
|
256
|
+
const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
257
|
+
try {
|
|
258
|
+
const { result } = await Runtime.evaluate({
|
|
259
|
+
expression: `document.querySelectorAll(${selectorLiteral}).length`,
|
|
260
|
+
returnByValue: true,
|
|
261
|
+
});
|
|
262
|
+
const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
|
|
263
|
+
if (!Number.isFinite(raw)) {
|
|
264
|
+
throw new Error('Turn count not numeric');
|
|
265
|
+
}
|
|
266
|
+
return Math.max(0, Math.floor(raw) - 1);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (logger?.verbose) {
|
|
270
|
+
logger(`Failed to read conversation turn index: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function normalizeForComparison(text) {
|
|
276
|
+
return String(text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
277
|
+
}
|
|
278
|
+
export function buildPromptEchoMatcher(promptPreview) {
|
|
279
|
+
const normalizedPrompt = normalizeForComparison(promptPreview ?? '');
|
|
280
|
+
if (!normalizedPrompt) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const promptPrefix = normalizedPrompt.length >= 80 ? normalizedPrompt.slice(0, Math.min(200, normalizedPrompt.length)) : '';
|
|
284
|
+
const minFragment = Math.min(40, normalizedPrompt.length);
|
|
285
|
+
return {
|
|
286
|
+
isEcho: (text) => {
|
|
287
|
+
const normalized = normalizeForComparison(text);
|
|
288
|
+
if (!normalized)
|
|
289
|
+
return false;
|
|
290
|
+
if (normalized === normalizedPrompt)
|
|
291
|
+
return true;
|
|
292
|
+
if (promptPrefix.length > 0 && normalized.startsWith(promptPrefix))
|
|
293
|
+
return true;
|
|
294
|
+
if (normalized.length >= minFragment && normalizedPrompt.startsWith(normalized)) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (normalized.includes('…') || normalized.includes('...')) {
|
|
298
|
+
const marker = normalized.includes('…') ? '…' : '...';
|
|
299
|
+
const [prefixRaw, suffixRaw] = normalized.split(marker);
|
|
300
|
+
const prefix = prefixRaw?.trim() ?? '';
|
|
301
|
+
const suffix = suffixRaw?.trim() ?? '';
|
|
302
|
+
if (!prefix && !suffix)
|
|
303
|
+
return false;
|
|
304
|
+
if (prefix && !normalizedPrompt.includes(prefix))
|
|
305
|
+
return false;
|
|
306
|
+
if (suffix && !normalizedPrompt.includes(suffix))
|
|
307
|
+
return false;
|
|
308
|
+
const fragmentLength = prefix.length + suffix.length;
|
|
309
|
+
return fragmentLength >= minFragment;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
export async function recoverPromptEcho(Runtime, answer, matcher, logger, minTurnIndex, timeoutMs) {
|
|
316
|
+
if (!matcher || !matcher.isEcho(answer.text)) {
|
|
317
|
+
return answer;
|
|
318
|
+
}
|
|
319
|
+
logger('Detected prompt echo while reattaching; waiting for assistant response...');
|
|
320
|
+
const deadline = Date.now() + Math.min(timeoutMs, 15_000);
|
|
321
|
+
let bestText = null;
|
|
322
|
+
let stableCount = 0;
|
|
323
|
+
while (Date.now() < deadline) {
|
|
324
|
+
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex ?? undefined).catch(() => null);
|
|
325
|
+
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
326
|
+
if (!text || matcher.isEcho(text)) {
|
|
327
|
+
await delay(300);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!bestText || text.length > bestText.length) {
|
|
331
|
+
bestText = text;
|
|
332
|
+
stableCount = 0;
|
|
333
|
+
}
|
|
334
|
+
else if (text === bestText) {
|
|
335
|
+
stableCount += 1;
|
|
336
|
+
}
|
|
337
|
+
if (stableCount >= 2) {
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
await delay(300);
|
|
341
|
+
}
|
|
342
|
+
if (bestText) {
|
|
343
|
+
logger('Recovered assistant response after prompt echo during reattach');
|
|
344
|
+
return { ...answer, text: bestText };
|
|
345
|
+
}
|
|
346
|
+
return answer;
|
|
347
|
+
}
|
|
348
|
+
export function alignPromptEchoPair(answerText, answerMarkdown, matcher, logger, messages) {
|
|
349
|
+
if (!matcher) {
|
|
350
|
+
return { answerText, answerMarkdown, textEcho: false, markdownEcho: false, isEcho: false };
|
|
351
|
+
}
|
|
352
|
+
let textEcho = matcher.isEcho(answerText);
|
|
353
|
+
let markdownEcho = matcher.isEcho(answerMarkdown);
|
|
354
|
+
if (textEcho && !markdownEcho && answerMarkdown) {
|
|
355
|
+
if (logger && messages?.text) {
|
|
356
|
+
logger(messages.text);
|
|
357
|
+
}
|
|
358
|
+
answerText = answerMarkdown;
|
|
359
|
+
textEcho = false;
|
|
360
|
+
}
|
|
361
|
+
if (markdownEcho && !textEcho && answerText) {
|
|
362
|
+
if (logger && messages?.markdown) {
|
|
363
|
+
logger(messages.markdown);
|
|
364
|
+
}
|
|
365
|
+
answerMarkdown = answerText;
|
|
366
|
+
markdownEcho = false;
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
answerText,
|
|
370
|
+
answerMarkdown,
|
|
371
|
+
textEcho,
|
|
372
|
+
markdownEcho,
|
|
373
|
+
isEcho: textEcho || markdownEcho,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
export function alignPromptEchoMarkdown(answerText, answerMarkdown, matcher, logger) {
|
|
377
|
+
const aligned = alignPromptEchoPair(answerText, answerMarkdown, matcher, logger, {
|
|
378
|
+
text: 'Aligned prompt-echo text to copied markdown during reattach',
|
|
379
|
+
markdown: 'Aligned prompt-echo markdown to response text during reattach',
|
|
380
|
+
});
|
|
381
|
+
return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
|
|
382
|
+
}
|
package/dist/src/browserMode.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
|
|
1
|
+
export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
3
|
+
import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
|
|
4
|
+
import { normalizeBrowserModelStrategy } from '../browser/modelStrategy.js';
|
|
4
5
|
import { getOracleHomeDir } from '../oracleHome.js';
|
|
5
6
|
const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
|
|
6
|
-
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS =
|
|
7
|
+
const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
|
|
7
8
|
const DEFAULT_CHROME_PROFILE = 'Default';
|
|
8
9
|
// Ordered array: most specific models first to ensure correct selection.
|
|
9
10
|
// The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
|
|
@@ -44,14 +45,18 @@ export async function buildBrowserConfig(options) {
|
|
|
44
45
|
const baseModel = options.model.toLowerCase();
|
|
45
46
|
const isChatGptModel = baseModel.startsWith('gpt-') && !baseModel.includes('codex');
|
|
46
47
|
const shouldUseOverride = !isChatGptModel && normalizedOverride.length > 0 && normalizedOverride !== baseModel;
|
|
48
|
+
const modelStrategy = normalizeBrowserModelStrategy(options.browserModelStrategy) ?? DEFAULT_MODEL_STRATEGY;
|
|
47
49
|
const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES);
|
|
48
|
-
|
|
50
|
+
let inline = await resolveInlineCookies({
|
|
49
51
|
inlineArg: options.browserInlineCookies,
|
|
50
52
|
inlineFileArg: options.browserInlineCookiesFile,
|
|
51
53
|
envPayload: process.env.ORACLE_BROWSER_COOKIES_JSON,
|
|
52
54
|
envFile: process.env.ORACLE_BROWSER_COOKIES_FILE,
|
|
53
55
|
cwd: process.cwd(),
|
|
54
56
|
});
|
|
57
|
+
if (inline?.source?.startsWith('home:') && options.browserNoCookieSync !== true) {
|
|
58
|
+
inline = undefined;
|
|
59
|
+
}
|
|
55
60
|
let remoteChrome;
|
|
56
61
|
if (options.remoteChrome) {
|
|
57
62
|
remoteChrome = parseRemoteChromeTarget(options.remoteChrome);
|
|
@@ -63,7 +68,7 @@ export async function buildBrowserConfig(options) {
|
|
|
63
68
|
: shouldUseOverride
|
|
64
69
|
? desiredModelOverride
|
|
65
70
|
: mapModelToBrowserLabel(options.model);
|
|
66
|
-
if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
|
|
71
|
+
if (modelStrategy === 'select' && url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
|
|
67
72
|
throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
|
|
68
73
|
'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
|
|
69
74
|
}
|
|
@@ -83,9 +88,11 @@ export async function buildBrowserConfig(options) {
|
|
|
83
88
|
inlineCookiesSource: inline?.source ?? null,
|
|
84
89
|
headless: undefined, // disable headless; Cloudflare blocks it
|
|
85
90
|
keepBrowser: options.browserKeepBrowser ? true : undefined,
|
|
86
|
-
manualLogin: options.browserManualLogin ?
|
|
91
|
+
manualLogin: options.browserManualLogin === undefined ? undefined : options.browserManualLogin,
|
|
92
|
+
manualLoginProfileDir: options.browserManualLoginProfileDir ?? undefined,
|
|
87
93
|
hideWindow: options.browserHideWindow ? true : undefined,
|
|
88
94
|
desiredModel,
|
|
95
|
+
modelStrategy,
|
|
89
96
|
debug: options.verbose ? true : undefined,
|
|
90
97
|
// Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
|
|
91
98
|
allowCookieErrors: options.browserAllowCookieErrors ?? true,
|
|
@@ -42,4 +42,16 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
|
|
|
42
42
|
if (isUnset('browserKeepBrowser') && browser.keepBrowser !== undefined) {
|
|
43
43
|
options.browserKeepBrowser = browser.keepBrowser;
|
|
44
44
|
}
|
|
45
|
+
if (isUnset('browserModelStrategy') && browser.modelStrategy !== undefined) {
|
|
46
|
+
options.browserModelStrategy = browser.modelStrategy;
|
|
47
|
+
}
|
|
48
|
+
if (isUnset('browserThinkingTime') && browser.thinkingTime !== undefined) {
|
|
49
|
+
options.browserThinkingTime = browser.thinkingTime;
|
|
50
|
+
}
|
|
51
|
+
if (isUnset('browserManualLogin') && browser.manualLogin !== undefined) {
|
|
52
|
+
options.browserManualLogin = browser.manualLogin;
|
|
53
|
+
}
|
|
54
|
+
if (isUnset('browserManualLoginProfileDir') && browser.manualLoginProfileDir !== undefined) {
|
|
55
|
+
options.browserManualLoginProfileDir = browser.manualLoginProfileDir;
|
|
56
|
+
}
|
|
45
57
|
}
|
|
@@ -61,6 +61,13 @@ export async function attachSession(sessionId, options) {
|
|
|
61
61
|
process.exitCode = 1;
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
|
+
if (metadata.mode === 'browser' && metadata.status === 'running' && !metadata.browser?.runtime) {
|
|
65
|
+
await wait(250);
|
|
66
|
+
const refreshed = await sessionStore.readSession(sessionId);
|
|
67
|
+
if (refreshed) {
|
|
68
|
+
metadata = refreshed;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
64
71
|
const normalizedModelFilter = options?.model?.trim().toLowerCase();
|
|
65
72
|
if (normalizedModelFilter) {
|
|
66
73
|
const availableModels = metadata.models?.map((model) => model.model.toLowerCase()) ??
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { getCookies } from '@steipete/sweet-cookie';
|
|
2
3
|
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
|
4
|
+
const GEMINI_COOKIE_NAMES = [
|
|
5
|
+
'__Secure-1PSID',
|
|
6
|
+
'__Secure-1PSIDTS',
|
|
7
|
+
'__Secure-1PSIDCC',
|
|
8
|
+
'__Secure-1PAPISID',
|
|
9
|
+
'NID',
|
|
10
|
+
'AEC',
|
|
11
|
+
'SOCS',
|
|
12
|
+
'__Secure-BUCKET',
|
|
13
|
+
'__Secure-ENID',
|
|
14
|
+
'SID',
|
|
15
|
+
'HSID',
|
|
16
|
+
'SSID',
|
|
17
|
+
'APISID',
|
|
18
|
+
'SAPISID',
|
|
19
|
+
'__Secure-3PSID',
|
|
20
|
+
'__Secure-3PSIDTS',
|
|
21
|
+
'__Secure-3PAPISID',
|
|
22
|
+
'SIDCC',
|
|
23
|
+
];
|
|
24
|
+
const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'];
|
|
3
25
|
function estimateTokenCount(text) {
|
|
4
26
|
return Math.ceil(text.length / 4);
|
|
5
27
|
}
|
|
@@ -30,59 +52,84 @@ function resolveGeminiWebModel(desiredModel, log) {
|
|
|
30
52
|
return 'gemini-3-pro';
|
|
31
53
|
}
|
|
32
54
|
}
|
|
55
|
+
function resolveCookieDomain(cookie) {
|
|
56
|
+
const rawDomain = cookie.domain?.trim();
|
|
57
|
+
if (rawDomain) {
|
|
58
|
+
return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain;
|
|
59
|
+
}
|
|
60
|
+
const rawUrl = cookie.url?.trim();
|
|
61
|
+
if (rawUrl) {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(rawUrl).hostname;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function pickCookieValue(cookies, name) {
|
|
72
|
+
const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string');
|
|
73
|
+
if (matches.length === 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
const preferredDomain = matches.find((cookie) => {
|
|
76
|
+
const domain = resolveCookieDomain(cookie);
|
|
77
|
+
return domain === 'google.com' && (cookie.path ?? '/') === '/';
|
|
78
|
+
});
|
|
79
|
+
const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com'));
|
|
80
|
+
return (preferredDomain ?? googleDomain ?? matches[0])?.value;
|
|
81
|
+
}
|
|
82
|
+
function buildGeminiCookieMap(cookies) {
|
|
83
|
+
const cookieMap = {};
|
|
84
|
+
for (const name of GEMINI_COOKIE_NAMES) {
|
|
85
|
+
const value = pickCookieValue(cookies, name);
|
|
86
|
+
if (value)
|
|
87
|
+
cookieMap[name] = value;
|
|
88
|
+
}
|
|
89
|
+
return cookieMap;
|
|
90
|
+
}
|
|
91
|
+
function hasRequiredGeminiCookies(cookieMap) {
|
|
92
|
+
return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
|
|
93
|
+
}
|
|
94
|
+
async function loadGeminiCookiesFromInline(browserConfig, log) {
|
|
95
|
+
const inline = browserConfig?.inlineCookies;
|
|
96
|
+
if (!inline || inline.length === 0)
|
|
97
|
+
return {};
|
|
98
|
+
const cookieMap = buildGeminiCookieMap(inline.filter((cookie) => Boolean(cookie?.name && typeof cookie.value === 'string')));
|
|
99
|
+
if (Object.keys(cookieMap).length > 0) {
|
|
100
|
+
const source = browserConfig?.inlineCookiesSource ?? 'inline';
|
|
101
|
+
log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.');
|
|
105
|
+
}
|
|
106
|
+
return cookieMap;
|
|
107
|
+
}
|
|
33
108
|
async function loadGeminiCookiesFromChrome(browserConfig, log) {
|
|
34
109
|
try {
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
browserConfig.chromeProfile.trim().length > 0
|
|
40
|
-
? browserConfig.chromeProfile.trim()
|
|
110
|
+
// Learned: Gemini web relies on Google auth cookies in the *browser* profile, not API keys.
|
|
111
|
+
const profileCandidate = browserConfig?.chromeCookiePath ?? browserConfig?.chromeProfile ?? undefined;
|
|
112
|
+
const profile = typeof profileCandidate === 'string' && profileCandidate.trim().length > 0
|
|
113
|
+
? profileCandidate.trim()
|
|
41
114
|
: undefined;
|
|
42
115
|
const sources = [
|
|
43
116
|
'https://gemini.google.com',
|
|
44
117
|
'https://accounts.google.com',
|
|
45
118
|
'https://www.google.com',
|
|
46
119
|
];
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'HSID',
|
|
59
|
-
'SSID',
|
|
60
|
-
'APISID',
|
|
61
|
-
'SAPISID',
|
|
62
|
-
'__Secure-3PSID',
|
|
63
|
-
'__Secure-3PSIDTS',
|
|
64
|
-
'__Secure-3PAPISID',
|
|
65
|
-
'SIDCC',
|
|
66
|
-
];
|
|
67
|
-
const cookieMap = {};
|
|
68
|
-
for (const url of sources) {
|
|
69
|
-
const cookies = (await chromeCookies.getCookiesPromised(url, 'puppeteer', profile));
|
|
70
|
-
for (const name of wantNames) {
|
|
71
|
-
if (cookieMap[name])
|
|
72
|
-
continue;
|
|
73
|
-
const matches = cookies.filter((cookie) => cookie.name === name);
|
|
74
|
-
if (matches.length === 0)
|
|
75
|
-
continue;
|
|
76
|
-
const preferredDomain = matches.find((cookie) => cookie.domain === '.google.com' && (cookie.path ?? '/') === '/');
|
|
77
|
-
const googleDomain = matches.find((cookie) => (cookie.domain ?? '').endsWith('google.com'));
|
|
78
|
-
const value = (preferredDomain ?? googleDomain ?? matches[0])?.value;
|
|
79
|
-
if (value)
|
|
80
|
-
cookieMap[name] = value;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
|
|
84
|
-
return {};
|
|
120
|
+
const { cookies, warnings } = await getCookies({
|
|
121
|
+
url: sources[0],
|
|
122
|
+
origins: sources,
|
|
123
|
+
names: [...GEMINI_COOKIE_NAMES],
|
|
124
|
+
browsers: ['chrome'],
|
|
125
|
+
mode: 'merge',
|
|
126
|
+
chromeProfile: profile,
|
|
127
|
+
timeoutMs: 5_000,
|
|
128
|
+
});
|
|
129
|
+
if (warnings.length && log?.verbose) {
|
|
130
|
+
log(`[gemini-web] Cookie warnings:\n- ${warnings.join('\n- ')}`);
|
|
85
131
|
}
|
|
132
|
+
const cookieMap = buildGeminiCookieMap(cookies);
|
|
86
133
|
log?.(`[gemini-web] Loaded Gemini cookies from Chrome (node): ${Object.keys(cookieMap).length} cookie(s).`);
|
|
87
134
|
return cookieMap;
|
|
88
135
|
}
|
|
@@ -91,13 +138,27 @@ async function loadGeminiCookiesFromChrome(browserConfig, log) {
|
|
|
91
138
|
return {};
|
|
92
139
|
}
|
|
93
140
|
}
|
|
141
|
+
async function loadGeminiCookies(browserConfig, log) {
|
|
142
|
+
const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log);
|
|
143
|
+
const hasInlineRequired = hasRequiredGeminiCookies(inlineMap);
|
|
144
|
+
if (hasInlineRequired && browserConfig?.cookieSync === false) {
|
|
145
|
+
return inlineMap;
|
|
146
|
+
}
|
|
147
|
+
if (browserConfig?.cookieSync === false && !hasInlineRequired) {
|
|
148
|
+
log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.');
|
|
149
|
+
return inlineMap;
|
|
150
|
+
}
|
|
151
|
+
const chromeMap = await loadGeminiCookiesFromChrome(browserConfig, log);
|
|
152
|
+
const merged = { ...chromeMap, ...inlineMap };
|
|
153
|
+
return merged;
|
|
154
|
+
}
|
|
94
155
|
export function createGeminiWebExecutor(geminiOptions) {
|
|
95
156
|
return async (runOptions) => {
|
|
96
157
|
const startTime = Date.now();
|
|
97
158
|
const log = runOptions.log;
|
|
98
159
|
log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
|
|
99
|
-
const cookieMap = await
|
|
100
|
-
if (!cookieMap
|
|
160
|
+
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
|
161
|
+
if (!hasRequiredGeminiCookies(cookieMap)) {
|
|
101
162
|
throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
|
|
102
163
|
}
|
|
103
164
|
const configTimeout = typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from 'osc-progress';
|
|
3
3
|
export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
|
|
4
|
+
if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
4
7
|
return supportsOscProgressShared(env, isTty, {
|
|
5
8
|
disableEnvVar: 'ORACLE_NO_OSC_PROGRESS',
|
|
6
9
|
forceEnvVar: 'ORACLE_FORCE_OSC_PROGRESS',
|
|
7
10
|
});
|
|
8
11
|
}
|
|
9
12
|
export function startOscProgress(options = {}) {
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
if (env.CODEX_MANAGED_BY_NPM === '1' && env.ORACLE_FORCE_OSC_PROGRESS !== '1') {
|
|
15
|
+
return () => { };
|
|
16
|
+
}
|
|
10
17
|
return startOscProgressShared({
|
|
11
18
|
...options,
|
|
12
19
|
// Preserve Oracle's previous default: progress emits to stdout.
|