import { access, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import path from 'node:path'; import { buildCaptureThreadSnapshotExpression, assistantSnapshotLooksTerminal, snapshotIndicatesBusy, completeThreadCaptureIdentity, isCaptureIdentityDigest, deriveAttachmentLabel, extractAssistantArtifactButtons, extractAssistantDownloadButtons, hasThreadPayload, isPatchArtifactAttachment, mergeDeepResearchReportSnapshot, normalizeThreadSnapshot, normalizeAttachmentValue, parseThreadCaptureIdentity, scopeThreadSnapshotToCaptureIdentity, type ExportedThreadSnapshot, type ThreadAssistantDownloadButton, type ThreadCaptureIdentity, type ThreadSnapshot, } from './chatgpt-thread-snapshot-lib.mjs'; export { assistantSnapshotLooksIncomplete, assistantSnapshotLooksTerminal, completeThreadCaptureIdentity, extractAssistantArtifactLabels, hasThreadPayload, isCaptureIdentityDigest, normalizeThreadSnapshot, parseThreadCaptureIdentity, scopeThreadSnapshotToCaptureIdentity, snapshotBusyReason, snapshotHasAssistantArtifacts, snapshotHasPatchArtifacts, snapshotIndicatesBusy, threadStatusTextIndicatesComplete, threadStatusTextIndicatesBusy, } from './chatgpt-thread-snapshot-lib.mjs'; export type { ExportedThreadSnapshot, ThreadCaptureIdentity, ThreadAssistantSnapshot, ThreadAttachmentButton, ThreadSnapshot, } from './chatgpt-thread-snapshot-lib.mjs'; const require = createRequire(import.meta.url); const { readChatGptConversationRoot, CHATGPT_ARTIFACT_CONTROL_SELECTOR, isChatGptFileReferenceControl, CHATGPT_ASSISTANT_TURN_SELECTOR, CHATGPT_USER_TURN_SELECTOR, buildDeepResearchResponseInspectionSource, canonicalizeChatGptTurnNodes, normalizeResponseText, readChatGptTurnText, readChatGptTurnIdentity, deriveChatGptHrefLabel, } = require('./chatgpt-dom-snapshot-shared.js') as typeof import('./chatgpt-dom-snapshot-shared.js'); export const DEFAULT_BROWSER_ENDPOINT = 'http://127.0.0.1:9222'; const BROWSER_ENDPOINT_REQUEST_TIMEOUT_MS = 10_000; const TARGET_READY_TIMEOUT_MS = 60_000; const TARGET_READY_POLL_MS = 750; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000; const NATIVE_DOWNLOAD_GRACE_MS = 1_500; const LATE_NATIVE_DOWNLOAD_GRACE_MS = 1_000; const SNAPSHOT_SETTLE_TIMEOUT_MS = 20_000; const SNAPSHOT_SETTLE_POLL_MS = 500; export type ExportThreadSnapshotOptions = { captureIdentity?: ThreadCaptureIdentity; onCaptureIdentity?: (capture: ThreadCaptureIdentity) => void; forceReload?: boolean; onTargetLease?: (lease: CdpTargetLease) => void; targetLifecycle?: ThreadTargetLifecycle; }; export type CapturedThreadTarget = { captureIdentity: ThreadCaptureIdentity; snapshot: ThreadSnapshot; targetLease: CdpTargetLease; }; export type CaptureThreadTargetOptions = Pick & { timeoutMs?: number; }; type CdpPending = { reject: (error?: unknown) => void; resolve: (value: unknown) => void; }; type CdpCloseListener = (error: Error) => void; type CdpEvaluateOptions = { awaitPromise?: boolean; returnByValue?: boolean; }; type CdpNetworkResponse = { headers?: Record; status?: number; url?: string; }; type CdpPayload = { error?: unknown; id?: number; method?: string; params?: Record; result?: unknown; }; export type CdpEvent = { method?: string; params?: Record; }; export type CdpTarget = { id?: string; parentId?: string; title?: string; type?: string; url?: string; webSocketDebuggerUrl: string; }; export type ThreadTargetLifecycle = 'keep' | 'close-created'; export type CdpTargetLease = { created: boolean; rehydrated?: boolean; target: CdpTarget; }; export type ThreadContentState = { articleCount: number; attachmentButtonCount: number; bodyLength: number; href: string; messageCount: number; readyState: string; title: string; }; export type ThreadAttachmentDownloadSelector = { artifactIndex?: number; artifactIndexInAssistantTurn?: number; assistantTurnId?: string; assistantTurnIndex?: number; href?: string | null; }; function normalizeError(error: unknown, fallbackMessage: string): Error { return error instanceof Error ? error : new Error(fallbackMessage); } function isTransientExecutionContextError(error: unknown): boolean { const message = normalizeError(error, '').message; return /Cannot find (?:default )?execution context|Execution context was destroyed|Inspected target navigated/u.test(message); } function parseUrl(value: string): URL | null { try { return new URL(value); } catch { return null; } } function normalizePathname(pathname: string): string { const normalized = pathname.replace(/\/+$/u, ''); return normalized.length > 0 ? normalized : '/'; } function extractChatId(pathname: string): string | null { const match = normalizePathname(pathname).match(/^\/c\/([^/?#]+)$/u); return match?.[1] ?? null; } function scoreThreadTargetUrl(targetUrl: string | undefined, chatUrl: string): number { const target = parseUrl(targetUrl ?? ''); const chat = parseUrl(chatUrl); if (!target || !chat || target.origin !== chat.origin) { return -1; } const normalizedTargetPath = normalizePathname(target.pathname); const normalizedChatPath = normalizePathname(chat.pathname); if (normalizedTargetPath === normalizedChatPath && target.search === chat.search) { return 3; } if (conversationUrlsReferToSameThread(targetUrl ?? '', chatUrl)) { return 2; } return -1; } export function conversationUrlsReferToSameThread(candidateUrl: string, chatUrl: string): boolean { const candidate = parseUrl(candidateUrl); const chat = parseUrl(chatUrl); if (!candidate || !chat || candidate.origin !== chat.origin) { return false; } const candidateChatId = extractChatId(candidate.pathname); const chatId = extractChatId(chat.pathname); return candidateChatId !== null && chatId !== null && candidateChatId === chatId; } export function pickBestThreadTarget(targets: CdpTarget[], chatUrl: string): CdpTarget | null { let bestScore = -1; let bestTarget: CdpTarget | null = null; for (const target of targets) { if (target.type !== 'page' || !target.webSocketDebuggerUrl) { continue; } const score = scoreThreadTargetUrl(target.url, chatUrl); if (score < 0) { continue; } if (score >= bestScore) { bestScore = score; bestTarget = target; } } return bestTarget; } function getNetworkResponse(event: CdpEvent): CdpNetworkResponse { return (event.params?.response as CdpNetworkResponse | undefined) ?? {}; } export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function exists(filePath: string): Promise { try { await access(filePath); return true; } catch { return false; } } async function removeIfPresent(filePath: string): Promise { if (await exists(filePath)) { await rm(filePath, { force: true }); } } async function fileSize(filePath: string): Promise { try { return (await stat(filePath)).size; } catch { return null; } } async function listDownloadDirectoryFiles(dirPath: string): Promise> { const files = new Map(); let entries: Array<{ isFile: () => boolean; name: string }>; try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { if (!entry.isFile()) { continue; } const filePath = path.join(dirPath, entry.name); const size = await fileSize(filePath); if (size !== null) { files.set(filePath, size); } } return files; } async function removeEmptyDownloadFilesCreatedSince(dirPath: string, beforeFiles: Map): Promise { const afterFiles = await listDownloadDirectoryFiles(dirPath); await Promise.all( [...afterFiles.entries()] .filter(([filePath, size]) => size === 0 && beforeFiles.get(filePath) !== 0) .map(([filePath]) => rm(filePath, { force: true })), ); } export async function recoverVerifiedDownloadFile( filePath: string, beforeFiles: Map, expectedContentSha256: string, ): Promise { if (!/^[a-f0-9]{64}$/u.test(expectedContentSha256) || beforeFiles.has(filePath)) return null; try { const info = await stat(filePath); if (!info.isFile() || info.size <= 0) return null; const digest = createHash('sha256').update(await readFile(filePath)).digest('hex'); return digest === expectedContentSha256 ? filePath : null; } catch { return null; } } export async function fetchJson( url: string, options: { timeoutMs?: number; } = {}, ): Promise { const timeoutMs = options.timeoutMs ?? BROWSER_ENDPOINT_REQUEST_TIMEOUT_MS; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error('Browser endpoint request timeout must be a positive integer.'); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return (await response.json()) as T; } catch (error) { if ( (error instanceof DOMException && error.name === 'AbortError') || (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') ) { throw new Error(`Timed out fetching ${url} after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timeoutId); } } export class CdpClient { private readonly eventListeners = new Set<(event: CdpEvent) => void>(); private readonly closeListeners = new Set(); private nextId = 1; private readonly pending = new Map(); private terminalError: Error | null = null; readonly ready: Promise; private readonly ws: WebSocket; constructor(url: string, private readonly commandTimeoutMs = BROWSER_ENDPOINT_REQUEST_TIMEOUT_MS) { if (!Number.isFinite(commandTimeoutMs) || commandTimeoutMs <= 0) { throw new Error('CDP command timeout must be positive.'); } this.ws = new WebSocket(url); this.ready = new Promise((resolve, reject) => { let opened = false; const connectTimer = setTimeout(() => { const error = new Error('Timed out opening CDP socket.'); reject(error); this.failPending(error); this.ws.close(); }, commandTimeoutMs); this.ws.addEventListener( 'open', () => { opened = true; clearTimeout(connectTimer); resolve(); }, { once: true }, ); this.ws.addEventListener( 'error', () => { clearTimeout(connectTimer); const error = new Error(opened ? 'CDP socket errored unexpectedly.' : 'CDP socket failed to open.'); if (!opened) { reject(error); } this.failPending(error); }, { once: true }, ); this.ws.addEventListener( 'close', () => { clearTimeout(connectTimer); const error = new Error(opened ? 'CDP socket closed unexpectedly.' : 'CDP socket closed before opening.'); if (!opened) { reject(error); } this.failPending(error); }, { once: true }, ); }); this.ws.addEventListener('message', (event) => { let payload: CdpPayload; try { payload = JSON.parse(String(event.data)) as CdpPayload; } catch (error) { this.failPending(normalizeError(error, 'Failed to parse a CDP websocket payload.')); return; } if (payload.id) { const pending = this.pending.get(payload.id); if (!pending) { return; } this.pending.delete(payload.id); if (payload.error) { pending.reject(new Error(JSON.stringify(payload.error))); return; } pending.resolve(payload.result); return; } const message: CdpEvent = { method: payload.method, params: payload.params, }; for (const listener of this.eventListeners) { listener(message); } }); } private failPending(error: Error): void { if (this.terminalError) { return; } this.terminalError = error; for (const pending of this.pending.values()) { pending.reject(error); } this.pending.clear(); for (const listener of this.closeListeners) { listener(error); } this.closeListeners.clear(); } private onTerminalError(listener: CdpCloseListener): () => void { if (this.terminalError) { listener(this.terminalError); return () => {}; } this.closeListeners.add(listener); return () => { this.closeListeners.delete(listener); }; } close(): void { this.failPending(new Error('CDP client closed.')); this.ws.close(); } async evaluate(expression: string, options: CdpEvaluateOptions = {}): Promise { const result = (await this.send('Runtime.evaluate', { awaitPromise: options.awaitPromise ?? false, expression, returnByValue: options.returnByValue ?? true, })) as { exceptionDetails?: { exception?: { description?: string }; text?: string; }; result?: { value?: T; }; }; if (result.exceptionDetails) { throw new Error( result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'CDP Runtime.evaluate failed.', ); } return result.result?.value as T; } async send(method: string, params: Record = {}): Promise { await this.ready; if (this.terminalError) { throw this.terminalError; } const id = this.nextId; this.nextId += 1; return await new Promise((resolve, reject) => { const commandTimer = setTimeout(() => { const error = new Error(`Timed out executing CDP command ${method}.`); this.failPending(error); this.ws.close(); }, this.commandTimeoutMs); const removeTerminalListener = this.onTerminalError((error) => { this.pending.delete(id); clearTimeout(commandTimer); reject(error); }); this.pending.set(id, { reject: (error) => { clearTimeout(commandTimer); removeTerminalListener(); reject(error); }, resolve: (value) => { clearTimeout(commandTimer); removeTerminalListener(); resolve(value as T); }, }); try { this.ws.send(JSON.stringify({ id, method, params })); } catch (error) { this.pending.delete(id); clearTimeout(commandTimer); removeTerminalListener(); reject(normalizeError(error, `Failed to send CDP command ${method}.`)); } }); } waitForEvent(predicate: (event: CdpEvent) => boolean, timeoutMs = TARGET_READY_TIMEOUT_MS): Promise { if (this.terminalError) { return Promise.reject(this.terminalError); } return new Promise((resolve, reject) => { let removeTerminalListener = () => {}; const cleanup = () => { clearTimeout(timeoutId); this.eventListeners.delete(handleEvent); removeTerminalListener(); }; const timeoutId = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for matching CDP event after ${timeoutMs}ms`)); }, timeoutMs); const handleEvent = (event: CdpEvent) => { if (!predicate(event)) { return; } cleanup(); resolve(event); }; removeTerminalListener = this.onTerminalError((error) => { cleanup(); reject(error); }); this.eventListeners.add(handleEvent); }); } } async function createTarget(browserEndpoint: string, chatUrl: string): Promise { const version = await fetchJson<{ webSocketDebuggerUrl: string }>(`${browserEndpoint}/json/version`); const browser = new CdpClient(version.webSocketDebuggerUrl); try { const result = await browser.send<{ targetId?: string }>('Target.createTarget', { url: chatUrl, background: true }); const targetId = String(result.targetId ?? ''); if (!targetId) { throw new Error('Browser created a thread target without returning its target id.'); } return targetId; } finally { browser.close(); } } export async function closeTarget(browserEndpoint: string, targetId: string): Promise { if (!targetId) { return; } const version = await fetchJson<{ webSocketDebuggerUrl: string }>(`${browserEndpoint}/json/version`); const browser = new CdpClient(version.webSocketDebuggerUrl); try { await browser.send('Target.closeTarget', { targetId }); } finally { browser.close(); } } async function findMatchingTarget(browserEndpoint: string, chatUrl: string): Promise { const targets = await fetchJson(`${browserEndpoint}/json/list`); const candidates = targets.filter((target) => target.type === 'page' && Boolean(target.webSocketDebuggerUrl) && scoreThreadTargetUrl(target.url, chatUrl) >= 0); if (candidates.length <= 1) return pickBestThreadTarget(candidates, chatUrl); // Probe only URL-based discovery. Exact captured targets retain their strict // identity path and never silently substitute another existing tab. const deadline = Date.now() + BROWSER_ENDPOINT_REQUEST_TIMEOUT_MS; while (candidates.length > 0 && Date.now() < deadline) { const candidate = pickBestThreadTarget(candidates, chatUrl)!; candidates.splice(candidates.indexOf(candidate), 1); const client = new CdpClient(candidate.webSocketDebuggerUrl, Math.max(1, Math.min(2_000, deadline - Date.now()))); try { const href = await client.evaluate('location.href'); if (conversationUrlsReferToSameThread(href, chatUrl)) return candidate; } catch { // A stale renderer is not evidence that the responsive duplicate vanished. } finally { client.close(); } } throw new Error('No responsive browser target for the requested conversation.'); } async function findTargetById(browserEndpoint: string, targetId: string): Promise { const targets = await fetchJson(`${browserEndpoint}/json/list`); return targets.find( (target) => target.id === targetId && target.type === 'page' && Boolean(target.webSocketDebuggerUrl), ) ?? null; } export async function closeThreadTarget( browserEndpoint: string, chatUrl: string, exactTargetId?: string, ): Promise { const target = exactTargetId ? (await fetchJson(`${browserEndpoint}/json/list`)).find( (candidate) => candidate.type === 'page' && candidate.id === exactTargetId && conversationUrlsReferToSameThread(candidate.url ?? '', chatUrl), ) ?? null : await findMatchingTarget(browserEndpoint, chatUrl); if (!target?.id) { return false; } await closeTarget(browserEndpoint, target.id); return true; } async function readThreadContentState(client: CdpClient): Promise { return await client.evaluate(`(() => { const root = (${readChatGptConversationRoot.toString()})(); return ({ href: location.href, readyState: document.readyState, title: document.title, bodyLength: root?.innerText?.length ?? 0, articleCount: root?.querySelectorAll('article').length ?? 0, messageCount: root?.querySelectorAll('[data-message-author-role]').length ?? 0, attachmentButtonCount: (() => { if (!root) return 0; const deriveHrefLabel = (href) => (${deriveChatGptHrefLabel.toString()})(href, location.href); const isConversationHref = (href) => { if (!href) return false; try { return /^\\/c\\/[^/]+$/u.test(new URL(href, location.href).pathname); } catch { return /^\\/?c\\/[^/]+$/u.test(String(href)); } }; return Array.from(root.querySelectorAll(${JSON.stringify(CHATGPT_ARTIFACT_CONTROL_SELECTOR)})).filter((element) => { const text = (element.innerText || element.getAttribute('aria-label') || '').trim(); const href = element.href || ''; const hrefLabel = deriveHrefLabel(href); if (isConversationHref(href)) return false; if (element.hasAttribute('download')) return true; if (element.classList?.contains('behavior-btn') && /\\b(?:patch|diff)\\b/i.test(text)) return true; return ( /\\.(patch|diff|zip|txt|json|md|patched)\\b/i.test(text) || /\\.(patch|diff|zip|txt|json|md|patched)\\b/i.test(href) || /\\.(patch|diff|zip|txt|json|md|patched)\\b/i.test(hrefLabel) || /\\b(?:patch|diff|archive|zip|file|download|attachment)\\b/i.test(text) ); }).length; })(), }); })()`); } function parseContentDispositionFilename(value: string | null | undefined): string | null { const raw = normalizeAttachmentValue(value); if (raw.length === 0) { return null; } const utf8Match = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/iu); if (utf8Match) { return decodeURIComponent(utf8Match[1] ?? ''); } const quotedMatch = raw.match(/filename\s*=\s*"([^"]+)"/iu); if (quotedMatch) { return quotedMatch[1] ?? null; } const bareMatch = raw.match(/filename\s*=\s*([^;]+)/iu); if (bareMatch) { return bareMatch[1]?.trim() ?? null; } return null; } function sanitizeDownloadFilename(value: string | null | undefined, fallback = 'downloaded-artifact'): string { const raw = normalizeAttachmentValue(value); const normalized = raw.replaceAll('\\', '/'); const basename = path.posix.basename(normalized).trim(); if (basename.length === 0 || basename === '.' || basename === '..') { return fallback; } return basename; } async function findAttachmentClickTarget(client: CdpClient, attachmentText: string): Promise<{ availableButtons?: string[]; centerX?: number; centerY?: number; found: boolean; href?: string | null; hrefLabel?: string; text?: string; }> { return await findAttachmentClickTargetWithSelector(client, attachmentText, {}); } async function findAttachmentClickTargetWithSelector( client: CdpClient, attachmentText: string, selector: ThreadAttachmentDownloadSelector, ): Promise<{ availableButtons?: string[]; centerX?: number; centerY?: number; found: boolean; href?: string | null; hrefLabel?: string; identityError?: string; text?: string; }> { const assistantTurnSelectorLiteral = JSON.stringify(CHATGPT_ASSISTANT_TURN_SELECTOR); const userTurnSelectorLiteral = JSON.stringify(CHATGPT_USER_TURN_SELECTOR); const canonicalizeChatGptTurnNodesSource = canonicalizeChatGptTurnNodes.toString(); const readChatGptTurnIdentitySource = readChatGptTurnIdentity.toString(); const readChatGptTurnTextSource = readChatGptTurnText.toString(); const normalizeResponseTextSource = normalizeResponseText.toString(); const artifactIndex = Number.isInteger(selector.artifactIndex) && Number(selector.artifactIndex) >= 0 ? Number(selector.artifactIndex) : -1; const artifactIndexInAssistantTurn = Number.isInteger(selector.artifactIndexInAssistantTurn) && Number(selector.artifactIndexInAssistantTurn) >= 0 ? Number(selector.artifactIndexInAssistantTurn) : -1; const assistantTurnId = String(selector.assistantTurnId ?? ''); const assistantTurnIndex = Number.isInteger(selector.assistantTurnIndex) && Number(selector.assistantTurnIndex) >= 0 ? Number(selector.assistantTurnIndex) : -1; const exactCaptureSelection = Boolean(assistantTurnId) && assistantTurnIndex >= 0 && artifactIndexInAssistantTurn >= 0; const expectedHrefSpecified = Object.prototype.hasOwnProperty.call(selector, 'href'); return await client.evaluate(`(() => { const root = (${readChatGptConversationRoot.toString()})(); const deriveHrefLabel = (href) => (${deriveChatGptHrefLabel.toString()})(href, location.href); const hasDownloadableHref = (href) => { if (!href) return false; const normalizedHref = String(href).trim(); if (!normalizedHref) return false; if (normalizedHref.startsWith('sandbox:/mnt/data/')) return true; try { const url = new URL(normalizedHref, location.href); return url.protocol === 'blob:' || url.protocol === 'data:'; } catch { return false; } }; const assistantTurnSelector = ${assistantTurnSelectorLiteral}; const userTurnSelector = ${userTurnSelectorLiteral}; const canonicalizeChatGptTurnNodes = ${canonicalizeChatGptTurnNodesSource}; const normalizeResponseText = ${normalizeResponseTextSource}; const readChatGptTurnText = ${readChatGptTurnTextSource}; const assistantTurnGroups = canonicalizeChatGptTurnNodes( Array.from(root.querySelectorAll(assistantTurnSelector)), ); const assistantNodes = assistantTurnGroups.map((group) => group.node); const assistantTurnGroupFor = (node) => assistantTurnGroups.find((group) => group.aliases.includes(node)) || null; const userNodes = canonicalizeChatGptTurnNodes( Array.from(root.querySelectorAll(userTurnSelector)), ).map((group) => group.node); const lastUserNode = userNodes.at(-1) || null; const isAfterLastUserNode = (node) => { if (!lastUserNode) return true; if (!node || node === lastUserNode || typeof lastUserNode.compareDocumentPosition !== 'function') return false; return Boolean(lastUserNode.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING); }; const assistantNodesAfterLastUser = assistantNodes.filter((node) => isAfterLastUserNode(node)); const assistantNodesAfterLastUserSet = new Set(assistantNodesAfterLastUser); const finalAssistantNode = assistantNodesAfterLastUser.at(-1) || (!lastUserNode ? assistantNodes.at(-1) || null : null); const turnIdentity = ${readChatGptTurnIdentitySource}; const sanitizedTurnIdentity = (value) => { const raw = String(value || ''); const marker = ':signature:'; const markerIndex = raw.indexOf(marker); if (markerIndex < 0) return raw; let hash = 0x811c9dc5; for (const character of raw.slice(markerIndex + marker.length)) { hash ^= character.codePointAt(0) || 0; hash = Math.imul(hash, 0x01000193) >>> 0; } return raw.slice(0, markerIndex) + ':hash32:' + hash.toString(16).padStart(8, '0'); }; const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\\s+/g, ' ').trim(); const capturedAssistantNodes = assistantNodes.filter((node, index) => { const signature = normalize(readChatGptTurnText(node)).slice(0, 320); const liveTurnId = turnIdentity(node, 'assistant', index, signature); const idMatches = !${JSON.stringify(assistantTurnId)} || liveTurnId === ${JSON.stringify(assistantTurnId)} || sanitizedTurnIdentity(liveTurnId) === ${JSON.stringify(assistantTurnId)}; const indexMatches = ${assistantTurnIndex} < 0 || index === ${assistantTurnIndex}; return idMatches && indexMatches; }); if ((${JSON.stringify(assistantTurnId)} || ${assistantTurnIndex} >= 0) && capturedAssistantNodes.length !== 1) { return { found: false, identityError: 'Captured assistant turn resolved to ' + capturedAssistantNodes.length + ' DOM nodes.', }; } const capturedAssistantNode = ${Boolean(assistantTurnId) || assistantTurnIndex >= 0} ? capturedAssistantNodes[0] || null : null; const isChatGptFileReferenceControl = ${isChatGptFileReferenceControl.toString()}; const controls = Array.from(root.querySelectorAll(${JSON.stringify(CHATGPT_ARTIFACT_CONTROL_SELECTOR)})); const assistantCandidates = controls.filter((element) => { const rawAssistantContainer = element.closest(assistantTurnSelector); const assistantContainer = assistantTurnGroupFor(rawAssistantContainer)?.node || rawAssistantContainer; if (!assistantContainer) return false; if (capturedAssistantNode && assistantContainer !== capturedAssistantNode) return false; if (!capturedAssistantNode && !assistantNodesAfterLastUserSet.has(assistantContainer)) return false; if (!(element.hasAttribute('download') || element.classList?.contains('behavior-btn') || isChatGptFileReferenceControl(element) || hasDownloadableHref(element.href || ''))) { return false; } return true; }); const finalAssistantCandidates = assistantCandidates.filter((element) => finalAssistantNode?.contains(element)); const candidates = !capturedAssistantNode && finalAssistantCandidates.length > 0 ? finalAssistantCandidates : assistantCandidates; const matchesAttachment = (element) => { const text = (element.innerText || element.getAttribute('aria-label') || '').trim(); return ( text === ${JSON.stringify(attachmentText)} || deriveHrefLabel(element.href || '') === ${JSON.stringify(attachmentText)} || (String(element.href || '') && String(element.href || '') === ${JSON.stringify(selector.href ?? '')}) ); }; const exactArtifactIndex = ${artifactIndexInAssistantTurn} >= 0 ? ${artifactIndexInAssistantTurn} : ${artifactIndex}; const indexedButton = exactArtifactIndex >= 0 ? candidates[exactArtifactIndex] || null : null; const exactHrefMatches = (element) => !${expectedHrefSpecified} || String(element?.href || '') === ${JSON.stringify(selector.href ?? '')}; const exactLabelMatches = (element) => !${JSON.stringify(attachmentText)} || matchesAttachment(element); if (${exactCaptureSelection} && indexedButton && (!exactHrefMatches(indexedButton) || !exactLabelMatches(indexedButton))) { return { found: false, identityError: 'Captured assistant artifact index no longer matches its exact href and label identity.', }; } const button = ${exactCaptureSelection} ? indexedButton && exactHrefMatches(indexedButton) && exactLabelMatches(indexedButton) ? indexedButton : null : indexedButton || candidates.find((element) => matchesAttachment(element)) || null; if (!button || typeof button.getBoundingClientRect !== 'function') { return { found: false, availableButtons: candidates .map((element, index) => { const text = (element.innerText || element.getAttribute('aria-label') || '').trim(); const hrefLabel = deriveHrefLabel(element.href || ''); return [index, text || hrefLabel || '(unlabeled)'].join(':'); }) .slice(-80), }; } if (isChatGptFileReferenceControl(button) && (button.getAttribute('aria-busy') === 'true' || button.getAttribute('aria-disabled') === 'true')) return { found: false }; button.scrollIntoView({ block: 'center' }); const rect = button.getBoundingClientRect(); return { found: true, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2, href: button.href || null, hrefLabel: deriveHrefLabel(button.href || ''), text: (button.innerText || button.getAttribute('aria-label') || '').trim(), }; })()`); } async function clickAttachment(client: CdpClient, attachmentText: string, timeoutMs: number): Promise<{ availableButtons?: string[]; found: boolean; href?: string | null; hrefLabel?: string; text?: string; }> { return await clickAttachmentWithSelector(client, attachmentText, timeoutMs, {}); } async function clickAttachmentWithSelector( client: CdpClient, attachmentText: string, timeoutMs: number, selector: ThreadAttachmentDownloadSelector, ): Promise<{ availableButtons?: string[]; found: boolean; href?: string | null; hrefLabel?: string; identityError?: string; text?: string; }> { const startedAt = Date.now(); let target = await findAttachmentClickTargetWithSelector(client, attachmentText, selector); while (!target.found && !target.identityError && Date.now() - startedAt <= timeoutMs) { await sleep(250); target = await findAttachmentClickTargetWithSelector(client, attachmentText, selector); } if (!target.found || target.centerX === undefined || target.centerY === undefined) { return target; } const assistantTurnSelectorLiteral = JSON.stringify(CHATGPT_ASSISTANT_TURN_SELECTOR); const userTurnSelectorLiteral = JSON.stringify(CHATGPT_USER_TURN_SELECTOR); const canonicalizeChatGptTurnNodesSource = canonicalizeChatGptTurnNodes.toString(); const readChatGptTurnTextSource = readChatGptTurnText.toString(); const normalizeResponseTextSource = normalizeResponseText.toString(); const readChatGptTurnIdentitySource = readChatGptTurnIdentity.toString(); const artifactIndex = Number.isInteger(selector.artifactIndex) && Number(selector.artifactIndex) >= 0 ? Number(selector.artifactIndex) : -1; const artifactIndexInAssistantTurn = Number.isInteger(selector.artifactIndexInAssistantTurn) && Number(selector.artifactIndexInAssistantTurn) >= 0 ? Number(selector.artifactIndexInAssistantTurn) : -1; const assistantTurnId = String(selector.assistantTurnId ?? ''); const assistantTurnIndex = Number.isInteger(selector.assistantTurnIndex) && Number(selector.assistantTurnIndex) >= 0 ? Number(selector.assistantTurnIndex) : -1; const exactCaptureSelection = Boolean(assistantTurnId) && assistantTurnIndex >= 0 && artifactIndexInAssistantTurn >= 0; const expectedHrefSpecified = Object.prototype.hasOwnProperty.call(selector, 'href'); const activated = await client.evaluate(`(() => { const root = (${readChatGptConversationRoot.toString()})(); const deriveHrefLabel = (href) => (${deriveChatGptHrefLabel.toString()})(href, location.href); const hasDownloadableHref = (href) => { if (!href) return false; const normalizedHref = String(href).trim(); if (!normalizedHref) return false; if (normalizedHref.startsWith('sandbox:/mnt/data/')) return true; try { const url = new URL(normalizedHref, location.href); return url.protocol === 'blob:' || url.protocol === 'data:'; } catch { return false; } }; const assistantTurnSelector = ${assistantTurnSelectorLiteral}; const userTurnSelector = ${userTurnSelectorLiteral}; const canonicalizeChatGptTurnNodes = ${canonicalizeChatGptTurnNodesSource}; const normalizeResponseText = ${normalizeResponseTextSource}; const readChatGptTurnText = ${readChatGptTurnTextSource}; const assistantTurnGroups = canonicalizeChatGptTurnNodes( Array.from(root.querySelectorAll(assistantTurnSelector)), ); const assistantNodes = assistantTurnGroups.map((group) => group.node); const assistantTurnGroupFor = (node) => assistantTurnGroups.find((group) => group.aliases.includes(node)) || null; const userNodes = canonicalizeChatGptTurnNodes( Array.from(root.querySelectorAll(userTurnSelector)), ).map((group) => group.node); const lastUserNode = userNodes.at(-1) || null; const isAfterLastUserNode = (node) => { if (!lastUserNode) return true; if (!node || node === lastUserNode || typeof lastUserNode.compareDocumentPosition !== 'function') return false; return Boolean(lastUserNode.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING); }; const assistantNodesAfterLastUser = assistantNodes.filter((node) => isAfterLastUserNode(node)); const assistantNodesAfterLastUserSet = new Set(assistantNodesAfterLastUser); const finalAssistantNode = assistantNodesAfterLastUser.at(-1) || (!lastUserNode ? assistantNodes.at(-1) || null : null); const turnIdentity = ${readChatGptTurnIdentitySource}; const sanitizedTurnIdentity = (value) => { const raw = String(value || ''); const marker = ':signature:'; const markerIndex = raw.indexOf(marker); if (markerIndex < 0) return raw; let hash = 0x811c9dc5; for (const character of raw.slice(markerIndex + marker.length)) { hash ^= character.codePointAt(0) || 0; hash = Math.imul(hash, 0x01000193) >>> 0; } return raw.slice(0, markerIndex) + ':hash32:' + hash.toString(16).padStart(8, '0'); }; const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\\s+/g, ' ').trim(); const capturedAssistantNodes = assistantNodes.filter((node, index) => { const signature = normalize(readChatGptTurnText(node)).slice(0, 320); const liveTurnId = turnIdentity(node, 'assistant', index, signature); const idMatches = !${JSON.stringify(assistantTurnId)} || liveTurnId === ${JSON.stringify(assistantTurnId)} || sanitizedTurnIdentity(liveTurnId) === ${JSON.stringify(assistantTurnId)}; const indexMatches = ${assistantTurnIndex} < 0 || index === ${assistantTurnIndex}; return idMatches && indexMatches; }); if ((${JSON.stringify(assistantTurnId)} || ${assistantTurnIndex} >= 0) && capturedAssistantNodes.length !== 1) { return false; } const capturedAssistantNode = ${Boolean(assistantTurnId) || assistantTurnIndex >= 0} ? capturedAssistantNodes[0] || null : null; const dispatchClickSequence = (node) => { if (!node || typeof node.dispatchEvent !== 'function') return false; const ownerView = (node.ownerDocument && node.ownerDocument.defaultView) || (typeof window === 'object' ? window : null); if (!ownerView) return false; for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) { const common = { bubbles: true, cancelable: true, view: ownerView }; let event; if (type.startsWith('pointer') && 'PointerEvent' in ownerView) { event = new ownerView.PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' }); } else { event = new ownerView.MouseEvent(type, common); } node.dispatchEvent(event); } return true; }; const isChatGptFileReferenceControl = ${isChatGptFileReferenceControl.toString()}; const controls = Array.from(root.querySelectorAll(${JSON.stringify(CHATGPT_ARTIFACT_CONTROL_SELECTOR)})); const assistantCandidates = controls.filter((element) => { const rawAssistantContainer = element.closest(assistantTurnSelector); const assistantContainer = assistantTurnGroupFor(rawAssistantContainer)?.node || rawAssistantContainer; if (!assistantContainer) return false; if (capturedAssistantNode && assistantContainer !== capturedAssistantNode) return false; if (!capturedAssistantNode && !assistantNodesAfterLastUserSet.has(assistantContainer)) return false; if (!(element.hasAttribute('download') || element.classList?.contains('behavior-btn') || isChatGptFileReferenceControl(element) || hasDownloadableHref(element.href || ''))) { return false; } return true; }); const finalAssistantCandidates = assistantCandidates.filter((element) => finalAssistantNode?.contains(element)); const candidates = !capturedAssistantNode && finalAssistantCandidates.length > 0 ? finalAssistantCandidates : assistantCandidates; const matchesAttachment = (element) => { const text = (element.innerText || element.getAttribute('aria-label') || '').trim(); return ( text === ${JSON.stringify(attachmentText)} || deriveHrefLabel(element.href || '') === ${JSON.stringify(attachmentText)} || (String(element.href || '') && String(element.href || '') === ${JSON.stringify(selector.href ?? '')}) ); }; const exactArtifactIndex = ${artifactIndexInAssistantTurn} >= 0 ? ${artifactIndexInAssistantTurn} : ${artifactIndex}; const indexedNode = exactArtifactIndex >= 0 ? candidates[exactArtifactIndex] || null : null; const exactHrefMatches = (element) => !${expectedHrefSpecified} || String(element?.href || '') === ${JSON.stringify(selector.href ?? '')}; const exactLabelMatches = (element) => !${JSON.stringify(attachmentText)} || matchesAttachment(element); const node = ${exactCaptureSelection} ? indexedNode && exactHrefMatches(indexedNode) && exactLabelMatches(indexedNode) ? indexedNode : null : indexedNode || candidates.find((element) => matchesAttachment(element)) || null; if (!(node instanceof HTMLElement)) { return false; } if (isChatGptFileReferenceControl(node) && (node.getAttribute('aria-busy') === 'true' || node.getAttribute('aria-disabled') === 'true')) return false; node.scrollIntoView({ block: 'center' }); if (typeof node.click === 'function') { node.click(); return true; } return dispatchClickSequence(node); })()`, { awaitPromise: true }); if (activated) { return target; } if (exactCaptureSelection) { const revalidatedTarget = await findAttachmentClickTargetWithSelector(client, attachmentText, selector); if ( !revalidatedTarget.found || revalidatedTarget.centerX === undefined || revalidatedTarget.centerY === undefined ) { return revalidatedTarget; } target = revalidatedTarget; } await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: target.centerX, y: target.centerY, button: 'left', buttons: 1, clickCount: 1, }); await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: target.centerX, y: target.centerY, button: 'left', buttons: 1, clickCount: 1, }); await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target.centerX, y: target.centerY, button: 'left', buttons: 0, clickCount: 1, }); return target; } async function waitForDownloadedFile(filePath: string, timeoutMs: number): Promise { const startedAt = Date.now(); for (;;) { const size = await fileSize(filePath); if (size !== null && size > 0) { return; } if (Date.now() - startedAt > timeoutMs) { throw new Error( size === 0 ? `Downloaded file stayed empty after ${timeoutMs}ms: ${filePath}` : `Timed out waiting for downloaded file ${filePath}`, ); } await sleep(250); } } export async function verifyDownloadedArtifact( downloadedFile: string, expectedContentSha256 = '', ): Promise { const downloadedStat = await stat(downloadedFile); if (!downloadedStat.isFile() || downloadedStat.size <= 0) { await removeIfPresent(downloadedFile); throw new Error('Attachment download did not produce a non-empty file.'); } if (!expectedContentSha256) return; const actualContentSha256 = createHash('sha256') .update(await readFile(downloadedFile)) .digest('hex'); if (actualContentSha256 !== expectedContentSha256) { await removeIfPresent(downloadedFile); throw new Error( `Downloaded artifact SHA-256 ${actualContentSha256} did not match the assistant-declared SHA-256 ${expectedContentSha256}.`, ); } } async function createTargetLease( browserEndpoint: string, chatUrl: string, rehydrated = false, ): Promise { const createdTargetId = await createTarget(browserEndpoint, chatUrl); const startedAt = Date.now(); try { for (;;) { const target = await findTargetById(browserEndpoint, createdTargetId); if (target) { return { created: true, ...(rehydrated ? { rehydrated: true } : {}), target, }; } if (Date.now() - startedAt > TARGET_READY_TIMEOUT_MS) { throw new Error(`Timed out waiting for a browser tab for ${chatUrl}`); } await sleep(TARGET_READY_POLL_MS); } } catch (error) { try { await closeTarget(browserEndpoint, createdTargetId); } catch (cleanupError) { throw new Error( `${normalizeError(error, 'Thread target setup failed.').message} Cleanup also failed: ${normalizeError(cleanupError, 'Unknown cleanup error.').message}`, { cause: error }, ); } throw error; } } export async function ensureTargetLease( browserEndpoint: string, chatUrl: string, exactTargetId?: string, rehydrateMissingExactTarget = false, ): Promise { if (exactTargetId) { const targets = await fetchJson(`${browserEndpoint}/json/list`); const exactTargets = targets.filter( (target) => target.type === 'page' && target.id === exactTargetId && Boolean(target.webSocketDebuggerUrl) && conversationUrlsReferToSameThread(target.url ?? '', chatUrl), ); if (exactTargets.length === 0 && rehydrateMissingExactTarget) { return await createTargetLease(browserEndpoint, chatUrl, true); } if (exactTargets.length !== 1) { throw new Error( `Exact captured browser target resolved to ${exactTargets.length} tabs; refusing to navigate, create, or select another target.`, ); } return { created: false, target: exactTargets[0]!, }; } const existingTarget = await findMatchingTarget(browserEndpoint, chatUrl); if (existingTarget) { return { created: false, target: existingTarget, }; } return await createTargetLease(browserEndpoint, chatUrl); } export async function ensureTarget(browserEndpoint: string, chatUrl: string): Promise { return (await ensureTargetLease(browserEndpoint, chatUrl)).target; } async function closeTargetLeaseIfRequested( browserEndpoint: string, lease: CdpTargetLease, lifecycle: ThreadTargetLifecycle | undefined, ): Promise { if (lifecycle !== 'close-created' || !lease.created) { return; } try { await closeTarget(browserEndpoint, lease.target.id ?? ''); } catch { // Best-effort cleanup must not turn a successful export/download into a failed wake. } } export function threadContentHasMeaningfulSignals(state: Pick): boolean { return state.bodyLength > 500 || state.articleCount > 0 || state.messageCount > 0 || state.attachmentButtonCount > 0; } export function threadContentLooksReady(state: ThreadContentState, chatUrl: string): boolean { return conversationUrlsReferToSameThread(state.href, chatUrl) && state.readyState === 'complete' && threadContentHasMeaningfulSignals(state); } export async function waitForTargetContent( client: CdpClient, chatUrl: string, timeoutMs = TARGET_READY_TIMEOUT_MS, ): Promise { const startedAt = Date.now(); for (;;) { try { const state = await readThreadContentState(client); if (threadContentLooksReady(state, chatUrl)) { return state; } } catch (error) { if (!isTransientExecutionContextError(error)) { throw error; } } if (Date.now() - startedAt > timeoutMs) { throw new Error(`Timed out waiting for ChatGPT thread content for ${chatUrl}`); } await sleep(TARGET_READY_POLL_MS); } } async function refreshTargetPage(client: CdpClient, timeoutMs = TARGET_READY_TIMEOUT_MS): Promise { await client.send('Page.enable'); const loadEventPromise = client.waitForEvent( (event) => event.method === 'Page.loadEventFired', timeoutMs, ); await client.send('Page.reload', { ignoreCache: true, }); await loadEventPromise; } async function navigateTargetPage( client: CdpClient, chatUrl: string, timeoutMs = TARGET_READY_TIMEOUT_MS, ): Promise { await client.send('Page.enable'); const loadEventPromise = client.waitForEvent( (event) => event.method === 'Page.loadEventFired', timeoutMs, ); await client.send('Page.navigate', { url: chatUrl, }); await loadEventPromise; } async function ensureThreadPageReady( client: CdpClient, chatUrl: string, options: { forceReload?: boolean; reloadExistingThread?: boolean; timeoutMs?: number; } = {}, ): Promise { const deadline = Date.now() + Math.max(1, options.timeoutMs ?? TARGET_READY_TIMEOUT_MS); let currentState: ThreadContentState; for (;;) { try { currentState = await readThreadContentState(client); break; } catch (error) { if (!isTransientExecutionContextError(error) || Date.now() >= deadline) { throw error; } await sleep(Math.min(TARGET_READY_POLL_MS, Math.max(1, deadline - Date.now()))); } } if (options.forceReload !== true && threadContentLooksReady(currentState, chatUrl)) { return currentState; } if (conversationUrlsReferToSameThread(currentState.href, chatUrl)) { if (options.reloadExistingThread !== false) { await refreshTargetPage(client, Math.max(1, deadline - Date.now())); } } else { await navigateTargetPage(client, chatUrl, Math.max(1, deadline - Date.now())); } return await waitForTargetContent(client, chatUrl, Math.max(1, deadline - Date.now())); } async function waitForSettledThreadSnapshot(client: CdpClient): Promise { const startedAt = Date.now(); let snapshot = await captureThreadSnapshot(client); if (hasThreadPayload(snapshot)) { return snapshot; } while (Date.now() - startedAt <= SNAPSHOT_SETTLE_TIMEOUT_MS) { await sleep(SNAPSHOT_SETTLE_POLL_MS); snapshot = await captureThreadSnapshot(client); if (hasThreadPayload(snapshot)) { return snapshot; } } return snapshot; } async function waitForCapturedThreadIdentity( client: CdpClient, browserEndpoint: string, parentTargetId: string, captureIdentity: ThreadCaptureIdentity, timeoutMs: number, ): Promise { const deadline = Date.now() + Math.max(1, timeoutMs); let lastIdentityError: unknown; for (;;) { let snapshot: ThreadSnapshot | null = null; try { snapshot = await captureThreadSnapshot(client); } catch (error) { if (!isTransientExecutionContextError(error)) { throw error; } lastIdentityError = error; } if (snapshot) { try { return await scopeCapturedThreadSnapshot( browserEndpoint, parentTargetId, snapshot, captureIdentity, ); } catch (error) { lastIdentityError = error; } } if (Date.now() >= deadline) { throw lastIdentityError; } await sleep(Math.min(SNAPSHOT_SETTLE_POLL_MS, Math.max(1, deadline - Date.now()))); } } export async function captureThreadSnapshot(client: CdpClient): Promise { const snapshot = await client.evaluate | null | undefined>(buildCaptureThreadSnapshotExpression()); return normalizeThreadSnapshot(snapshot); } function isDeepResearchIframeTarget(target: CdpTarget, parentTargetId: string): boolean { if (target.type !== 'iframe' || target.parentId !== parentTargetId || !target.webSocketDebuggerUrl) { return false; } const metadata = `${target.title ?? ''}\n${target.url ?? ''}`.toLowerCase(); return ( metadata.includes('deep research') || metadata.includes('deep-research') || metadata.includes('deep_research') || metadata.includes('connector_openai_deep_research') ); } async function captureDeepResearchReportSnapshots( browserEndpoint: string, parentTargetId: string, ): Promise { const targets = await fetchJson(`${browserEndpoint}/json/list`); const iframeTargets = targets.filter((target) => isDeepResearchIframeTarget(target, parentTargetId)); if (iframeTargets.length === 0) { throw new Error( 'Captured Deep Research iframe target resolved to 0 frames; refusing thread export.', ); } const snapshots: ThreadSnapshot[] = []; for (const iframeTarget of iframeTargets) { const iframeClient = new CdpClient(iframeTarget.webSocketDebuggerUrl); try { await iframeClient.send('Runtime.enable'); snapshots.push(normalizeThreadSnapshot( await iframeClient.evaluate | null | undefined>( buildDeepResearchResponseInspectionSource(), { awaitPromise: true }, ), )); } finally { iframeClient.close(); } } return snapshots; } async function scopeCapturedThreadSnapshot( browserEndpoint: string, parentTargetId: string, pageSnapshot: ThreadSnapshot, captureIdentity: ThreadCaptureIdentity | undefined, ): Promise { const expectsDeepResearch = captureIdentity?.expectedContentSource === 'deep-research-iframe' || captureIdentity?.assistantResponse?.contentSource === 'deep-research-iframe'; if (!captureIdentity || !expectsDeepResearch) { return scopeThreadSnapshotToCaptureIdentity(pageSnapshot, captureIdentity); } if (!parentTargetId) { throw new Error('Captured Deep Research parent target is missing its exact browser target identity.'); } const reportSnapshots = await captureDeepResearchReportSnapshots(browserEndpoint, parentTargetId); if (reportSnapshots.length === 1) { return scopeThreadSnapshotToCaptureIdentity( mergeDeepResearchReportSnapshot(pageSnapshot, reportSnapshots[0], captureIdentity), captureIdentity, ); } if (captureIdentity.assistantResponse?.contentSource !== 'deep-research-iframe') { throw new Error( `Captured Deep Research iframe target resolved to ${reportSnapshots.length} frames before the report identity was known; refusing ambiguous thread export.`, ); } const exactMatches: ThreadSnapshot[] = []; for (const reportSnapshot of reportSnapshots) { try { exactMatches.push(scopeThreadSnapshotToCaptureIdentity( mergeDeepResearchReportSnapshot(pageSnapshot, reportSnapshot, captureIdentity), captureIdentity, )); } catch { // A stale Deep Research iframe is expected to fail the stored report digest. } } if (exactMatches.length !== 1) { throw new Error( `Captured Deep Research report identity resolved to ${exactMatches.length} of ${reportSnapshots.length} frames; refusing ambiguous thread export.`, ); } return exactMatches[0]!; } export function extractPatchAttachmentLabels(snapshot: Partial | Pick): string[] { return [ ...new Set( extractAssistantArtifactButtons(snapshot) .filter((attachment) => isPatchArtifactAttachment(attachment)) .map((attachment) => deriveAttachmentLabel(attachment)) .filter((label) => label.length > 0), ), ]; } export function extractAssistantDownloadTargets(snapshot: Partial | Pick): Array<{ artifactIndex: number; artifactIndexInAssistantTurn?: number; assistantTurnId?: string; assistantTurnIndex?: number; href?: string | null; label: string; }> { return extractAssistantDownloadButtons(snapshot).map((attachment: ThreadAssistantDownloadButton) => ({ artifactIndex: attachment.artifactIndex, ...(Number.isInteger(attachment.artifactIndexInAssistantTurn) ? { artifactIndexInAssistantTurn: attachment.artifactIndexInAssistantTurn } : {}), ...(attachment.assistantTurnId ? { assistantTurnId: attachment.assistantTurnId } : {}), ...(Number.isInteger(attachment.assistantTurnIndex) ? { assistantTurnIndex: attachment.assistantTurnIndex } : {}), href: attachment.href, label: attachment.label, })); } export async function resolveCapturedConversation( browserEndpoint: string, chatUrl: string, capture: ThreadCaptureIdentity, ): Promise { if (capture.conversationUrlPending !== undefined) parseThreadCaptureIdentity(capture); const capturedUrl = parseUrl(capture.chatUrl); let capturedId = ''; try { capturedId = decodeURIComponent(extractChatId(capturedUrl?.pathname ?? '') ?? ''); } catch { /* rejected below */ } if (!capture.conversationUrlPending && !/^WEB:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(capturedId)) return capture; if (capture.browserEndpoint !== browserEndpoint) throw new Error('Capture metadata browser endpoint does not match the requested endpoint.'); const targets = await fetchJson(`${browserEndpoint}/json/list`); const matches = targets.filter((target) => target.type === 'page' && target.id === capture.targetId && Boolean(target.webSocketDebuggerUrl)); if (matches.length !== 1) throw new Error('Accepted transient capture requires its original browser target; refusing replacement or resend.'); const liveUrl = parseUrl(matches[0]!.url ?? ''); const liveId = extractChatId(liveUrl?.pathname ?? ''); if (!liveUrl || liveUrl.origin !== capturedUrl?.origin || !liveId || !/^[A-Za-z0-9_-]+$/u.test(liveId)) { throw new Error('Accepted transient conversation has not obtained a canonical URL. Retry unchanged capture metadata; do not resend.'); } const canonicalUrl = `${liveUrl.origin}/c/${liveId}`; const requestedUrl = parseUrl(chatUrl); let requestedId = ''; try { requestedId = decodeURIComponent(extractChatId(requestedUrl?.pathname ?? '') ?? ''); } catch { /* rejected below */ } const matchesAcceptedTransient = !capture.conversationUrlPending && requestedUrl?.origin === capturedUrl?.origin && requestedId === capturedId; if (!matchesAcceptedTransient && !conversationUrlsReferToSameThread(chatUrl, canonicalUrl)) { throw new Error('Requested conversation does not match the exact accepted target.'); } // This is a location candidate, not identity approval. The caller still waits // for the recorded exact user turn before exporting or activating an artifact. const { conversationUrlPending: _pending, ...canonicalCapture } = capture; return { ...canonicalCapture, chatUrl: canonicalUrl }; } export async function captureThreadTargetSnapshot( browserEndpoint: string, chatUrl: string, captureIdentity: ThreadCaptureIdentity, options: CaptureThreadTargetOptions = {}, ): Promise { const originalChatUrl = captureIdentity.chatUrl; captureIdentity = await resolveCapturedConversation(browserEndpoint, chatUrl, captureIdentity); const promotedTransient = originalChatUrl !== captureIdentity.chatUrl; if (captureIdentity.chatUrl !== chatUrl && /\/c\/WEB(?::|%3A)/iu.test(chatUrl)) chatUrl = captureIdentity.chatUrl; if (captureIdentity.browserEndpoint !== browserEndpoint) { throw new Error('Capture metadata browser endpoint does not match the requested endpoint.'); } if (!conversationUrlsReferToSameThread(captureIdentity.chatUrl, chatUrl)) { throw new Error('Capture metadata thread does not match the requested ChatGPT conversation.'); } const targetLease = await ensureTargetLease( browserEndpoint, chatUrl, captureIdentity.targetId, !promotedTransient, ); options.onTargetLease?.(targetLease); const client = new CdpClient(targetLease.target.webSocketDebuggerUrl); const captureDeadline = options.timeoutMs === undefined ? null : Date.now() + Math.max(1, options.timeoutMs); let captureSucceeded = false; try { await client.send('Runtime.enable'); await ensureThreadPageReady(client, chatUrl, { forceReload: options.forceReload, ...(captureDeadline === null ? {} : { timeoutMs: Math.max(1, captureDeadline - Date.now()) }), }); const snapshot = await waitForCapturedThreadIdentity( client, browserEndpoint, String(targetLease.target.id ?? ''), captureIdentity, captureDeadline === null ? SNAPSHOT_SETTLE_TIMEOUT_MS : Math.max(1, captureDeadline - Date.now()), ); captureSucceeded = true; return { captureIdentity, snapshot, targetLease }; } finally { client.close(); if (!captureSucceeded && targetLease.rehydrated) { await closeTarget(browserEndpoint, String(targetLease.target.id ?? '')); } } } export function completeDownloadCaptureIdentity( capture: ThreadCaptureIdentity, snapshot: ThreadSnapshot, ): ThreadCaptureIdentity { if (snapshot.stopVisible || snapshot.statusBusy || snapshotIndicatesBusy(snapshot) || !assistantSnapshotLooksTerminal(snapshot)) { throw new Error('Exact captured response is not complete; refuse artifact identity promotion.'); } const completed = completeThreadCaptureIdentity(capture, snapshot); if (completed.artifacts.length === 0) throw new Error('Completed exact response has no captured artifacts.'); return completed; } export async function recoverPendingDownloadCapture( browserEndpoint: string, chatUrl: string, capture: ThreadCaptureIdentity, timeoutMs: number, ): Promise { const captured = await captureThreadTargetSnapshot(browserEndpoint, chatUrl, capture, { timeoutMs }); return completeDownloadCaptureIdentity({ ...captured.captureIdentity, chatUrl: captured.captureIdentity.chatUrl, targetId: String(captured.targetLease.target.id ?? ''), }, captured.snapshot); } export async function exportThreadSnapshot( browserEndpoint: string, chatUrl: string, outputPath: string, options: ExportThreadSnapshotOptions = {}, ): Promise { const captured = options.captureIdentity ? await captureThreadTargetSnapshot(browserEndpoint, chatUrl, options.captureIdentity, options) : null; const targetLease = captured?.targetLease ?? await ensureTargetLease(browserEndpoint, chatUrl); if (!captured) { options.onTargetLease?.(targetLease); } let exportSucceeded = false; try { let snapshot = captured?.snapshot; if (!snapshot) { const client = new CdpClient(targetLease.target.webSocketDebuggerUrl); try { await client.send('Runtime.enable'); await ensureThreadPageReady(client, chatUrl, { forceReload: options.forceReload, }); snapshot = await scopeCapturedThreadSnapshot( browserEndpoint, String(targetLease.target.id ?? ''), await waitForSettledThreadSnapshot(client), undefined, ); } finally { client.close(); } } const payload: ExportedThreadSnapshot = { capturedAt: new Date().toISOString(), chatUrl, ...snapshot, }; await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile( outputPath, `${JSON.stringify( payload, (key, value) => key === 'deepResearchParentAnchor' ? undefined : value, 2, )}\n`, ); if (captured) options.onCaptureIdentity?.(captured.captureIdentity); exportSucceeded = true; return payload; } finally { if (!exportSucceeded && targetLease.rehydrated) { await closeTarget(browserEndpoint, String(targetLease.target.id ?? '')); } else { await closeTargetLeaseIfRequested(browserEndpoint, targetLease, options.targetLifecycle); } } } export async function downloadThreadAttachment( browserEndpoint: string, chatUrl: string, attachmentText: string, outputDir: string, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, selector: ThreadAttachmentDownloadSelector = {}, options: { captureIdentity?: ThreadCaptureIdentity; onTargetLease?: (lease: CdpTargetLease) => void; targetLifecycle?: ThreadTargetLifecycle; } = {}, ): Promise { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error('Download timeout must be a positive integer.'); } if (attachmentText.trim().length === 0 && selector.artifactIndex === undefined && normalizeAttachmentValue(selector.href).length === 0) { throw new Error('Attachment selection requires --attachment-text, --artifact-index, or a concrete href.'); } outputDir = path.resolve(outputDir); await mkdir(outputDir, { recursive: true }); const filesBeforeDownloadAttempt = await listDownloadDirectoryFiles(outputDir); if (options.captureIdentity) { if (options.captureIdentity.browserEndpoint !== browserEndpoint) { throw new Error('Capture metadata browser endpoint does not match the requested endpoint.'); } if (!conversationUrlsReferToSameThread(options.captureIdentity.chatUrl, chatUrl)) { throw new Error('Capture metadata thread does not match the requested ChatGPT conversation.'); } } const targetLease = await ensureTargetLease( browserEndpoint, chatUrl, options.captureIdentity?.targetId, Boolean(options.captureIdentity), ); options.onTargetLease?.(targetLease); const client = new CdpClient(targetLease.target.webSocketDebuggerUrl, timeoutMs); let downloadSucceeded = false; let captureValidated = !options.captureIdentity?.assistantResponse; let expectedContentSha256 = ''; const completeVerifiedDownload = async (downloadedFile: string): Promise => { await verifyDownloadedArtifact(downloadedFile, expectedContentSha256); downloadSucceeded = true; return downloadedFile; }; try { await client.send('Runtime.enable'); await client.send('Page.enable'); await client.send('Network.enable'); await ensureThreadPageReady(client, chatUrl, { reloadExistingThread: false, }); let effectiveAttachmentText = attachmentText; let effectiveSelector = selector; if (options.captureIdentity?.assistantResponse) { if (!Number.isInteger(selector.artifactIndex) || Number(selector.artifactIndex) < 0) { throw new Error('Exact capture metadata requires an artifact index before download activation.'); } const capturedArtifact = options.captureIdentity.artifacts[Number(selector.artifactIndex)]; if (!capturedArtifact) { throw new Error('Requested artifact index is not present in the exact waited capture metadata.'); } expectedContentSha256 = capturedArtifact.contentSha256 ?? ''; const exactSnapshot = await waitForCapturedThreadIdentity( client, browserEndpoint, String(targetLease.target.id ?? ''), options.captureIdentity, Math.min(timeoutMs, SNAPSHOT_SETTLE_TIMEOUT_MS), ); captureValidated = true; const liveArtifact = exactSnapshot.attachmentButtons[Number(selector.artifactIndex)]; if (!liveArtifact) { throw new Error('Requested artifact index did not resolve after exact capture validation.'); } effectiveAttachmentText = deriveAttachmentLabel(liveArtifact); effectiveSelector = { artifactIndex: selector.artifactIndex, artifactIndexInAssistantTurn: liveArtifact.artifactIndexInAssistantTurn, assistantTurnId: liveArtifact.assistantTurnId, assistantTurnIndex: liveArtifact.assistantTurnIndex, href: liveArtifact.href, }; } // Keep the existing hydrated thread tab alive for attachment clicks. Reloading here // can leave behavior buttons visible before ChatGPT rebinds their click handlers. await client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: path.resolve(outputDir), }); const downloadStartPromise = client.waitForEvent( (event) => event.method === 'Page.downloadWillBegin' && String(event.params?.suggestedFilename ?? '').length > 0, timeoutMs, ).then((event) => ({ event, kind: 'native-download' as const })); let nativeDownloadGuid = ''; // Subscribe before activating the control: a small completed file can emit // both events before the caller resumes from the download-start promise. const downloadCompletionPromise = client.waitForEvent((event) => { if (event.method === 'Page.downloadWillBegin' && !nativeDownloadGuid && String(event.params?.suggestedFilename ?? '').length > 0) { nativeDownloadGuid = String(event.params?.guid ?? ''); } return event.method === 'Page.downloadProgress' && Boolean(nativeDownloadGuid) && String(event.params?.guid ?? '') === nativeDownloadGuid && ['completed', 'canceled'].includes(String(event.params?.state ?? '')); }, timeoutMs); void downloadCompletionPromise.catch(() => {}); const estuaryResponsePromise = client.waitForEvent( (event) => { const response = getNetworkResponse(event); return ( event.method === 'Network.responseReceived' && String(response.url ?? '').includes('/backend-api/estuary/content') && Number(response.status ?? 0) >= 200 && Number(response.status ?? 0) < 300 ); }, timeoutMs, ).then((event) => ({ event, kind: 'estuary-response' as const })); // Either signal may win the download race. Observe the unused wait as well // so bounded socket shutdown cannot surface it as an unhandled rejection. void downloadStartPromise.catch(() => {}); void estuaryResponsePromise.catch(() => {}); try { const clicked = await clickAttachmentWithSelector(client, effectiveAttachmentText, timeoutMs, effectiveSelector); if (!clicked.found) { throw new Error( clicked.identityError ?? `Attachment button not found for ${effectiveAttachmentText || `artifact #${effectiveSelector.artifactIndex ?? '?'}`}. Available buttons: ${(clicked.availableButtons ?? []).join(' | ')}`, ); } const persistFetchedArtifact = async (artifactSignal: { event: CdpEvent; kind: 'estuary-response' }): Promise => { const fetchedArtifact = await client.evaluate<{ base64: string; contentDisposition: string | null; contentType: string | null; ok: boolean; status: number; }>(`(async () => { const response = await fetch(${JSON.stringify(String(getNetworkResponse(artifactSignal.event).url ?? ''))}, { credentials: 'include', }); const buffer = await response.arrayBuffer(); const bytes = new Uint8Array(buffer); let binary = ''; const chunkSize = 0x8000; for (let index = 0; index < bytes.length; index += chunkSize) { binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); } return { base64: btoa(binary), contentDisposition: response.headers.get('content-disposition'), contentType: response.headers.get('content-type'), ok: response.ok, status: response.status, }; })()`, { awaitPromise: true }); if (!fetchedArtifact.ok) { throw new Error(`Attachment fetch failed for ${effectiveAttachmentText} with status ${fetchedArtifact.status}.`); } const fallbackHeaderFilename = parseContentDispositionFilename(fetchedArtifact.contentDisposition) ?? parseContentDispositionFilename( String( getNetworkResponse(artifactSignal.event).headers?.['content-disposition'] ?? getNetworkResponse(artifactSignal.event).headers?.['Content-Disposition'] ?? '', ), ); const downloadedFile = path.join( path.resolve(outputDir), sanitizeDownloadFilename(fallbackHeaderFilename ?? clicked.hrefLabel ?? clicked.text ?? effectiveAttachmentText), ); await removeIfPresent(downloadedFile); await writeFile(downloadedFile, Buffer.from(fetchedArtifact.base64, 'base64')); return downloadedFile; }; const tryFetchArtifactFallback = async (): Promise => { try { const fallbackArtifactSignal = await Promise.race([ estuaryResponsePromise, sleep(Math.min(timeoutMs, LATE_NATIVE_DOWNLOAD_GRACE_MS)).then(() => null), ]); if (fallbackArtifactSignal?.kind === 'estuary-response') { return await persistFetchedArtifact(fallbackArtifactSignal); } } catch { // Preserve the original native-download error when no fetch fallback is available. } return null; }; const completeNativeDownload = async (downloadStart: CdpEvent): Promise => { const suggestedFilename = sanitizeDownloadFilename( String(downloadStart.params?.suggestedFilename ?? ''), sanitizeDownloadFilename(effectiveAttachmentText), ); const guid = String(downloadStart.params?.guid ?? ''); const downloadedFile = path.join(path.resolve(outputDir), suggestedFilename); await removeIfPresent(`${downloadedFile}.crdownload`); const completion = await downloadCompletionPromise; if (String(completion.params?.guid ?? '') !== guid || String(completion.params?.state ?? '') !== 'completed') { throw new Error('Native artifact download was canceled or changed identity.'); } try { await waitForDownloadedFile(downloadedFile, timeoutMs); } catch (error) { const fallbackDownloadedFile = await tryFetchArtifactFallback(); if (fallbackDownloadedFile) { return fallbackDownloadedFile; } throw error; } return downloadedFile; }; const earlySignal = await Promise.race([ downloadStartPromise, sleep(Math.min(timeoutMs, NATIVE_DOWNLOAD_GRACE_MS)).then(() => ({ kind: 'native-download-timeout' as const })), ]); if (earlySignal.kind === 'native-download') { const downloadedFile = await completeNativeDownload(earlySignal.event); return await completeVerifiedDownload(downloadedFile); } const fallbackSignal = await Promise.race([ downloadStartPromise, estuaryResponsePromise, sleep(Math.min(timeoutMs, LATE_NATIVE_DOWNLOAD_GRACE_MS)).then(() => ({ kind: 'late-native-timeout' as const })), ]); if (fallbackSignal.kind === 'native-download') { const downloadedFile = await completeNativeDownload(fallbackSignal.event); return await completeVerifiedDownload(downloadedFile); } const artifactSignal = fallbackSignal.kind === 'estuary-response' ? fallbackSignal : await Promise.race([downloadStartPromise, estuaryResponsePromise]); if (artifactSignal.kind === 'native-download') { const downloadedFile = await completeNativeDownload(artifactSignal.event); return await completeVerifiedDownload(downloadedFile); } const downloadedFile = await persistFetchedArtifact(artifactSignal); return await completeVerifiedDownload(downloadedFile); } catch (error) { const verifiedFile = await recoverVerifiedDownloadFile( path.join(path.resolve(outputDir), sanitizeDownloadFilename(effectiveAttachmentText)), filesBeforeDownloadAttempt, expectedContentSha256, ); if (verifiedFile) return await completeVerifiedDownload(verifiedFile); await removeEmptyDownloadFilesCreatedSince(outputDir, filesBeforeDownloadAttempt); if (/Timed out waiting for matching CDP event/u.test(normalizeError(error, '').message)) { throw new Error('No completed artifact download event. If this artifact opened a native Save dialog, finish it and retry using the unchanged capture metadata; do not resend the request.', { cause: error }); } throw error; } } finally { client.close(); if (!downloadSucceeded && targetLease.rehydrated && !captureValidated) { await closeTarget(browserEndpoint, String(targetLease.target.id ?? '')); } else { await closeTargetLeaseIfRequested(browserEndpoint, targetLease, options.targetLifecycle); } } }