@asterxsk/kiln 0.1.0 → 0.2.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 (203) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +170 -170
  3. package/agent/AGENTS.md +67 -67
  4. package/agent/README.md +5 -5
  5. package/agent/extensions/AGENTS.md +68 -68
  6. package/agent/extensions/ask-user/index.ts +418 -418
  7. package/agent/extensions/ask-user/package-lock.json +769 -769
  8. package/agent/extensions/ask-user/package.json +19 -19
  9. package/agent/extensions/ask-user/prompt.ts +45 -45
  10. package/agent/extensions/ask-user/tsconfig.json +7 -7
  11. package/agent/extensions/background-terminals/docs/implementation-guide.md +942 -942
  12. package/agent/extensions/background-terminals/index.ts +627 -627
  13. package/agent/extensions/background-terminals/manager.test.ts +735 -735
  14. package/agent/extensions/background-terminals/output.test.ts +109 -109
  15. package/agent/extensions/background-terminals/package-lock.json +769 -769
  16. package/agent/extensions/background-terminals/package.json +17 -17
  17. package/agent/extensions/background-terminals/prompt.test.ts +125 -125
  18. package/agent/extensions/background-terminals/ps.test.ts +82 -82
  19. package/agent/extensions/background-terminals/result-delivery.test.ts +44 -44
  20. package/agent/extensions/background-terminals/src/domain.ts +87 -87
  21. package/agent/extensions/background-terminals/src/manager.ts +907 -907
  22. package/agent/extensions/background-terminals/src/output.ts +84 -84
  23. package/agent/extensions/background-terminals/src/prompt.ts +142 -142
  24. package/agent/extensions/background-terminals/src/result-delivery.ts +27 -27
  25. package/agent/extensions/background-terminals/src/runtime.ts +36 -36
  26. package/agent/extensions/background-terminals/src/ui/output-view.ts +79 -79
  27. package/agent/extensions/background-terminals/src/ui/ps.ts +621 -621
  28. package/agent/extensions/background-terminals/tsconfig.json +7 -7
  29. package/agent/extensions/file-search/index.spec.ts +443 -443
  30. package/agent/extensions/file-search/index.ts +459 -459
  31. package/agent/extensions/file-search/package-lock.json +2253 -2253
  32. package/agent/extensions/file-search/package.json +23 -23
  33. package/agent/extensions/file-search/src/args.ts +122 -122
  34. package/agent/extensions/file-search/src/binaries.ts +422 -422
  35. package/agent/extensions/file-search/src/output.ts +126 -126
  36. package/agent/extensions/file-search/src/process.ts +146 -146
  37. package/agent/extensions/file-search/src/prompt.ts +52 -52
  38. package/agent/extensions/file-search/tsconfig.json +7 -7
  39. package/agent/extensions/modelconf/PLAN.md +915 -915
  40. package/agent/extensions/modelconf/index.ts +296 -296
  41. package/agent/extensions/modelconf/src/ui/ModelConfView.ts +1101 -1101
  42. package/agent/extensions/pi-web-access/CHANGELOG.md +690 -690
  43. package/agent/extensions/pi-web-access/LICENSE +21 -21
  44. package/agent/extensions/pi-web-access/README.md +470 -470
  45. package/agent/extensions/pi-web-access/SECURITY.md +5 -5
  46. package/agent/extensions/pi-web-access/activity.ts +101 -101
  47. package/agent/extensions/pi-web-access/auth-fetch.ts +148 -148
  48. package/agent/extensions/pi-web-access/brightdata-unlocker.ts +272 -272
  49. package/agent/extensions/pi-web-access/chrome-cookies.ts +669 -669
  50. package/agent/extensions/pi-web-access/content-find.ts +139 -139
  51. package/agent/extensions/pi-web-access/credential-source.ts +191 -191
  52. package/agent/extensions/pi-web-access/data-uri-sanitize.ts +406 -406
  53. package/agent/extensions/pi-web-access/datalab-pdf-extract.ts +568 -568
  54. package/agent/extensions/pi-web-access/declared-web-links.ts +173 -173
  55. package/agent/extensions/pi-web-access/evidence/CONTRACT-EVIDENCE.md +496 -496
  56. package/agent/extensions/pi-web-access/evidence/contract-probe.mjs +140 -140
  57. package/agent/extensions/pi-web-access/exa.ts +526 -526
  58. package/agent/extensions/pi-web-access/extract.ts +1196 -1196
  59. package/agent/extensions/pi-web-access/feature-config.ts +29 -29
  60. package/agent/extensions/pi-web-access/fetch-params.ts +111 -111
  61. package/agent/extensions/pi-web-access/gemini-adc.ts +298 -298
  62. package/agent/extensions/pi-web-access/gemini-api.ts +353 -353
  63. package/agent/extensions/pi-web-access/gemini-pdf-extract.ts +108 -108
  64. package/agent/extensions/pi-web-access/gemini-url-context.ts +128 -128
  65. package/agent/extensions/pi-web-access/gemini-web-config.ts +101 -101
  66. package/agent/extensions/pi-web-access/gemini-web.ts +487 -487
  67. package/agent/extensions/pi-web-access/github-api.ts +197 -197
  68. package/agent/extensions/pi-web-access/github-extract.ts +746 -746
  69. package/agent/extensions/pi-web-access/github-issue-pr.ts +700 -700
  70. package/agent/extensions/pi-web-access/index.ts +1737 -1737
  71. package/agent/extensions/pi-web-access/package-lock.json +5808 -5808
  72. package/agent/extensions/pi-web-access/package.json +64 -64
  73. package/agent/extensions/pi-web-access/page-query.ts +96 -96
  74. package/agent/extensions/pi-web-access/pdf-extract.ts +409 -409
  75. package/agent/extensions/pi-web-access/promise-try.d.ts +7 -7
  76. package/agent/extensions/pi-web-access/query-rewrite.ts +51 -51
  77. package/agent/extensions/pi-web-access/render-search-error.ts +170 -170
  78. package/agent/extensions/pi-web-access/rsc-extract.ts +338 -338
  79. package/agent/extensions/pi-web-access/source-check.ts +282 -282
  80. package/agent/extensions/pi-web-access/ssrf-protection.ts +526 -526
  81. package/agent/extensions/pi-web-access/storage.ts +521 -521
  82. package/agent/extensions/pi-web-access/summary-model-scope.ts +125 -125
  83. package/agent/extensions/pi-web-access/test/auth-fetch.test.mjs +208 -208
  84. package/agent/extensions/pi-web-access/test/brightdata-unlocker.test.mjs +840 -840
  85. package/agent/extensions/pi-web-access/test/chrome-cookie-extraction.test.mjs +441 -441
  86. package/agent/extensions/pi-web-access/test/config-path.test.mjs +283 -283
  87. package/agent/extensions/pi-web-access/test/content-find.test.mjs +25 -25
  88. package/agent/extensions/pi-web-access/test/credential-source.test.mjs +118 -118
  89. package/agent/extensions/pi-web-access/test/data-uri-sanitize.test.mjs +210 -210
  90. package/agent/extensions/pi-web-access/test/datalab-pdf-extract.test.mjs +552 -552
  91. package/agent/extensions/pi-web-access/test/declared-web-links.test.mjs +212 -212
  92. package/agent/extensions/pi-web-access/test/fetch-answer-storage.test.mjs +40 -40
  93. package/agent/extensions/pi-web-access/test/fetch-cache-storage.test.mjs +334 -334
  94. package/agent/extensions/pi-web-access/test/fetch-content-domain-policy.test.mjs +95 -95
  95. package/agent/extensions/pi-web-access/test/fetch-modes.test.mjs +53 -53
  96. package/agent/extensions/pi-web-access/test/fetch-not-found-guidance.test.mjs +92 -92
  97. package/agent/extensions/pi-web-access/test/fetch-params.test.mjs +86 -86
  98. package/agent/extensions/pi-web-access/test/fetch-render-call.test.mjs +34 -34
  99. package/agent/extensions/pi-web-access/test/fetch-routing.test.mjs +173 -173
  100. package/agent/extensions/pi-web-access/test/gemini-adc-auth.test.mjs +257 -257
  101. package/agent/extensions/pi-web-access/test/gemini-api-transport.test.mjs +170 -170
  102. package/agent/extensions/pi-web-access/test/gemini-pdf-extract.test.mjs +133 -133
  103. package/agent/extensions/pi-web-access/test/gemini-web-cookie-opt-in.test.mjs +178 -178
  104. package/agent/extensions/pi-web-access/test/gemini-web-header-overflow.test.mjs +148 -148
  105. package/agent/extensions/pi-web-access/test/get-search-content.test.mjs +223 -223
  106. package/agent/extensions/pi-web-access/test/github-extract.test.mjs +378 -378
  107. package/agent/extensions/pi-web-access/test/github-issue-pr.test.mjs +565 -565
  108. package/agent/extensions/pi-web-access/test/inline-content-config.test.mjs +99 -99
  109. package/agent/extensions/pi-web-access/test/lazy-extract-load.test.mjs +118 -118
  110. package/agent/extensions/pi-web-access/test/local-video-oversize.test.mjs +52 -52
  111. package/agent/extensions/pi-web-access/test/package-typebox-dependency.test.mjs +50 -50
  112. package/agent/extensions/pi-web-access/test/page-query.test.mjs +51 -51
  113. package/agent/extensions/pi-web-access/test/pdf-config.test.mjs +140 -140
  114. package/agent/extensions/pi-web-access/test/pdf-extract.test.mjs +500 -500
  115. package/agent/extensions/pi-web-access/test/proxy-transport.test.mjs +286 -286
  116. package/agent/extensions/pi-web-access/test/query-rewrite.test.mjs +52 -52
  117. package/agent/extensions/pi-web-access/test/rsc-fallback.test.mjs +102 -102
  118. package/agent/extensions/pi-web-access/test/search-error-render.test.mjs +152 -152
  119. package/agent/extensions/pi-web-access/test/search-providers.test.mjs +274 -274
  120. package/agent/extensions/pi-web-access/test/source-check.test.mjs +179 -179
  121. package/agent/extensions/pi-web-access/test/ssrf-allow-ranges-config.test.mjs +205 -205
  122. package/agent/extensions/pi-web-access/test/ssrf-protection.test.mjs +456 -456
  123. package/agent/extensions/pi-web-access/test/tool-registration-config.test.mjs +182 -182
  124. package/agent/extensions/pi-web-access/test/youtube-extract-errors.test.mjs +64 -64
  125. package/agent/extensions/pi-web-access/tsconfig.json +11 -11
  126. package/agent/extensions/pi-web-access/utils.ts +451 -451
  127. package/agent/extensions/pi-web-access/video-extract.ts +392 -392
  128. package/agent/extensions/pi-web-access/youtube-extract.ts +328 -328
  129. package/agent/extensions/shared/activity-status.ts +31 -31
  130. package/agent/extensions/shared/child-session.test.ts +270 -270
  131. package/agent/extensions/shared/child-session.ts +148 -148
  132. package/agent/extensions/shared/context-utilization.test.ts +48 -48
  133. package/agent/extensions/shared/context-utilization.ts +47 -47
  134. package/agent/extensions/shared/dashboard-state.ts +99 -99
  135. package/agent/extensions/shared/tool-call-timeout.test.ts +117 -117
  136. package/agent/extensions/shared/tool-call-timeout.ts +104 -104
  137. package/agent/extensions/subagents/by-the-way.test.ts +29 -29
  138. package/agent/extensions/subagents/claude.test.ts +119 -119
  139. package/agent/extensions/subagents/codex.test.ts +102 -102
  140. package/agent/extensions/subagents/context-usage.test.ts +107 -107
  141. package/agent/extensions/subagents/docs/design-plan.md +568 -568
  142. package/agent/extensions/subagents/docs/effect-v4-extension-guide.md +354 -354
  143. package/agent/extensions/subagents/docs/effect-v4-notes.md +571 -571
  144. package/agent/extensions/subagents/index.ts +779 -779
  145. package/agent/extensions/subagents/manager.test.ts +276 -276
  146. package/agent/extensions/subagents/package-lock.json +2244 -2244
  147. package/agent/extensions/subagents/package.json +19 -19
  148. package/agent/extensions/subagents/result-delivery.test.ts +27 -27
  149. package/agent/extensions/subagents/src/backend.ts +73 -73
  150. package/agent/extensions/subagents/src/backends/claude.ts +701 -701
  151. package/agent/extensions/subagents/src/backends/codex.ts +1060 -1060
  152. package/agent/extensions/subagents/src/backends/pi.ts +575 -575
  153. package/agent/extensions/subagents/src/backends/stub.ts +300 -300
  154. package/agent/extensions/subagents/src/by-the-way.ts +21 -21
  155. package/agent/extensions/subagents/src/domain.ts +253 -253
  156. package/agent/extensions/subagents/src/format.ts +74 -74
  157. package/agent/extensions/subagents/src/manager.ts +736 -736
  158. package/agent/extensions/subagents/src/prompt.ts +92 -92
  159. package/agent/extensions/subagents/src/result-delivery.ts +20 -20
  160. package/agent/extensions/subagents/src/runtime.ts +53 -53
  161. package/agent/extensions/subagents/src/ui/takeover.ts +583 -583
  162. package/agent/extensions/subagents/src/ui/transcript.ts +201 -201
  163. package/agent/extensions/subagents/takeover.test.ts +29 -29
  164. package/agent/extensions/subagents/tsconfig.json +7 -7
  165. package/agent/extensions/todo/AGENTS.md +38 -38
  166. package/agent/extensions/todo/LICENSE +21 -21
  167. package/agent/extensions/todo/config.ts +55 -55
  168. package/agent/extensions/todo/index.ts +151 -151
  169. package/agent/extensions/todo/locales/de.json +17 -17
  170. package/agent/extensions/todo/locales/en.json +15 -15
  171. package/agent/extensions/todo/locales/es.json +17 -17
  172. package/agent/extensions/todo/locales/fr.json +17 -17
  173. package/agent/extensions/todo/locales/pt-BR.json +17 -17
  174. package/agent/extensions/todo/locales/pt.json +17 -17
  175. package/agent/extensions/todo/locales/ru.json +17 -17
  176. package/agent/extensions/todo/locales/uk.json +17 -17
  177. package/agent/extensions/todo/locales/zh.json +17 -17
  178. package/agent/extensions/todo/package-lock.json +3358 -3358
  179. package/agent/extensions/todo/package.json +67 -67
  180. package/agent/extensions/todo/state/i18n-bridge.ts +64 -64
  181. package/agent/extensions/todo/state/invariants.ts +20 -20
  182. package/agent/extensions/todo/state/replay.ts +38 -38
  183. package/agent/extensions/todo/state/selectors.ts +107 -107
  184. package/agent/extensions/todo/state/state-reducer.ts +326 -326
  185. package/agent/extensions/todo/state/state.ts +18 -18
  186. package/agent/extensions/todo/state/store.ts +82 -82
  187. package/agent/extensions/todo/state/task-graph.ts +57 -57
  188. package/agent/extensions/todo/todo-overlay.ts +200 -200
  189. package/agent/extensions/todo/todo.ts +155 -155
  190. package/agent/extensions/todo/tool/response-envelope.ts +109 -109
  191. package/agent/extensions/todo/tool/types.ts +206 -206
  192. package/agent/extensions/todo/view/format.ts +177 -177
  193. package/agent/install.ps1 +637 -527
  194. package/agent/install.sh +620 -511
  195. package/agent/keybindings.json +7 -7
  196. package/bin/kiln.js +124 -11
  197. package/package.json +8 -2
  198. package/agent/extensions/taste/index.ts +0 -443
  199. package/agent/extensions/taste/install.ps1 +0 -23
  200. package/agent/extensions/taste/install.sh +0 -21
  201. /package/agent/extensions/{status line → statusline}/index.ts +0 -0
  202. /package/agent/extensions/{status line → statusline}/install.ps1 +0 -0
  203. /package/agent/extensions/{status line → statusline}/install.sh +0 -0
