@steipete/oracle 0.6.0 → 0.7.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.
Files changed (25) hide show
  1. package/README.md +16 -8
  2. package/dist/bin/oracle-cli.js +33 -13
  3. package/dist/src/browser/actions/assistantResponse.js +65 -6
  4. package/dist/src/browser/constants.js +1 -1
  5. package/dist/src/browser/index.js +22 -50
  6. package/dist/src/browser/profileState.js +171 -0
  7. package/dist/src/browser/prompt.js +30 -6
  8. package/dist/src/browser/sessionRunner.js +0 -5
  9. package/dist/src/cli/runOptions.js +6 -7
  10. package/dist/src/cli/sessionDisplay.js +8 -1
  11. package/dist/src/cli/sessionRunner.js +0 -8
  12. package/dist/src/gemini-web/client.js +322 -0
  13. package/dist/src/gemini-web/executor.js +204 -0
  14. package/dist/src/gemini-web/index.js +1 -0
  15. package/dist/src/gemini-web/types.js +1 -0
  16. package/dist/src/remote/server.js +17 -11
  17. package/package.json +2 -2
  18. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  19. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  20. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  21. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  22. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  23. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +0 -45
  24. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +0 -24
  25. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -93
@@ -13,7 +13,6 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
13
13
  const resolvedModel = resolvedEngine === 'browser' && normalizedRequestedModels.length === 0
14
14
  ? inferModelFromLabel(cliModelArg)
15
15
  : resolveApiModel(cliModelArg);
16
- const isGemini = resolvedModel.startsWith('gemini');
17
16
  const isCodex = resolvedModel.startsWith('gpt-5.1-codex');
18
17
  const isClaude = resolvedModel.startsWith('claude');
19
18
  const isGrok = resolvedModel.startsWith('grok');
@@ -21,13 +20,13 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
21
20
  const allModels = normalizedRequestedModels.length > 0
22
21
  ? Array.from(new Set(normalizedRequestedModels.map((entry) => resolveApiModel(entry))))
23
22
  : [resolvedModel];
24
- const hasNonGptBrowserTarget = (browserRequested || browserConfigured) && allModels.some((m) => !m.startsWith('gpt-'));
25
- if (hasNonGptBrowserTarget) {
26
- throw new PromptValidationError('Browser engine only supports GPT-series ChatGPT models. Re-run with --engine api for Grok, Claude, Gemini, or other non-GPT models.', { engine: 'browser', models: allModels });
23
+ const isBrowserCompatible = (m) => m.startsWith('gpt-') || m.startsWith('gemini');
24
+ const hasNonBrowserCompatibleTarget = (browserRequested || browserConfigured) && allModels.some((m) => !isBrowserCompatible(m));
25
+ if (hasNonBrowserCompatibleTarget) {
26
+ throw new PromptValidationError('Browser engine only supports GPT and Gemini models. Re-run with --engine api for Grok, Claude, or other models.', { engine: 'browser', models: allModels });
27
27
  }
28
- const engineCoercedToApi = engineWasBrowser && (isGemini || isCodex || isClaude || isGrok);
29
- // When Gemini, Claude, or Grok is selected, force API engine for auto-browser detection; codex also forces API.
30
- const fixedEngine = isGemini || isCodex || isClaude || isGrok || normalizedRequestedModels.length > 0 ? 'api' : resolvedEngine;
28
+ const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok);
29
+ const fixedEngine = isCodex || isClaude || isGrok || normalizedRequestedModels.length > 0 ? 'api' : resolvedEngine;
31
30
  const promptWithSuffix = userConfig?.promptSuffix && userConfig.promptSuffix.trim().length > 0
32
31
  ? `${prompt.trim()}\n${userConfig.promptSuffix}`
33
32
  : prompt;
@@ -18,7 +18,14 @@ function isProcessAlive(pid) {
18
18
  return true;
19
19
  }
20
20
  catch (error) {
21
- return !(error instanceof Error && error.code === 'ESRCH');
21
+ const code = error instanceof Error ? error.code : undefined;
22
+ if (code === 'ESRCH' || code === 'EINVAL') {
23
+ return false;
24
+ }
25
+ if (code === 'EPERM') {
26
+ return true;
27
+ }
28
+ return true;
22
29
  }
23
30
  }
24
31
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
@@ -38,9 +38,6 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
38
38
  const modelForStatus = runOptions.model ?? sessionMeta.model;
