@indykish/oracle 0.9.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/LICENSE +21 -0
- package/README.md +215 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1252 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1067 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1910 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +485 -0
- package/dist/src/browser/actions/navigation.js +445 -0
- package/dist/src/browser/actions/promptComposer.js +485 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +344 -0
- package/dist/src/browser/config.js +103 -0
- package/dist/src/browser/constants.js +71 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1741 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +43 -0
- package/dist/src/browser/profileState.js +280 -0
- package/dist/src/browser/prompt.js +152 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/reattach.js +186 -0
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +119 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/browserConfig.js +278 -0
- package/dist/src/cli/browserDefaults.js +81 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +11 -0
- package/dist/src/cli/dryRun.js +105 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +17 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +306 -0
- package/dist/src/cli/options.js +281 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +78 -0
- package/dist/src/cli/sessionCommand.js +111 -0
- package/dist/src/cli/sessionDisplay.js +567 -0
- package/dist/src/cli/sessionRunner.js +602 -0
- package/dist/src/cli/sessionTable.js +92 -0
- package/dist/src/cli/tagline.js +258 -0
- package/dist/src/cli/tui/index.js +486 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/client.js +328 -0
- package/dist/src/gemini-web/executor.js +285 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +290 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +105 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +37 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +101 -0
- package/dist/src/oracle/client.js +197 -0
- package/dist/src/oracle/config.js +227 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +378 -0
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +195 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +13 -0
- package/dist/src/oracle/request.js +50 -0
- package/dist/src/oracle/run.js +596 -0
- package/dist/src/oracle/runUtils.js +31 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +533 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +637 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +115 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
4
|
+
const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb';
|
|
5
|
+
const MODEL_HEADERS = {
|
|
6
|
+
'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
|
|
7
|
+
'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
|
|
8
|
+
'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
|
|
9
|
+
};
|
|
10
|
+
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
|
11
|
+
const GEMINI_STREAM_GENERATE_URL = 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate';
|
|
12
|
+
const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload';
|
|
13
|
+
const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz';
|
|
14
|
+
function getNestedValue(value, pathParts, fallback) {
|
|
15
|
+
let current = value;
|
|
16
|
+
for (const part of pathParts) {
|
|
17
|
+
if (current == null)
|
|
18
|
+
return fallback;
|
|
19
|
+
if (typeof part === 'number') {
|
|
20
|
+
if (!Array.isArray(current))
|
|
21
|
+
return fallback;
|
|
22
|
+
current = current[part];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
if (typeof current !== 'object')
|
|
26
|
+
return fallback;
|
|
27
|
+
current = current[part];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return current ?? fallback;
|
|
31
|
+
}
|
|
32
|
+
function buildCookieHeader(cookieMap) {
|
|
33
|
+
return Object.entries(cookieMap)
|
|
34
|
+
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
35
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
36
|
+
.join('; ');
|
|
37
|
+
}
|
|
38
|
+
export async function fetchGeminiAccessToken(cookieMap, signal) {
|
|
39
|
+
const cookieHeader = buildCookieHeader(cookieMap);
|
|
40
|
+
const res = await fetch(GEMINI_APP_URL, {
|
|
41
|
+
redirect: 'follow',
|
|
42
|
+
signal,
|
|
43
|
+
headers: {
|
|
44
|
+
cookie: cookieHeader,
|
|
45
|
+
'user-agent': USER_AGENT,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const html = await res.text();
|
|
49
|
+
const tokens = ['SNlM0e', 'thykhd'];
|
|
50
|
+
for (const key of tokens) {
|
|
51
|
+
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
|
|
52
|
+
if (match?.[1])
|
|
53
|
+
return match[1];
|
|
54
|
+
}
|
|
55
|
+
throw new Error('Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).');
|
|
56
|
+
}
|
|
57
|
+
function trimGeminiJsonEnvelope(text) {
|
|
58
|
+
const start = text.indexOf('[');
|
|
59
|
+
const end = text.lastIndexOf(']');
|
|
60
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
61
|
+
throw new Error('Gemini response did not contain a JSON payload.');
|
|
62
|
+
}
|
|
63
|
+
return text.slice(start, end + 1);
|
|
64
|
+
}
|
|
65
|
+
function extractErrorCode(responseJson) {
|
|
66
|
+
const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
|
|
67
|
+
return typeof code === 'number' && code >= 0 ? code : undefined;
|
|
68
|
+
}
|
|
69
|
+
function extractGgdlUrls(rawText) {
|
|
70
|
+
const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const urls = [];
|
|
73
|
+
for (const match of matches) {
|
|
74
|
+
if (seen.has(match))
|
|
75
|
+
continue;
|
|
76
|
+
seen.add(match);
|
|
77
|
+
urls.push(match);
|
|
78
|
+
}
|
|
79
|
+
return urls;
|
|
80
|
+
}
|
|
81
|
+
function ensureFullSizeImageUrl(url) {
|
|
82
|
+
if (url.includes('=s2048'))
|
|
83
|
+
return url;
|
|
84
|
+
if (url.includes('=s'))
|
|
85
|
+
return url;
|
|
86
|
+
return `${url}=s2048`;
|
|
87
|
+
}
|
|
88
|
+
async function fetchWithCookiePreservingRedirects(url, init, signal, maxRedirects = 10) {
|
|
89
|
+
let current = url;
|
|
90
|
+
for (let i = 0; i <= maxRedirects; i += 1) {
|
|
91
|
+
const res = await fetch(current, { ...init, redirect: 'manual', signal });
|
|
92
|
+
if (res.status >= 300 && res.status < 400) {
|
|
93
|
+
const location = res.headers.get('location');
|
|
94
|
+
if (!location)
|
|
95
|
+
return res;
|
|
96
|
+
current = new URL(location, current).toString();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
return res;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
|
|
102
|
+
}
|
|
103
|
+
async function downloadGeminiImage(url, cookieMap, outputPath, signal) {
|
|
104
|
+
const cookieHeader = buildCookieHeader(cookieMap);
|
|
105
|
+
const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
|
|
106
|
+
headers: {
|
|
107
|
+
cookie: cookieHeader,
|
|
108
|
+
'user-agent': USER_AGENT,
|
|
109
|
+
},
|
|
110
|
+
}, signal);
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
|
|
113
|
+
}
|
|
114
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
115
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
116
|
+
await writeFile(outputPath, data);
|
|
117
|
+
}
|
|
118
|
+
async function uploadGeminiFile(filePath, signal) {
|
|
119
|
+
const absPath = path.resolve(process.cwd(), filePath);
|
|
120
|
+
const data = await readFile(absPath);
|
|
121
|
+
const fileName = path.basename(absPath);
|
|
122
|
+
const form = new FormData();
|
|
123
|
+
form.append('file', new Blob([data]), fileName);
|
|
124
|
+
const res = await fetch(GEMINI_UPLOAD_URL, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
redirect: 'follow',
|
|
127
|
+
signal,
|
|
128
|
+
headers: {
|
|
129
|
+
'push-id': GEMINI_UPLOAD_PUSH_ID,
|
|
130
|
+
'user-agent': USER_AGENT,
|
|
131
|
+
},
|
|
132
|
+
body: form,
|
|
133
|
+
});
|
|
134
|
+
const text = await res.text();
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
|
|
137
|
+
}
|
|
138
|
+
return { id: text, name: fileName };
|
|
139
|
+
}
|
|
140
|
+
function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
|
|
141
|
+
const promptPayload = uploaded.length > 0
|
|
142
|
+
? [
|
|
143
|
+
prompt,
|
|
144
|
+
0,
|
|
145
|
+
null,
|
|
146
|
+
// Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
|
|
147
|
+
// Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
|
|
148
|
+
uploaded.map((file) => [[file.id, 1]]),
|
|
149
|
+
]
|
|
150
|
+
: [prompt];
|
|
151
|
+
const innerList = [promptPayload, null, chatMetadata ?? null];
|
|
152
|
+
return JSON.stringify([null, JSON.stringify(innerList)]);
|
|
153
|
+
}
|
|
154
|
+
export function parseGeminiStreamGenerateResponse(rawText) {
|
|
155
|
+
const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
|
|
156
|
+
const errorCode = extractErrorCode(responseJson);
|
|
157
|
+
const parts = Array.isArray(responseJson) ? responseJson : [];
|
|
158
|
+
let bodyIndex = 0;
|
|
159
|
+
let body = null;
|
|
160
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
161
|
+
const partBody = getNestedValue(parts[i], [2], null);
|
|
162
|
+
if (!partBody)
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(partBody);
|
|
166
|
+
const candidateList = getNestedValue(parsed, [4], []);
|
|
167
|
+
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
|
168
|
+
bodyIndex = i;
|
|
169
|
+
body = parsed;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// ignore
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const candidateList = getNestedValue(body, [4], []);
|
|
178
|
+
const firstCandidate = candidateList[0];
|
|
179
|
+
const textRaw = getNestedValue(firstCandidate, [1, 0], '');
|
|
180
|
+
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
|
181
|
+
const text = cardContent
|
|
182
|
+
? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
|
|
183
|
+
: textRaw;
|
|
184
|
+
const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
|
|
185
|
+
const metadata = getNestedValue(body, [1], []);
|
|
186
|
+
const images = [];
|
|
187
|
+
const webImages = getNestedValue(firstCandidate, [12, 1], []);
|
|
188
|
+
for (const webImage of webImages) {
|
|
189
|
+
const url = getNestedValue(webImage, [0, 0, 0], null);
|
|
190
|
+
if (!url)
|
|
191
|
+
continue;
|
|
192
|
+
images.push({
|
|
193
|
+
kind: 'web',
|
|
194
|
+
url,
|
|
195
|
+
title: getNestedValue(webImage, [7, 0], undefined),
|
|
196
|
+
alt: getNestedValue(webImage, [0, 4], undefined),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
|
|
200
|
+
if (hasGenerated) {
|
|
201
|
+
let imgBody = null;
|
|
202
|
+
for (let i = bodyIndex; i < parts.length; i += 1) {
|
|
203
|
+
const partBody = getNestedValue(parts[i], [2], null);
|
|
204
|
+
if (!partBody)
|
|
205
|
+
continue;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(partBody);
|
|
208
|
+
const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
|
|
209
|
+
if (candidateImages != null) {
|
|
210
|
+
imgBody = parsed;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
|
|
219
|
+
const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
|
|
220
|
+
for (const genImage of generated) {
|
|
221
|
+
const url = getNestedValue(genImage, [0, 3, 3], null);
|
|
222
|
+
if (!url)
|
|
223
|
+
continue;
|
|
224
|
+
images.push({
|
|
225
|
+
kind: 'generated',
|
|
226
|
+
url,
|
|
227
|
+
title: '[Generated Image]',
|
|
228
|
+
alt: '',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { metadata, text, thoughts, images, errorCode };
|
|
233
|
+
}
|
|
234
|
+
export function isGeminiModelUnavailable(errorCode) {
|
|
235
|
+
return errorCode === 1052;
|
|
236
|
+
}
|
|
237
|
+
export async function runGeminiWebOnce(input) {
|
|
238
|
+
const cookieHeader = buildCookieHeader(input.cookieMap);
|
|
239
|
+
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
|
240
|
+
const uploaded = [];
|
|
241
|
+
for (const file of input.files ?? []) {
|
|
242
|
+
if (input.signal?.aborted) {
|
|
243
|
+
throw new Error('Gemini web run aborted before upload.');
|
|
244
|
+
}
|
|
245
|
+
uploaded.push(await uploadGeminiFile(file, input.signal));
|
|
246
|
+
}
|
|
247
|
+
const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
|
|
248
|
+
const params = new URLSearchParams();
|
|
249
|
+
params.set('at', at);
|
|
250
|
+
params.set('f.req', fReq);
|
|
251
|
+
const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
redirect: 'follow',
|
|
254
|
+
signal: input.signal,
|
|
255
|
+
headers: {
|
|
256
|
+
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
|
|
257
|
+
origin: 'https://gemini.google.com',
|
|
258
|
+
referer: 'https://gemini.google.com/',
|
|
259
|
+
'x-same-domain': '1',
|
|
260
|
+
'user-agent': USER_AGENT,
|
|
261
|
+
cookie: cookieHeader,
|
|
262
|
+
[MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
|
|
263
|
+
},
|
|
264
|
+
body: params.toString(),
|
|
265
|
+
});
|
|
266
|
+
const rawResponseText = await res.text();
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
return {
|
|
269
|
+
rawResponseText,
|
|
270
|
+
text: '',
|
|
271
|
+
thoughts: null,
|
|
272
|
+
metadata: input.chatMetadata ?? null,
|
|
273
|
+
images: [],
|
|
274
|
+
errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
|
|
279
|
+
return {
|
|
280
|
+
rawResponseText,
|
|
281
|
+
text: parsed.text ?? '',
|
|
282
|
+
thoughts: parsed.thoughts,
|
|
283
|
+
metadata: parsed.metadata,
|
|
284
|
+
images: parsed.images,
|
|
285
|
+
errorCode: parsed.errorCode,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
let responseJson = null;
|
|
290
|
+
try {
|
|
291
|
+
responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
responseJson = null;
|
|
295
|
+
}
|
|
296
|
+
const errorCode = extractErrorCode(responseJson);
|
|
297
|
+
return {
|
|
298
|
+
rawResponseText,
|
|
299
|
+
text: '',
|
|
300
|
+
thoughts: null,
|
|
301
|
+
metadata: input.chatMetadata ?? null,
|
|
302
|
+
images: [],
|
|
303
|
+
errorCode: typeof errorCode === 'number' ? errorCode : undefined,
|
|
304
|
+
errorMessage: error instanceof Error ? error.message : String(error ?? ''),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export async function runGeminiWebWithFallback(input) {
|
|
309
|
+
const attempt = await runGeminiWebOnce(input);
|
|
310
|
+
if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
|
|
311
|
+
const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
|
|
312
|
+
return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
|
|
313
|
+
}
|
|
314
|
+
return { ...attempt, effectiveModel: input.model };
|
|
315
|
+
}
|
|
316
|
+
export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath, signal) {
|
|
317
|
+
const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
|
|
318
|
+
if (generatedOrWeb?.url) {
|
|
319
|
+
await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
|
|
320
|
+
return { saved: true, imageCount: output.images.length };
|
|
321
|
+
}
|
|
322
|
+
const ggdl = extractGgdlUrls(output.rawResponseText);
|
|
323
|
+
if (ggdl[0]) {
|
|
324
|
+
await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
|
|
325
|
+
return { saved: true, imageCount: ggdl.length };
|
|
326
|
+
}
|
|
327
|
+
return { saved: false, imageCount: 0 };
|
|
328
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getCookies } from '@steipete/sweet-cookie';
|
|
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'];
|
|
25
|
+
function estimateTokenCount(text) {
|
|
26
|
+
return Math.ceil(text.length / 4);
|
|
27
|
+
}
|
|
28
|
+
function resolveInvocationPath(value) {
|
|
29
|
+
if (!value)
|
|
30
|
+
return undefined;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return undefined;
|
|
34
|
+
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
|
35
|
+
}
|
|
36
|
+
function resolveGeminiWebModel(desiredModel, log) {
|
|
37
|
+
const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
|
|
38
|
+
if (!desired)
|
|
39
|
+
return 'gemini-3-pro';
|
|
40
|
+
switch (desired) {
|
|
41
|
+
case 'gemini-3-pro':
|
|
42
|
+
case 'gemini-3.0-pro':
|
|
43
|
+
return 'gemini-3-pro';
|
|
44
|
+
case 'gemini-2.5-pro':
|
|
45
|
+
return 'gemini-2.5-pro';
|
|
46
|
+
case 'gemini-2.5-flash':
|
|
47
|
+
return 'gemini-2.5-flash';
|
|
48
|
+
default:
|
|
49
|
+
if (desired.startsWith('gemini-')) {
|
|
50
|
+
log?.(`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`);
|
|
51
|
+
}
|
|
52
|
+
return 'gemini-3-pro';
|
|
53
|
+
}
|
|
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
|
+
}
|
|
108
|
+
async function loadGeminiCookiesFromChrome(browserConfig, log) {
|
|
109
|
+
try {
|
|
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()
|
|
114
|
+
: undefined;
|
|
115
|
+
const sources = [
|
|
116
|
+
'https://gemini.google.com',
|
|
117
|
+
'https://accounts.google.com',
|
|
118
|
+
'https://www.google.com',
|
|
119
|
+
];
|
|
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- ')}`);
|
|
131
|
+
}
|
|
132
|
+
const cookieMap = buildGeminiCookieMap(cookies);
|
|
133
|
+
log?.(`[gemini-web] Loaded Gemini cookies from Chrome (node): ${Object.keys(cookieMap).length} cookie(s).`);
|
|
134
|
+
return cookieMap;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
log?.(`[gemini-web] Failed to load Chrome cookies via node: ${error instanceof Error ? error.message : String(error ?? '')}`);
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
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
|
+
}
|
|
155
|
+
export function createGeminiWebExecutor(geminiOptions) {
|
|
156
|
+
return async (runOptions) => {
|
|
157
|
+
const startTime = Date.now();
|
|
158
|
+
const log = runOptions.log;
|
|
159
|
+
log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
|
|
160
|
+
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
|
161
|
+
if (!hasRequiredGeminiCookies(cookieMap)) {
|
|
162
|
+
throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
|
|
163
|
+
}
|
|
164
|
+
const configTimeout = typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
|
|
165
|
+
? Math.max(1_000, runOptions.config.timeoutMs)
|
|
166
|
+
: null;
|
|
167
|
+
const defaultTimeoutMs = geminiOptions.youtube
|
|
168
|
+
? 240_000
|
|
169
|
+
: geminiOptions.generateImage || geminiOptions.editImage
|
|
170
|
+
? 300_000
|
|
171
|
+
: 120_000;
|
|
172
|
+
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000);
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
175
|
+
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
|
176
|
+
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
|
177
|
+
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
|
178
|
+
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
|
179
|
+
let prompt = runOptions.prompt;
|
|
180
|
+
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
|
|
181
|
+
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
|
182
|
+
}
|
|
183
|
+
if (geminiOptions.youtube) {
|
|
184
|
+
prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
|
|
185
|
+
}
|
|
186
|
+
if (generateImagePath && !editImagePath) {
|
|
187
|
+
prompt = `Generate an image: ${prompt}`;
|
|
188
|
+
}
|
|
189
|
+
const model = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
if (editImagePath) {
|
|
193
|
+
const intro = await runGeminiWebWithFallback({
|
|
194
|
+
prompt: 'Here is an image to edit',
|
|
195
|
+
files: [editImagePath],
|
|
196
|
+
model,
|
|
197
|
+
cookieMap,
|
|
198
|
+
chatMetadata: null,
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
});
|
|
201
|
+
const editPrompt = `Use image generation tool to ${prompt}`;
|
|
202
|
+
const out = await runGeminiWebWithFallback({
|
|
203
|
+
prompt: editPrompt,
|
|
204
|
+
files: attachmentPaths,
|
|
205
|
+
model,
|
|
206
|
+
cookieMap,
|
|
207
|
+
chatMetadata: intro.metadata,
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
});
|
|
210
|
+
response = {
|
|
211
|
+
text: out.text ?? null,
|
|
212
|
+
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
|
213
|
+
has_images: false,
|
|
214
|
+
image_count: 0,
|
|
215
|
+
};
|
|
216
|
+
const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
|
|
217
|
+
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
|
218
|
+
response.has_images = imageSave.saved;
|
|
219
|
+
response.image_count = imageSave.imageCount;
|
|
220
|
+
if (!imageSave.saved) {
|
|
221
|
+
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else if (generateImagePath) {
|
|
225
|
+
const out = await runGeminiWebWithFallback({
|
|
226
|
+
prompt,
|
|
227
|
+
files: attachmentPaths,
|
|
228
|
+
model,
|
|
229
|
+
cookieMap,
|
|
230
|
+
chatMetadata: null,
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
});
|
|
233
|
+
response = {
|
|
234
|
+
text: out.text ?? null,
|
|
235
|
+
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
|
236
|
+
has_images: false,
|
|
237
|
+
image_count: 0,
|
|
238
|
+
};
|
|
239
|
+
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal);
|
|
240
|
+
response.has_images = imageSave.saved;
|
|
241
|
+
response.image_count = imageSave.imageCount;
|
|
242
|
+
if (!imageSave.saved) {
|
|
243
|
+
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const out = await runGeminiWebWithFallback({
|
|
248
|
+
prompt,
|
|
249
|
+
files: attachmentPaths,
|
|
250
|
+
model,
|
|
251
|
+
cookieMap,
|
|
252
|
+
chatMetadata: null,
|
|
253
|
+
signal: controller.signal,
|
|
254
|
+
});
|
|
255
|
+
response = {
|
|
256
|
+
text: out.text ?? null,
|
|
257
|
+
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
|
258
|
+
has_images: out.images.length > 0,
|
|
259
|
+
image_count: out.images.length,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
clearTimeout(timeout);
|
|
265
|
+
}
|
|
266
|
+
const answerText = response.text ?? '';
|
|
267
|
+
let answerMarkdown = answerText;
|
|
268
|
+
if (geminiOptions.showThoughts && response.thoughts) {
|
|
269
|
+
answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
|
|
270
|
+
}
|
|
271
|
+
if (response.has_images && response.image_count > 0) {
|
|
272
|
+
const imagePath = generateImagePath || outputPath || 'generated.png';
|
|
273
|
+
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
|
274
|
+
}
|
|
275
|
+
const tookMs = Date.now() - startTime;
|
|
276
|
+
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
|
277
|
+
return {
|
|
278
|
+
answerText,
|
|
279
|
+
answerMarkdown,
|
|
280
|
+
tookMs,
|
|
281
|
+
answerTokens: estimateTokenCount(answerText),
|
|
282
|
+
answerChars: answerText.length,
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createGeminiWebExecutor } from './executor.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function startHeartbeat(config) {
|
|
2
|
+
const { intervalMs, log, isActive, makeMessage } = config;
|
|
3
|
+
if (!intervalMs || intervalMs <= 0) {
|
|
4
|
+
return () => { };
|
|
5
|
+
}
|
|
6
|
+
let stopped = false;
|
|
7
|
+
let pending = false;
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const timer = setInterval(async () => {
|
|
10
|
+
// stop flag flips asynchronously
|
|
11
|
+
if (stopped || pending) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!isActive()) {
|
|
15
|
+
stop();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
pending = true;
|
|
19
|
+
try {
|
|
20
|
+
const elapsed = Date.now() - start;
|
|
21
|
+
const message = await makeMessage(elapsed);
|
|
22
|
+
if (message && !stopped) {
|
|
23
|
+
log(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore heartbeat errors
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
pending = false;
|
|
31
|
+
}
|
|
32
|
+
}, intervalMs);
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
const stop = () => {
|
|
35
|
+
// multiple callers may race to stop
|
|
36
|
+
if (stopped) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
stopped = true;
|
|
40
|
+
clearInterval(timer);
|
|
41
|
+
};
|
|
42
|
+
return stop;
|
|
43
|
+
}
|