@@ -1,487 +1,487 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename } from "node:path";
3
- import { getLastGoogleCookieDiagnostic, getLastGoogleCookieDiagnosticDetails, type BrowserCookieDiagnosticDetails, type CookieMap, getGoogleCookies } from "./chrome-cookies.ts";
4
- import { getBrowserCookieSelectionFromConfig, isBrowserCookieAccessAllowed, normalizeChromeProfile } from "./gemini-web-config.ts";
5
-
6
- const GEMINI_APP_URL = "https://gemini.google.com/app";
7
- const GEMINI_STREAM_GENERATE_URL =
8
- "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
9
- const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
10
- const GEMINI_UPLOAD_PUSH_ID = "feeds/mcudyrk2a4khkz";
11
- const GOOGLE_LIST_ACCOUNTS_URL =
12
- "https://accounts.google.com/ListAccounts?gpsia=1&source=ChromiumBrowser&laf=b64bin&json=standard";
13
-
14
- const USER_AGENT =
15
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
16
-
17
- const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
18
- export const DEFAULT_GEMINI_WEB_MODEL = "gemini-3.1-pro";
19
- const MODEL_HEADERS: Record<string, string> = {
20
- [DEFAULT_GEMINI_WEB_MODEL]: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
21
- "gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
22
- "gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
23
- };
24
-
25
- const REQUIRED_COOKIES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
26
-
27
- // Gemini Web talks to Google's frontend inside the host agent (pi), whose
28
- // global undici dispatcher runs HTTP/1.1 with undici's default 16 KiB
29
- // maxHeaderSize. Google's gemini.google.com /app response headers routinely
30
- // exceed that budget over HTTP/1.1, so cookie-bearing requests fail with
31
- // UND_ERR_HEADERS_OVERFLOW surfaced as "fetch failed". Route these requests
32
- // through a dedicated undici Agent with a larger header budget instead; fall
33
- // back to the ambient global fetch when undici is not resolvable (e.g.
34
- // standalone test environments).
35
- let geminiFetchImpl: typeof fetch | null = null;
36
-
37
- // Test-only hook: lets tests inject a simulated transport instead of patching
38
- // globalThis.fetch. Gemini Web deliberately bypasses the ambient global fetch
39
- // (inside the pi host agent it is constrained to undici's 16 KiB
40
- // maxHeaderSize and cannot serve Google's /app page over HTTP/1.1), so
41
- // globalThis.fetch patching is no longer a reliable way to intercept these
42
- // requests.
43
- export function setGeminiFetchOverrideForTests(fetchImpl: typeof fetch | null): void {
44
- geminiFetchOverride = fetchImpl;
45
- }
46
-
47
- let geminiFetchOverride: typeof fetch | null = null;
48
-
49
- // The header budget Gemini Web needs for Google frontend responses.
50
- // Google's /app page exceeds undici's default 16 KiB maxHeaderSize when
51
- // served over HTTP/1.1, which is how pi's global dispatcher operates.
52
- export const GEMINI_MAX_HEADER_SIZE = 4 * 1024 * 1024;
53
-
54
- // Build a fetch that routes through a dedicated undici Agent with a larger
55
- // header budget than the host agent's global dispatcher allows. Exported for
56
- // tests so the agent configuration can be asserted without network access.
57
- export function createGeminiFetch(undiciImpl: typeof import("undici")): typeof fetch {
58
- const agent = new undiciImpl.EnvHttpProxyAgent({
59
- allowH2: false,
60
- connectTimeout: 30_000,
61
- maxHeaderSize: GEMINI_MAX_HEADER_SIZE,
62
- pipelining: 1,
63
- });
64
- // undici 8 types its fetch request/response structurally differently from
65
- // the DOM lib types (duplex/textStream on Request, Symbol.dispose on
66
- // Headers); the shape this module uses (status, ok, headers.get, text) is
67
- // identical, so bridge the two.
68
- type UndiciFetch = typeof undiciImpl.fetch;
69
- return (input, init) =>
70
- undiciImpl.fetch(
71
- input as unknown as Parameters<UndiciFetch>[0],
72
- { ...init, dispatcher: agent } as unknown as Parameters<UndiciFetch>[1],
73
- ) as unknown as Promise<Response>;
74
- }
75
-
76
- export async function resolveGeminiFetch(): Promise<typeof fetch> {
77
- if (geminiFetchOverride) return geminiFetchOverride;
78
- if (geminiFetchImpl) return geminiFetchImpl;
79
-
80
- let undici: typeof import("undici");
81
- try {
82
- undici = await import("undici");
83
- } catch {
84
- geminiFetchImpl = fetch;
85
- return geminiFetchImpl;
86
- }
87
-
88
- geminiFetchImpl = createGeminiFetch(undici);
89
- return geminiFetchImpl;
90
- }
91
-
92
- export interface GeminiWebOptions {
93
- youtubeUrl?: string;
94
- model?: string;
95
- files?: string[];
96
- signal?: AbortSignal;
97
- timeoutMs?: number;
98
- }
99
-
100
- export async function isGeminiWebAvailable(chromeProfile?: string): Promise<CookieMap | null> {
101
- if (!isBrowserCookieAccessAllowed()) return null;
102
-
103
- const configured = getBrowserCookieSelectionFromConfig();
104
- const result = await getGoogleCookies({
105
- browser: configured.browser,
106
- profile: normalizeChromeProfile(chromeProfile) ?? configured.profile,
107
- requiredCookies: REQUIRED_COOKIES,
108
- });
109
- if (!result) return null;
110
- return result.cookies;
111
- }
112
-
113
- export function getGeminiWebAvailabilityDiagnostic(): string | null {
114
- return isBrowserCookieAccessAllowed() ? getLastGoogleCookieDiagnostic() : null;
115
- }
116
-
117
- export function getGeminiWebAvailabilityDiagnosticDetails(): BrowserCookieDiagnosticDetails | null {
118
- return isBrowserCookieAccessAllowed() ? getLastGoogleCookieDiagnosticDetails() : null;
119
- }
120
-
121
- export async function getActiveGoogleEmail(cookies: CookieMap): Promise<string | null> {
122
- const cookieHeader = buildCookieHeader(cookies);
123
- if (!cookieHeader) return null;
124
-
125
- try {
126
- const html = await fetchWithCookieRedirects(
127
- GEMINI_APP_URL,
128
- cookieHeader,
129
- 10,
130
- AbortSignal.timeout(10000),
131
- );
132
- const email = extractEmailFromGeminiHtml(html);
133
- if (email) return email;
134
- } catch {
135
- }
136
-
137
- try {
138
- const response = await fetchWithCookieRedirects(
139
- GOOGLE_LIST_ACCOUNTS_URL,
140
- cookieHeader,
141
- 10,
142
- AbortSignal.timeout(10000),
143
- );
144
- return extractEmailFromListAccounts(response);
145
- } catch {
146
- return null;
147
- }
148
- }
149
-
150
- export async function queryWithCookies(
151
- prompt: string,
152
- cookieMap: CookieMap,
153
- options: GeminiWebOptions = {},
154
- ): Promise<string> {
155
- const model = options.model ?? DEFAULT_GEMINI_WEB_MODEL;
156
- if (!MODEL_HEADERS[model]) {
157
- throw new Error(`Gemini Web does not support model ${model}; configure Gemini API or choose a supported Gemini Web model.`);
158
- }
159
- const timeoutMs = options.timeoutMs ?? 120000;
160
-
161
- let fullPrompt = prompt;
162
- if (options.youtubeUrl) {
163
- fullPrompt = `${fullPrompt}\n\nYouTube video: ${options.youtubeUrl}`;
164
- }
165
-
166
- const result = await runGeminiWebOnce(fullPrompt, cookieMap, model, options.files, timeoutMs, options.signal);
167
-
168
- if (result.errorMessage) throw new Error(result.errorMessage);
169
- if (!result.text) throw new Error("Gemini Web returned empty response");
170
- return result.text;
171
- }
172
-
173
- interface GeminiWebResult {
174
- text: string;
175
- errorCode?: number;
176
- errorMessage?: string;
177
- }
178
-
179
- async function runGeminiWebOnce(
180
- prompt: string,
181
- cookieMap: CookieMap,
182
- model: string,
183
- files: string[] | undefined,
184
- timeoutMs: number,
185
- signal?: AbortSignal,
186
- ): Promise<GeminiWebResult> {
187
- const effectiveSignal = withTimeout(signal, timeoutMs);
188
- const cookieHeader = buildCookieHeader(cookieMap);
189
- const accessToken = await fetchAccessToken(cookieHeader, effectiveSignal);
190
-
191
- const uploaded: Array<{ id: string; name: string }> = [];
192
- if (files) {
193
- for (const filePath of files) {
194
- uploaded.push(await uploadFile(filePath, cookieHeader, effectiveSignal));
195
- }
196
- }
197
-
198
- const fReq = buildFReqPayload(prompt, uploaded);
199
- const params = new URLSearchParams();
200
- params.set("at", accessToken);
201
- params.set("f.req", fReq);
202
-
203
- const res = await (await resolveGeminiFetch())(GEMINI_STREAM_GENERATE_URL, {
204
- method: "POST",
205
- redirect: "error",
206
- headers: {
207
- "content-type": "application/x-www-form-urlencoded;charset=utf-8",
208
- host: "gemini.google.com",
209
- origin: "https://gemini.google.com",
210
- referer: "https://gemini.google.com/",
211
- "x-same-domain": "1",
212
- "user-agent": USER_AGENT,
213
- cookie: cookieHeader,
214
- [MODEL_HEADER_NAME]: MODEL_HEADERS[model],
215
- },
216
- body: params.toString(),
217
- signal: effectiveSignal,
218
- });
219
-
220
- const rawText = await res.text();
221
-
222
- if (!res.ok) {
223
- return { text: "", errorMessage: `Gemini request failed: ${res.status}` };
224
- }
225
-
226
- try {
227
- return parseStreamGenerateResponse(rawText);
228
- } catch (err) {
229
- let errorCode: number | undefined;
230
- try {
231
- const json = JSON.parse(trimJsonEnvelope(rawText));
232
- errorCode = extractErrorCode(json);
233
- } catch {
234
- }
235
- return {
236
- text: "",
237
- errorCode,
238
- errorMessage: err instanceof Error ? err.message : String(err),
239
- };
240
- }
241
- }
242
-
243
- async function fetchAccessToken(
244
- cookieHeader: string,
245
- signal: AbortSignal,
246
- ): Promise<string> {
247
- const html = await fetchWithCookieRedirects(GEMINI_APP_URL, cookieHeader, 10, signal);
248
-
249
- for (const key of ["SNlM0e", "thykhd"]) {
250
- const match = html.match(new RegExp(`"${key}":"(.*?)"`));
251
- if (match?.[1]) return match[1];
252
- }
253
-
254
- throw new Error("Unable to authenticate with Gemini. Make sure you're signed into gemini.google.com in a supported Chromium-based browser.");
255
- }
256
-
257
- async function fetchWithCookieRedirects(
258
- url: string,
259
- cookieHeader: string,
260
- maxRedirects: number,
261
- signal: AbortSignal,
262
- ): Promise<string> {
263
- let current = url;
264
- const allowedOrigin = new URL(url).origin;
265
- for (let i = 0; i <= maxRedirects; i++) {
266
- const res = await (await resolveGeminiFetch())(current, {
267
- headers: { "user-agent": USER_AGENT, cookie: cookieHeader },
268
- redirect: "manual",
269
- signal,
270
- });
271
- if (res.status >= 300 && res.status < 400) {
272
- const location = res.headers.get("location");
273
- if (location) {
274
- const next = new URL(location, current);
275
- if (next.origin !== allowedOrigin) {
276
- throw new Error(`Refusing to send Google cookies across origins: ${allowedOrigin} -> ${next.origin}`);
277
- }
278
- current = next.toString();
279
- continue;
280
- }
281
- }
282
- return await res.text();
283
- }
284
- throw new Error(`Too many redirects (>${maxRedirects})`);
285
- }
286
-
287
- function extractEmailFromGeminiHtml(html: string): string | null {
288
- const patterns = [
289
- // Gemini bootstraps the active account in oPEP7c. Prefer it over generic
290
- // feature/config entries that may contain other signed-in Google accounts.
291
- /"oPEP7c"\s*:\s*"([^"]+)"/,
292
- // The Google account menu aria-label is rendered for the active account.
293
- /aria-label="Google Account:[^"]*?\(([^)]+)\)"/,
294
- /"displayEmail"\s*:\s*"([^"]+)"/,
295
- /"defaultEmail"\s*:\s*"([^"]+)"/,
296
- /"email"\s*:\s*"([^"]+)"/,
297
- /"identifier"\s*:\s*"([^"]+)"/,
298
- /"gaiaIdentifier"\s*:\s*"([^"]+)"/,
299
- ];
300
-
301
- for (const pattern of patterns) {
302
- const match = html.match(pattern);
303
- const email = normalizeEmail(match?.[1]);
304
- if (email) return email;
305
- }
306
-
307
- return findFirstEmail(html);
308
- }
309
-
310
- function extractEmailFromListAccounts(text: string): string | null {
311
- const trimmed = text.replace(/^\)\]\}'\s*/, "");
312
- try {
313
- return findEmailInValue(JSON.parse(trimmed)) ?? findFirstEmail(trimmed);
314
- } catch {
315
- return findFirstEmail(trimmed);
316
- }
317
- }
318
-
319
- function findEmailInValue(value: unknown): string | null {
320
- if (typeof value === "string") return normalizeEmail(value);
321
- if (Array.isArray(value)) {
322
- for (const item of value) {
323
- const email = findEmailInValue(item);
324
- if (email) return email;
325
- }
326
- return null;
327
- }
328
- if (value && typeof value === "object") {
329
- for (const item of Object.values(value as Record<string, unknown>)) {
330
- const email = findEmailInValue(item);
331
- if (email) return email;
332
- }
333
- }
334
- return null;
335
- }
336
-
337
- function findFirstEmail(text: string): string | null {
338
- const normalized = decodeEmailEscapes(text);
339
- const match = normalized.match(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i);
340
- return match?.[0] ?? null;
341
- }
342
-
343
- function normalizeEmail(value: string | undefined): string | null {
344
- if (!value) return null;
345
- const normalized = decodeEmailEscapes(value.trim());
346
- return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(normalized) ? normalized : null;
347
- }
348
-
349
- function decodeEmailEscapes(value: string): string {
350
- return value
351
- .replace(/\\u0040/gi, "@")
352
- .replace(/\\x40/gi, "@")
353
- .replace(/&#64;/gi, "@")
354
- .replace(/&commat;/gi, "@")
355
- .replace(/\\"/g, "\"")
356
- .replace(/\\\\/g, "\\");
357
- }
358
-
359
- async function uploadFile(
360
- filePath: string,
361
- cookieHeader: string,
362
- signal: AbortSignal,
363
- ): Promise<{ id: string; name: string }> {
364
- const data = readFileSync(filePath);
365
- const fileName = basename(filePath);
366
- const boundary = "----FormBoundary" + Math.random().toString(36).slice(2);
367
- const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
368
- const footer = `\r\n--${boundary}--\r\n`;
369
-
370
- const body = Buffer.concat([
371
- Buffer.from(header, "utf-8"),
372
- data,
373
- Buffer.from(footer, "utf-8"),
374
- ]);
375
-
376
- const res = await (await resolveGeminiFetch())(GEMINI_UPLOAD_URL, {
377
- method: "POST",
378
- redirect: "error",
379
- headers: {
380
- "content-type": `multipart/form-data; boundary=${boundary}`,
381
- "push-id": GEMINI_UPLOAD_PUSH_ID,
382
- "user-agent": USER_AGENT,
383
- cookie: cookieHeader,
384
- },
385
- body,
386
- signal,
387
- });
388
-
389
- if (!res.ok) {
390
- const text = await res.text();
391
- throw new Error(`File upload failed: ${res.status} (${text.slice(0, 200)})`);
392
- }
393
-
394
- return { id: await res.text(), name: fileName };
395
- }
396
-
397
- function buildFReqPayload(
398
- prompt: string,
399
- uploaded: Array<{ id: string; name: string }>,
400
- ): string {
401
- const promptPayload =
402
- uploaded.length > 0
403
- ? [prompt, 0, null, uploaded.map((file) => [[file.id, 1]])]
404
- : [prompt];
405
- const innerList = [promptPayload, null, null];
406
- return JSON.stringify([null, JSON.stringify(innerList)]);
407
- }
408
-
409
- function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
410
- const timeout = AbortSignal.timeout(timeoutMs);
411
- return signal ? AbortSignal.any([signal, timeout]) : timeout;
412
- }
413
-
414
- function buildCookieHeader(cookieMap: CookieMap): string {
415
- return Object.entries(cookieMap)
416
- .filter(([, value]) => typeof value === "string" && value.length > 0)
417
- .map(([name, value]) => `${name}=${value}`)
418
- .join("; ");
419
- }
420
-
421
- function getNestedValue(value: unknown, pathParts: number[]): unknown {
422
- let current: unknown = value;
423
- for (const part of pathParts) {
424
- if (current == null) return undefined;
425
- if (!Array.isArray(current)) return undefined;
426
- current = (current as unknown[])[part];
427
- }
428
- return current;
429
- }
430
-
431
- function trimJsonEnvelope(text: string): string {
432
- const start = text.indexOf("[");
433
- const end = text.lastIndexOf("]");
434
- if (start === -1 || end === -1 || end <= start) {
435
- throw new Error("Gemini response did not contain a JSON payload.");
436
- }
437
- return text.slice(start, end + 1);
438
- }
439
-
440
- function extractErrorCode(responseJson: unknown): number | undefined {
441
- const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0]);
442
- return typeof code === "number" && code >= 0 ? code : undefined;
443
- }
444
-
445
- function extractCandidateText(candidate: unknown): string {
446
- const textRaw = getNestedValue(candidate, [1, 0]);
447
- let text = typeof textRaw === "string" ? textRaw : "";
448
-
449
- if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
450
- const alt = getNestedValue(candidate, [22, 0]);
451
- if (typeof alt === "string" && alt.length > 0) text = alt;
452
- }
453
-
454
- return text;
455
- }
456
-
457
- function parseStreamGenerateResponse(rawText: string): GeminiWebResult {
458
- const responseJson = JSON.parse(trimJsonEnvelope(rawText));
459
- const errorCode = extractErrorCode(responseJson);
460
-
461
- const parts = Array.isArray(responseJson) ? responseJson : [];
462
- let firstCandidateSeen: unknown = undefined;
463
- let latestNonEmptyText = "";
464
-
465
- for (let i = 0; i < parts.length; i++) {
466
- const partBody = getNestedValue(parts[i], [2]);
467
- if (!partBody || typeof partBody !== "string") continue;
468
- try {
469
- const parsed = JSON.parse(partBody);
470
- const candidateList = getNestedValue(parsed, [4]);
471
- if (!Array.isArray(candidateList) || candidateList.length === 0) continue;
472
-
473
- const firstCandidate = (candidateList as unknown[])[0];
474
- if (firstCandidateSeen === undefined) firstCandidateSeen = firstCandidate;
475
-
476
- const text = extractCandidateText(firstCandidate);
477
- if (text.length > 0) latestNonEmptyText = text;
478
- } catch {
479
- }
480
- }
481
-
482
- const text = latestNonEmptyText.length > 0
483
- ? latestNonEmptyText
484
- : extractCandidateText(firstCandidateSeen);
485
-
486
- return { text, errorCode };
487
- }
1
+ import { readFileSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { getLastGoogleCookieDiagnostic, getLastGoogleCookieDiagnosticDetails, type BrowserCookieDiagnosticDetails, type CookieMap, getGoogleCookies } from "./chrome-cookies.ts";
4
+ import { getBrowserCookieSelectionFromConfig, isBrowserCookieAccessAllowed, normalizeChromeProfile } from "./gemini-web-config.ts";
5
+
6
+ const GEMINI_APP_URL = "https://gemini.google.com/app";
7
+ const GEMINI_STREAM_GENERATE_URL =
8
+ "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
9
+ const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
10
+ const GEMINI_UPLOAD_PUSH_ID = "feeds/mcudyrk2a4khkz";
11
+ const GOOGLE_LIST_ACCOUNTS_URL =
12
+ "https://accounts.google.com/ListAccounts?gpsia=1&source=ChromiumBrowser&laf=b64bin&json=standard";
13
+
14
+ const USER_AGENT =
15
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
16
+
17
+ const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
18
+ export const DEFAULT_GEMINI_WEB_MODEL = "gemini-3.1-pro";
19
+ const MODEL_HEADERS: Record<string, string> = {
20
+ [DEFAULT_GEMINI_WEB_MODEL]: '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
21
+ "gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
22
+ "gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
23
+ };
24
+
25
+ const REQUIRED_COOKIES = ["__Secure-1PSID", "__Secure-1PSIDTS"];
26
+
27
+ // Gemini Web talks to Google's frontend inside the host agent (pi), whose
28
+ // global undici dispatcher runs HTTP/1.1 with undici's default 16 KiB
29
+ // maxHeaderSize. Google's gemini.google.com /app response headers routinely
30
+ // exceed that budget over HTTP/1.1, so cookie-bearing requests fail with
31
+ // UND_ERR_HEADERS_OVERFLOW surfaced as "fetch failed". Route these requests
32
+ // through a dedicated undici Agent with a larger header budget instead; fall
33
+ // back to the ambient global fetch when undici is not resolvable (e.g.
34
+ // standalone test environments).
35
+ let geminiFetchImpl: typeof fetch | null = null;
36
+
37
+ // Test-only hook: lets tests inject a simulated transport instead of patching
38
+ // globalThis.fetch. Gemini Web deliberately bypasses the ambient global fetch
39
+ // (inside the pi host agent it is constrained to undici's 16 KiB
40
+ // maxHeaderSize and cannot serve Google's /app page over HTTP/1.1), so
41
+ // globalThis.fetch patching is no longer a reliable way to intercept these
42
+ // requests.
43
+ export function setGeminiFetchOverrideForTests(fetchImpl: typeof fetch | null): void {
44
+ geminiFetchOverride = fetchImpl;
45
+ }
46
+
47
+ let geminiFetchOverride: typeof fetch | null = null;
48
+
49
+ // The header budget Gemini Web needs for Google frontend responses.
50
+ // Google's /app page exceeds undici's default 16 KiB maxHeaderSize when
51
+ // served over HTTP/1.1, which is how pi's global dispatcher operates.
52
+ export const GEMINI_MAX_HEADER_SIZE = 4 * 1024 * 1024;
53
+
54
+ // Build a fetch that routes through a dedicated undici Agent with a larger
55
+ // header budget than the host agent's global dispatcher allows. Exported for
56
+ // tests so the agent configuration can be asserted without network access.
57
+ export function createGeminiFetch(undiciImpl: typeof import("undici")): typeof fetch {
58
+ const agent = new undiciImpl.EnvHttpProxyAgent({
59
+ allowH2: false,
60
+ connectTimeout: 30_000,
61
+ maxHeaderSize: GEMINI_MAX_HEADER_SIZE,
62
+ pipelining: 1,
63
+ });
64
+ // undici 8 types its fetch request/response structurally differently from
65
+ // the DOM lib types (duplex/textStream on Request, Symbol.dispose on
66
+ // Headers); the shape this module uses (status, ok, headers.get, text) is
67
+ // identical, so bridge the two.
68
+ type UndiciFetch = typeof undiciImpl.fetch;
69
+ return (input, init) =>
70
+ undiciImpl.fetch(
71
+ input as unknown as Parameters<UndiciFetch>[0],
72
+ { ...init, dispatcher: agent } as unknown as Parameters<UndiciFetch>[1],
73
+ ) as unknown as Promise<Response>;
74
+ }
75
+
76
+ export async function resolveGeminiFetch(): Promise<typeof fetch> {
77
+ if (geminiFetchOverride) return geminiFetchOverride;
78
+ if (geminiFetchImpl) return geminiFetchImpl;
79
+
80
+ let undici: typeof import("undici");
81
+ try {
82
+ undici = await import("undici");
83
+ } catch {
84
+ geminiFetchImpl = fetch;
85
+ return geminiFetchImpl;
86
+ }
87
+
88
+ geminiFetchImpl = createGeminiFetch(undici);
89
+ return geminiFetchImpl;
90
+ }
91
+
92
+ export interface GeminiWebOptions {
93
+ youtubeUrl?: string;
94
+ model?: string;
95
+ files?: string[];
96
+ signal?: AbortSignal;
97
+ timeoutMs?: number;
98
+ }
99
+
100
+ export async function isGeminiWebAvailable(chromeProfile?: string): Promise<CookieMap | null> {
101
+ if (!isBrowserCookieAccessAllowed()) return null;
102
+
103
+ const configured = getBrowserCookieSelectionFromConfig();
104
+ const result = await getGoogleCookies({
105
+ browser: configured.browser,
106
+ profile: normalizeChromeProfile(chromeProfile) ?? configured.profile,
107
+ requiredCookies: REQUIRED_COOKIES,
108
+ });
109
+ if (!result) return null;
110
+ return result.cookies;
111
+ }
112
+
113
+ export function getGeminiWebAvailabilityDiagnostic(): string | null {
114
+ return isBrowserCookieAccessAllowed() ? getLastGoogleCookieDiagnostic() : null;
115
+ }
116
+
117
+ export function getGeminiWebAvailabilityDiagnosticDetails(): BrowserCookieDiagnosticDetails | null {
118
+ return isBrowserCookieAccessAllowed() ? getLastGoogleCookieDiagnosticDetails() : null;
119
+ }
120
+
121
+ export async function getActiveGoogleEmail(cookies: CookieMap): Promise<string | null> {
122
+ const cookieHeader = buildCookieHeader(cookies);
123
+ if (!cookieHeader) return null;
124
+
125
+ try {
126
+ const html = await fetchWithCookieRedirects(
127
+ GEMINI_APP_URL,
128
+ cookieHeader,
129
+ 10,
130
+ AbortSignal.timeout(10000),
131
+ );
132
+ const email = extractEmailFromGeminiHtml(html);
133
+ if (email) return email;
134
+ } catch {
135
+ }
136
+
137
+ try {
138
+ const response = await fetchWithCookieRedirects(
139
+ GOOGLE_LIST_ACCOUNTS_URL,
140
+ cookieHeader,
141
+ 10,
142
+ AbortSignal.timeout(10000),
143
+ );
144
+ return extractEmailFromListAccounts(response);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ export async function queryWithCookies(
151
+ prompt: string,
152
+ cookieMap: CookieMap,
153
+ options: GeminiWebOptions = {},
154
+ ): Promise<string> {
155
+ const model = options.model ?? DEFAULT_GEMINI_WEB_MODEL;
156
+ if (!MODEL_HEADERS[model]) {
157
+ throw new Error(`Gemini Web does not support model ${model}; configure Gemini API or choose a supported Gemini Web model.`);
158
+ }
159
+ const timeoutMs = options.timeoutMs ?? 120000;
160
+
161
+ let fullPrompt = prompt;
162
+ if (options.youtubeUrl) {
163
+ fullPrompt = `${fullPrompt}\n\nYouTube video: ${options.youtubeUrl}`;
164
+ }
165
+
166
+ const result = await runGeminiWebOnce(fullPrompt, cookieMap, model, options.files, timeoutMs, options.signal);
167
+
168
+ if (result.errorMessage) throw new Error(result.errorMessage);
169
+ if (!result.text) throw new Error("Gemini Web returned empty response");
170
+ return result.text;
171
+ }
172
+
173
+ interface GeminiWebResult {
174
+ text: string;
175
+ errorCode?: number;
176
+ errorMessage?: string;
177
+ }
178
+
179
+ async function runGeminiWebOnce(
180
+ prompt: string,
181
+ cookieMap: CookieMap,
182
+ model: string,
183
+ files: string[] | undefined,
184
+ timeoutMs: number,
185
+ signal?: AbortSignal,
186
+ ): Promise<GeminiWebResult> {
187
+ const effectiveSignal = withTimeout(signal, timeoutMs);
188
+ const cookieHeader = buildCookieHeader(cookieMap);
189
+ const accessToken = await fetchAccessToken(cookieHeader, effectiveSignal);
190
+
191
+ const uploaded: Array<{ id: string; name: string }> = [];
192
+ if (files) {
193
+ for (const filePath of files) {
194
+ uploaded.push(await uploadFile(filePath, cookieHeader, effectiveSignal));
195
+ }
196
+ }
197
+
198
+ const fReq = buildFReqPayload(prompt, uploaded);
199
+ const params = new URLSearchParams();
200
+ params.set("at", accessToken);
201
+ params.set("f.req", fReq);
202
+
203
+ const res = await (await resolveGeminiFetch())(GEMINI_STREAM_GENERATE_URL, {
204
+ method: "POST",
205
+ redirect: "error",
206
+ headers: {
207
+ "content-type": "application/x-www-form-urlencoded;charset=utf-8",
208
+ host: "gemini.google.com",
209
+ origin: "https://gemini.google.com",
210
+ referer: "https://gemini.google.com/",
211
+ "x-same-domain": "1",
212
+ "user-agent": USER_AGENT,
213
+ cookie: cookieHeader,
214
+ [MODEL_HEADER_NAME]: MODEL_HEADERS[model],
215
+ },
216
+ body: params.toString(),
217
+ signal: effectiveSignal,
218
+ });
219
+
220
+ const rawText = await res.text();
221
+
222
+ if (!res.ok) {
223
+ return { text: "", errorMessage: `Gemini request failed: ${res.status}` };
224
+ }
225
+
226
+ try {
227
+ return parseStreamGenerateResponse(rawText);
228
+ } catch (err) {
229
+ let errorCode: number | undefined;
230
+ try {
231
+ const json = JSON.parse(trimJsonEnvelope(rawText));
232
+ errorCode = extractErrorCode(json);
233
+ } catch {
234
+ }
235
+ return {
236
+ text: "",
237
+ errorCode,
238
+ errorMessage: err instanceof Error ? err.message : String(err),
239
+ };
240
+ }
241
+ }
242
+
243
+ async function fetchAccessToken(
244
+ cookieHeader: string,
245
+ signal: AbortSignal,
246
+ ): Promise<string> {
247
+ const html = await fetchWithCookieRedirects(GEMINI_APP_URL, cookieHeader, 10, signal);
248
+
249
+ for (const key of ["SNlM0e", "thykhd"]) {
250
+ const match = html.match(new RegExp(`"${key}":"(.*?)"`));
251
+ if (match?.[1]) return match[1];
252
+ }
253
+
254
+ throw new Error("Unable to authenticate with Gemini. Make sure you're signed into gemini.google.com in a supported Chromium-based browser.");
255
+ }
256
+
257
+ async function fetchWithCookieRedirects(
258
+ url: string,
259
+ cookieHeader: string,
260
+ maxRedirects: number,
261
+ signal: AbortSignal,
262
+ ): Promise<string> {
263
+ let current = url;
264
+ const allowedOrigin = new URL(url).origin;
265
+ for (let i = 0; i <= maxRedirects; i++) {
266
+ const res = await (await resolveGeminiFetch())(current, {
267
+ headers: { "user-agent": USER_AGENT, cookie: cookieHeader },
268
+ redirect: "manual",
269
+ signal,
270
+ });
271
+ if (res.status >= 300 && res.status < 400) {
272
+ const location = res.headers.get("location");
273
+ if (location) {
274
+ const next = new URL(location, current);
275
+ if (next.origin !== allowedOrigin) {
276
+ throw new Error(`Refusing to send Google cookies across origins: ${allowedOrigin} -> ${next.origin}`);
277
+ }
278
+ current = next.toString();
279
+ continue;
280
+ }
281
+ }
282
+ return await res.text();
283
+ }
284
+ throw new Error(`Too many redirects (>${maxRedirects})`);
285
+ }
286
+
287
+ function extractEmailFromGeminiHtml(html: string): string | null {
288
+ const patterns = [
289
+ // Gemini bootstraps the active account in oPEP7c. Prefer it over generic
290
+ // feature/config entries that may contain other signed-in Google accounts.
291
+ /"oPEP7c"\s*:\s*"([^"]+)"/,
292
+ // The Google account menu aria-label is rendered for the active account.
293
+ /aria-label="Google Account:[^"]*?\(([^)]+)\)"/,
294
+ /"displayEmail"\s*:\s*"([^"]+)"/,
295
+ /"defaultEmail"\s*:\s*"([^"]+)"/,
296
+ /"email"\s*:\s*"([^"]+)"/,
297
+ /"identifier"\s*:\s*"([^"]+)"/,
298
+ /"gaiaIdentifier"\s*:\s*"([^"]+)"/,
299
+ ];
300
+
301
+ for (const pattern of patterns) {
302
+ const match = html.match(pattern);
303
+ const email = normalizeEmail(match?.[1]);
304
+ if (email) return email;
305
+ }
306
+
307
+ return findFirstEmail(html);
308
+ }
309
+
310
+ function extractEmailFromListAccounts(text: string): string | null {
311
+ const trimmed = text.replace(/^\)\]\}'\s*/, "");
312
+ try {
313
+ return findEmailInValue(JSON.parse(trimmed)) ?? findFirstEmail(trimmed);
314
+ } catch {
315
+ return findFirstEmail(trimmed);
316
+ }
317
+ }
318
+
319
+ function findEmailInValue(value: unknown): string | null {
320
+ if (typeof value === "string") return normalizeEmail(value);
321
+ if (Array.isArray(value)) {
322
+ for (const item of value) {
323
+ const email = findEmailInValue(item);
324
+ if (email) return email;
325
+ }
326
+ return null;
327
+ }
328
+ if (value && typeof value === "object") {
329
+ for (const item of Object.values(value as Record<string, unknown>)) {
330
+ const email = findEmailInValue(item);
331
+ if (email) return email;
332
+ }
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function findFirstEmail(text: string): string | null {
338
+ const normalized = decodeEmailEscapes(text);
339
+ const match = normalized.match(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i);
340
+ return match?.[0] ?? null;
341
+ }
342
+
343
+ function normalizeEmail(value: string | undefined): string | null {
344
+ if (!value) return null;
345
+ const normalized = decodeEmailEscapes(value.trim());
346
+ return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(normalized) ? normalized : null;
347
+ }
348
+
349
+ function decodeEmailEscapes(value: string): string {
350
+ return value
351
+ .replace(/\\u0040/gi, "@")
352
+ .replace(/\\x40/gi, "@")
353
+ .replace(/&#64;/gi, "@")
354
+ .replace(/&commat;/gi, "@")
355
+ .replace(/\\"/g, "\"")
356
+ .replace(/\\\\/g, "\\");
357
+ }
358
+
359
+ async function uploadFile(
360
+ filePath: string,
361
+ cookieHeader: string,
362
+ signal: AbortSignal,
363
+ ): Promise<{ id: string; name: string }> {
364
+ const data = readFileSync(filePath);
365
+ const fileName = basename(filePath);
366
+ const boundary = "----FormBoundary" + Math.random().toString(36).slice(2);
367
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
368
+ const footer = `\r\n--${boundary}--\r\n`;
369
+
370
+ const body = Buffer.concat([
371
+ Buffer.from(header, "utf-8"),
372
+ data,
373
+ Buffer.from(footer, "utf-8"),
374
+ ]);
375
+
376
+ const res = await (await resolveGeminiFetch())(GEMINI_UPLOAD_URL, {
377
+ method: "POST",
378
+ redirect: "error",
379
+ headers: {
380
+ "content-type": `multipart/form-data; boundary=${boundary}`,
381
+ "push-id": GEMINI_UPLOAD_PUSH_ID,
382
+ "user-agent": USER_AGENT,
383
+ cookie: cookieHeader,
384
+ },
385
+ body,
386
+ signal,
387
+ });
388
+
389
+ if (!res.ok) {
390
+ const text = await res.text();
391
+ throw new Error(`File upload failed: ${res.status} (${text.slice(0, 200)})`);
392
+ }
393
+
394
+ return { id: await res.text(), name: fileName };
395
+ }
396
+
397
+ function buildFReqPayload(
398
+ prompt: string,
399
+ uploaded: Array<{ id: string; name: string }>,
400
+ ): string {
401
+ const promptPayload =
402
+ uploaded.length > 0
403
+ ? [prompt, 0, null, uploaded.map((file) => [[file.id, 1]])]
404
+ : [prompt];
405
+ const innerList = [promptPayload, null, null];
406
+ return JSON.stringify([null, JSON.stringify(innerList)]);
407
+ }
408
+
409
+ function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
410
+ const timeout = AbortSignal.timeout(timeoutMs);
411
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
412
+ }
413
+
414
+ function buildCookieHeader(cookieMap: CookieMap): string {
415
+ return Object.entries(cookieMap)
416
+ .filter(([, value]) => typeof value === "string" && value.length > 0)
417
+ .map(([name, value]) => `${name}=${value}`)
418
+ .join("; ");
419
+ }
420
+
421
+ function getNestedValue(value: unknown, pathParts: number[]): unknown {
422
+ let current: unknown = value;
423
+ for (const part of pathParts) {
424
+ if (current == null) return undefined;
425
+ if (!Array.isArray(current)) return undefined;
426
+ current = (current as unknown[])[part];
427
+ }
428
+ return current;
429
+ }
430
+
431
+ function trimJsonEnvelope(text: string): string {
432
+ const start = text.indexOf("[");
433
+ const end = text.lastIndexOf("]");
434
+ if (start === -1 || end === -1 || end <= start) {
435
+ throw new Error("Gemini response did not contain a JSON payload.");
436
+ }
437
+ return text.slice(start, end + 1);
438
+ }
439
+
440
+ function extractErrorCode(responseJson: unknown): number | undefined {
441
+ const code = getNestedValue(responseJson, [0, 5, 2, 0, 1, 0]);
442
+ return typeof code === "number" && code >= 0 ? code : undefined;
443
+ }
444
+
445
+ function extractCandidateText(candidate: unknown): string {
446
+ const textRaw = getNestedValue(candidate, [1, 0]);
447
+ let text = typeof textRaw === "string" ? textRaw : "";
448
+
449
+ if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
450
+ const alt = getNestedValue(candidate, [22, 0]);
451
+ if (typeof alt === "string" && alt.length > 0) text = alt;
452
+ }
453
+
454
+ return text;
455
+ }
456
+
457
+ function parseStreamGenerateResponse(rawText: string): GeminiWebResult {
458
+ const responseJson = JSON.parse(trimJsonEnvelope(rawText));
459
+ const errorCode = extractErrorCode(responseJson);
460
+
461
+ const parts = Array.isArray(responseJson) ? responseJson : [];
462
+ let firstCandidateSeen: unknown = undefined;
463
+ let latestNonEmptyText = "";
464
+
465
+ for (let i = 0; i < parts.length; i++) {
466
+ const partBody = getNestedValue(parts[i], [2]);
467
+ if (!partBody || typeof partBody !== "string") continue;
468
+ try {
469
+ const parsed = JSON.parse(partBody);
470
+ const candidateList = getNestedValue(parsed, [4]);
471
+ if (!Array.isArray(candidateList) || candidateList.length === 0) continue;
472
+
473
+ const firstCandidate = (candidateList as unknown[])[0];
474
+ if (firstCandidateSeen === undefined) firstCandidateSeen = firstCandidate;
475
+
476
+ const text = extractCandidateText(firstCandidate);
477
+ if (text.length > 0) latestNonEmptyText = text;
478
+ } catch {
479
+ }
480
+ }
481
+
482
+ const text = latestNonEmptyText.length > 0
483
+ ? latestNonEmptyText
484
+ : extractCandidateText(firstCandidateSeen);
485
+
486
+ return { text, errorCode };
487
+ }