39
39
  try {
40
40
  if (mode === 'browser') {
41
- if (runOptions.model.startsWith('gemini')) {
42
- throw new Error('Gemini models are not available in browser mode. Re-run with --engine api.');
43
- }
44
41
  if (!browserConfig) {
45
42
  throw new Error('Missing browser configuration for session.');
46
43
  }
@@ -360,11 +357,6 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
360
357
  }
361
358
  : undefined,
362
359
  });
363
- if (mode === 'browser') {
364
- log(dim('Next steps (browser fallback):')); // guides users when automation breaks
365
- log(dim('- Rerun with --engine api to bypass Chrome entirely.'));
366
- log(dim('- Or rerun with --engine api --render-markdown [--file …] to generate a single markdown bundle you can paste into ChatGPT manually (add --browser-bundle-files if you still want attachments).'));
367
- }
368
360
  if (modelForStatus) {
369
361
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
370
362
  status: 'error',
@@ -0,0 +1,322 @@
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) {
39
+ const cookieHeader = buildCookieHeader(cookieMap);
40
+ const res = await fetch(GEMINI_APP_URL, {
41
+ redirect: 'follow',
42
+ headers: {
43
+ cookie: cookieHeader,
44
+ 'user-agent': USER_AGENT,
45
+ },
46
+ });
47
+ const html = await res.text();
48
+ const tokens = ['SNlM0e', 'thykhd'];
49
+ for (const key of tokens) {
50
+ const match = html.match(new RegExp(`"${key}":"(.*?)"`));
51
+ if (match?.[1])
52
+ return match[1];
53
+ }
54
+ throw new Error('Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).');
55
+ }
56
+ function trimGeminiJsonEnvelope(text) {
57
+ const start = text.indexOf('[');
58
+ const end = text.lastIndexOf(']');
59
+ if (start === -1 || end === -1 || end <= start) {
60
+ throw new Error('Gemini response did not contain a JSON payload.');
61
+ }
62
+ return text.slice(start, end + 1);
63
+ }
64
+ function extractErrorCode(responseJson) {
65
+ const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0], -1);
66
+ return typeof code === 'number' && code >= 0 ? code : undefined;
67
+ }
68
+ function extractGgdlUrls(rawText) {
69
+ const matches = rawText.match(/https:\/\/lh3\.googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
70
+ const seen = new Set();
71
+ const urls = [];
72
+ for (const match of matches) {
73
+ if (seen.has(match))
74
+ continue;
75
+ seen.add(match);
76
+ urls.push(match);
77
+ }
78
+ return urls;
79
+ }
80
+ function ensureFullSizeImageUrl(url) {
81
+ if (url.includes('=s2048'))
82
+ return url;
83
+ if (url.includes('=s'))
84
+ return url;
85
+ return `${url}=s2048`;
86
+ }
87
+ async function fetchWithCookiePreservingRedirects(url, init, maxRedirects = 10) {
88
+ let current = url;
89
+ for (let i = 0; i <= maxRedirects; i += 1) {
90
+ const res = await fetch(current, { ...init, redirect: 'manual' });
91
+ if (res.status >= 300 && res.status < 400) {
92
+ const location = res.headers.get('location');
93
+ if (!location)
94
+ return res;
95
+ current = new URL(location, current).toString();
96
+ continue;
97
+ }
98
+ return res;
99
+ }
100
+ throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
101
+ }
102
+ async function downloadGeminiImage(url, cookieMap, outputPath) {
103
+ const cookieHeader = buildCookieHeader(cookieMap);
104
+ const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
105
+ headers: {
106
+ cookie: cookieHeader,
107
+ 'user-agent': USER_AGENT,
108
+ },
109
+ });
110
+ if (!res.ok) {
111
+ throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
112
+ }
113
+ const data = new Uint8Array(await res.arrayBuffer());
114
+ await mkdir(path.dirname(outputPath), { recursive: true });
115
+ await writeFile(outputPath, data);
116
+ }
117
+ async function uploadGeminiFile(filePath) {
118
+ const absPath = path.resolve(process.cwd(), filePath);
119
+ const data = await readFile(absPath);
120
+ const fileName = path.basename(absPath);
121
+ const form = new FormData();
122
+ form.append('file', new Blob([data]), fileName);
123
+ const res = await fetch(GEMINI_UPLOAD_URL, {
124
+ method: 'POST',
125
+ redirect: 'follow',
126
+ headers: {
127
+ 'push-id': GEMINI_UPLOAD_PUSH_ID,
128
+ 'user-agent': USER_AGENT,
129
+ },
130
+ body: form,
131
+ });
132
+ const text = await res.text();
133
+ if (!res.ok) {
134
+ throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
135
+ }
136
+ return { id: text, name: fileName };
137
+ }
138
+ function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
139
+ const promptPayload = uploaded.length > 0
140
+ ? [
141
+ prompt,
142
+ 0,
143
+ null,
144
+ // Matches gemini-webapi payload format: [[[fileId, 1]]] for a single attachment.
145
+ // Keep it extensible for multiple uploads by emitting one [[id, 1]] entry per file.
146
+ uploaded.map((file) => [[file.id, 1]]),
147
+ ]
148
+ : [prompt];
149
+ const innerList = [promptPayload, null, chatMetadata ?? null];
150
+ return JSON.stringify([null, JSON.stringify(innerList)]);
151
+ }
152
+ export function parseGeminiStreamGenerateResponse(rawText) {
153
+ const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText));
154
+ const errorCode = extractErrorCode(responseJson);
155
+ const parts = Array.isArray(responseJson) ? responseJson : [];
156
+ let bodyIndex = 0;
157
+ let body = null;
158
+ for (let i = 0; i < parts.length; i += 1) {
159
+ const partBody = getNestedValue(parts[i], [2], null);
160
+ if (!partBody)
161
+ continue;
162
+ try {
163
+ const parsed = JSON.parse(partBody);
164
+ const candidateList = getNestedValue(parsed, [4], []);
165
+ if (Array.isArray(candidateList) && candidateList.length > 0) {
166
+ bodyIndex = i;
167
+ body = parsed;
168
+ break;
169
+ }
170
+ }
171
+ catch {
172
+ // ignore
173
+ }
174
+ }
175
+ const candidateList = getNestedValue(body, [4], []);
176
+ const firstCandidate = candidateList[0];
177
+ const textRaw = getNestedValue(firstCandidate, [1, 0], '');
178
+ const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
179
+ const text = cardContent
180
+ ? (getNestedValue(firstCandidate, [22, 0], null) ?? textRaw)
181
+ : textRaw;
182
+ const thoughts = getNestedValue(firstCandidate, [37, 0, 0], null);
183
+ const metadata = getNestedValue(body, [1], []);
184
+ const images = [];
185
+ const webImages = getNestedValue(firstCandidate, [12, 1], []);
186
+ for (const webImage of webImages) {
187
+ const url = getNestedValue(webImage, [0, 0, 0], null);
188
+ if (!url)
189
+ continue;
190
+ images.push({
191
+ kind: 'web',
192
+ url,
193
+ title: getNestedValue(webImage, [7, 0], undefined),
194
+ alt: getNestedValue(webImage, [0, 4], undefined),
195
+ });
196
+ }
197
+ const hasGenerated = Boolean(getNestedValue(firstCandidate, [12, 7, 0], null));
198
+ if (hasGenerated) {
199
+ let imgBody = null;
200
+ for (let i = bodyIndex; i < parts.length; i += 1) {
201
+ const partBody = getNestedValue(parts[i], [2], null);
202
+ if (!partBody)
203
+ continue;
204
+ try {
205
+ const parsed = JSON.parse(partBody);
206
+ const candidateImages = getNestedValue(parsed, [4, 0, 12, 7, 0], null);
207
+ if (candidateImages != null) {
208
+ imgBody = parsed;
209
+ break;
210
+ }
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ }
216
+ const imgCandidate = getNestedValue(imgBody ?? body, [4, 0], null);
217
+ const generated = getNestedValue(imgCandidate, [12, 7, 0], []);
218
+ for (const genImage of generated) {
219
+ const url = getNestedValue(genImage, [0, 3, 3], null);
220
+ if (!url)
221
+ continue;
222
+ images.push({
223
+ kind: 'generated',
224
+ url,
225
+ title: '[Generated Image]',
226
+ alt: '',
227
+ });
228
+ }
229
+ }
230
+ return { metadata, text, thoughts, images, errorCode };
231
+ }
232
+ export function isGeminiModelUnavailable(errorCode) {
233
+ return errorCode === 1052;
234
+ }
235
+ export async function runGeminiWebOnce(input) {
236
+ const cookieHeader = buildCookieHeader(input.cookieMap);
237
+ const at = await fetchGeminiAccessToken(input.cookieMap);
238
+ const uploaded = [];
239
+ for (const file of input.files ?? []) {
240
+ uploaded.push(await uploadGeminiFile(file));
241
+ }
242
+ const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
243
+ const params = new URLSearchParams();
244
+ params.set('at', at);
245
+ params.set('f.req', fReq);
246
+ const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
247
+ method: 'POST',
248
+ redirect: 'follow',
249
+ headers: {
250
+ 'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
251
+ origin: 'https://gemini.google.com',
252
+ referer: 'https://gemini.google.com/',
253
+ 'x-same-domain': '1',
254
+ 'user-agent': USER_AGENT,
255
+ cookie: cookieHeader,
256
+ [MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
257
+ },
258
+ body: params.toString(),
259
+ });
260
+ const rawResponseText = await res.text();
261
+ if (!res.ok) {
262
+ return {
263
+ rawResponseText,
264
+ text: '',
265
+ thoughts: null,
266
+ metadata: input.chatMetadata ?? null,
267
+ images: [],
268
+ errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
269
+ };
270
+ }
271
+ try {
272
+ const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
273
+ return {
274
+ rawResponseText,
275
+ text: parsed.text ?? '',
276
+ thoughts: parsed.thoughts,
277
+ metadata: parsed.metadata,
278
+ images: parsed.images,
279
+ errorCode: parsed.errorCode,
280
+ };
281
+ }
282
+ catch (error) {
283
+ let responseJson = null;
284
+ try {
285
+ responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText));
286
+ }
287
+ catch {
288
+ responseJson = null;
289
+ }
290
+ const errorCode = extractErrorCode(responseJson);
291
+ return {
292
+ rawResponseText,
293
+ text: '',
294
+ thoughts: null,
295
+ metadata: input.chatMetadata ?? null,
296
+ images: [],
297
+ errorCode: typeof errorCode === 'number' ? errorCode : undefined,
298
+ errorMessage: error instanceof Error ? error.message : String(error ?? ''),
299
+ };
300
+ }
301
+ }
302
+ export async function runGeminiWebWithFallback(input) {
303
+ const attempt = await runGeminiWebOnce(input);
304
+ if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
305
+ const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
306
+ return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
307
+ }
308
+ return { ...attempt, effectiveModel: input.model };
309
+ }
310
+ export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath) {
311
+ const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
312
+ if (generatedOrWeb?.url) {
313
+ await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath);
314
+ return { saved: true, imageCount: output.images.length };
315
+ }
316
+ const ggdl = extractGgdlUrls(output.rawResponseText);
317
+ if (ggdl[0]) {
318
+ await downloadGeminiImage(ggdl[0], cookieMap, outputPath);
319
+ return { saved: true, imageCount: ggdl.length };
320
+ }
321
+ return { saved: false, imageCount: 0 };
322
+ }
@@ -0,0 +1,204 @@
1
+ import path from 'node:path';
2
+ import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
3
+ function estimateTokenCount(text) {
4
+ return Math.ceil(text.length / 4);
5
+ }
6
+ function resolveInvocationPath(value) {
7
+ if (!value)
8
+ return undefined;
9
+ const trimmed = value.trim();
10
+ if (!trimmed)
11
+ return undefined;
12
+ return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
13
+ }
14
+ function resolveGeminiWebModel(desiredModel, log) {
15
+ const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
16
+ if (!desired)
17
+ return 'gemini-3-pro';
18
+ switch (desired) {
19
+ case 'gemini-3-pro':
20
+ case 'gemini-3.0-pro':
21
+ return 'gemini-3-pro';
22
+ case 'gemini-2.5-pro':
23
+ return 'gemini-2.5-pro';
24
+ case 'gemini-2.5-flash':
25
+ return 'gemini-2.5-flash';
26
+ default:
27
+ if (desired.startsWith('gemini-')) {
28
+ log?.(`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`);
29
+ }
30
+ return 'gemini-3-pro';
31
+ }
32
+ }
33
+ async function loadGeminiCookiesFromChrome(browserConfig, log) {
34
+ try {
35
+ const mod = (await import('chrome-cookies-secure'));
36
+ const chromeCookies = mod.default ??
37
+ mod;
38
+ const profile = typeof browserConfig?.chromeProfile === 'string' &&
39
+ browserConfig.chromeProfile.trim().length > 0
40
+ ? browserConfig.chromeProfile.trim()
41
+ : undefined;
42
+ const sources = [
43
+ 'https://gemini.google.com',
44
+ 'https://accounts.google.com',
45
+ 'https://www.google.com',
46
+ ];
47
+ const wantNames = [
48
+ '__Secure-1PSID',
49
+ '__Secure-1PSIDTS',
50
+ '__Secure-1PSIDCC',
51
+ '__Secure-1PAPISID',
52
+ 'NID',
53
+ 'AEC',
54
+ 'SOCS',
55
+ '__Secure-BUCKET',
56
+ '__Secure-ENID',
57
+ 'SID',
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 {};
85
+ }
86
+ log?.(`[gemini-web] Loaded Gemini cookies from Chrome (node): ${Object.keys(cookieMap).length} cookie(s).`);
87
+ return cookieMap;
88
+ }
89
+ catch (error) {
90
+ log?.(`[gemini-web] Failed to load Chrome cookies via node: ${error instanceof Error ? error.message : String(error ?? '')}`);
91
+ return {};
92
+ }
93
+ }
94
+ export function createGeminiWebExecutor(geminiOptions) {
95
+ return async (runOptions) => {
96
+ const startTime = Date.now();
97
+ const log = runOptions.log;
98
+ log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
99
+ const cookieMap = await loadGeminiCookiesFromChrome(runOptions.config, log);
100
+ if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
101
+ throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
102
+ }
103
+ const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
104
+ const editImagePath = resolveInvocationPath(geminiOptions.editImage);
105
+ const outputPath = resolveInvocationPath(geminiOptions.outputPath);
106
+ const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
107
+ let prompt = runOptions.prompt;
108
+ if (geminiOptions.aspectRatio && (generateImagePath || editImagePath)) {
109
+ prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
110
+ }
111
+ if (geminiOptions.youtube) {
112
+ prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
113
+ }
114
+ if (generateImagePath && !editImagePath) {
115
+ prompt = `Generate an image: ${prompt}`;
116
+ }
117
+ const model = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
118
+ let response;
119
+ if (editImagePath) {
120
+ const intro = await runGeminiWebWithFallback({
121
+ prompt: 'Here is an image to edit',
122
+ files: [editImagePath],
123
+ model,
124
+ cookieMap,
125
+ chatMetadata: null,
126
+ });
127
+ const editPrompt = `Use image generation tool to ${prompt}`;
128
+ const out = await runGeminiWebWithFallback({
129
+ prompt: editPrompt,
130
+ files: attachmentPaths,
131
+ model,
132
+ cookieMap,
133
+ chatMetadata: intro.metadata,
134
+ });
135
+ response = {
136
+ text: out.text ?? null,
137
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
138
+ has_images: false,
139
+ image_count: 0,
140
+ };
141
+ const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
142
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath);
143
+ response.has_images = imageSave.saved;
144
+ response.image_count = imageSave.imageCount;
145
+ if (!imageSave.saved) {
146
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
147
+ }
148
+ }
149
+ else if (generateImagePath) {
150
+ const out = await runGeminiWebWithFallback({
151
+ prompt,
152
+ files: attachmentPaths,
153
+ model,
154
+ cookieMap,
155
+ chatMetadata: null,
156
+ });
157
+ response = {
158
+ text: out.text ?? null,
159
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
160
+ has_images: false,
161
+ image_count: 0,
162
+ };
163
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath);
164
+ response.has_images = imageSave.saved;
165
+ response.image_count = imageSave.imageCount;
166
+ if (!imageSave.saved) {
167
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
168
+ }
169
+ }
170
+ else {
171
+ const out = await runGeminiWebWithFallback({
172
+ prompt,
173
+ files: attachmentPaths,
174
+ model,
175
+ cookieMap,
176
+ chatMetadata: null,
177
+ });
178
+ response = {
179
+ text: out.text ?? null,
180
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
181
+ has_images: out.images.length > 0,
182
+ image_count: out.images.length,
183
+ };
184
+ }
185
+ const answerText = response.text ?? '';
186
+ let answerMarkdown = answerText;
187
+ if (geminiOptions.showThoughts && response.thoughts) {
188
+ answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
189
+ }
190
+ if (response.has_images && response.image_count > 0) {
191
+ const imagePath = generateImagePath || outputPath || 'generated.png';
192
+ answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
193
+ }
194
+ const tookMs = Date.now() - startTime;
195
+ log?.(`[gemini-web] Completed in ${tookMs}ms`);
196
+ return {
197
+ answerText,
198
+ answerMarkdown,
199
+ tookMs,
200
+ answerTokens: estimateTokenCount(answerText),
201
+ answerChars: answerText.length,
202
+ };
203
+ };
204
+ }
@@ -0,0 +1 @@
1
+ export { createGeminiWebExecutor } from './executor.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -4,12 +4,12 @@ import path from 'node:path';
4
4
  import net from 'node:net';
5
5
  import { randomBytes, randomUUID } from 'node:crypto';
6
6
  import { spawn, spawnSync } from 'node:child_process';
7
- import { existsSync } from 'node:fs';
8
7
  import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
9
8
  import chalk from 'chalk';
10
9
  import { runBrowserMode } from '../browserMode.js';
11
10
  import { loadChromeCookies } from '../browser/chromeCookies.js';
12
11
  import { CHATGPT_URL } from '../browser/constants.js';
12
+ import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from '../browser/profileState.js';
13
13
  import { normalizeChatgptUrl } from '../browser/utils.js';
14
14
  async function findAvailablePort() {
15
15
  return await new Promise((resolve, reject) => {
@@ -209,10 +209,17 @@ export async function serveRemote(options = {}) {
209
209
  if (preferManualLogin) {
210
210
  await mkdir(manualProfileDir, { recursive: true });
211
211
  console.log(`Cookie extraction is unavailable on this platform. Using manual-login Chrome profile at ${manualProfileDir}. Remote runs will reuse this profile; sign in once when the browser opens.`);
212
- const devtoolsPortFile = path.join(manualProfileDir, 'DevToolsActivePort');
213
- const alreadyRunning = existsSync(devtoolsPortFile);
214
- if (alreadyRunning) {
215
- console.log('Detected an existing automation Chrome session; will reuse it for manual login.');
212
+ const existingPort = await readDevToolsPort(manualProfileDir);
213
+ if (existingPort) {
214
+ const reachable = await verifyDevToolsReachable({ port: existingPort });
215
+ if (reachable.ok) {
216
+ console.log('Detected an existing automation Chrome session; will reuse it for manual login.');
217
+ }
218
+ else {
219
+ console.log(`Found stale DevToolsActivePort (port ${existingPort}, ${reachable.error}); launching a fresh manual-login Chrome.`);
220
+ await cleanupStaleProfileState(manualProfileDir, console.log, { lockRemovalMode: 'never' });
221
+ void launchManualLoginChrome(manualProfileDir, CHATGPT_URL, console.log);
222
+ }
216
223
  }
217
224
  else {
218
225
  void launchManualLoginChrome(manualProfileDir, CHATGPT_URL, console.log);
@@ -459,12 +466,11 @@ async function launchManualLoginChrome(profileDir, url, logger) {
459
466
  });
460
467
  const chosenPort = chrome?.port ?? debugPort ?? null;
461
468
  if (chosenPort) {
462
- // Write DevToolsActivePort eagerly so maybeReuseRunningChrome can attach on the next run
463
- const devtoolsFile = path.join(profileDir, 'DevToolsActivePort');
464
- const devtoolsFileDefault = path.join(profileDir, 'Default', 'DevToolsActivePort');
465
- const contents = `${chosenPort}\n/devtools/browser`;
466
- await writeFile(devtoolsFile, contents).catch(() => undefined);
467
- await writeFile(devtoolsFileDefault, contents).catch(() => undefined);
469
+ // Persist DevToolsActivePort eagerly so future runs can attach/reuse this Chrome.
470
+ await writeDevToolsActivePort(profileDir, chosenPort);
471
+ if (chrome?.pid) {
472
+ await writeChromePid(profileDir, chrome.pid);
473
+ }
468
474
  logger(`Manual-login Chrome DevTools port: ${chosenPort}`);
469
475
  logger(`If needed, DevTools JSON at http://127.0.0.1:${chosenPort}/json/version`);
470
476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -86,7 +86,7 @@
86
86
  "scripts": {
87
87
  "docs:list": "tsx scripts/docs-list.ts",
88
88
  "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
89
- "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const src=path.join('vendor','oracle-notifier'); const dest=path.join('dist','vendor','oracle-notifier'); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){ fs.cpSync(src,dest,{recursive:true,force:true}); }\"",
89
+ "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const vendorRoot=path.join('dist','vendor'); fs.rmSync(vendorRoot,{recursive:true,force:true}); const vendors=[['oracle-notifier']]; vendors.forEach(([name])=>{const src=path.join('vendor',name); const dest=path.join(vendorRoot,name); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){fs.cpSync(src,dest,{recursive:true,force:true});}});\"",
90
90
  "start": "pnpm run build && node ./dist/scripts/run-cli.js",
91
91
  "oracle": "pnpm start",
92
92
  "check": "pnpm run typecheck",