diff --git a/client/src/capture/chatgpt-autorun.ts b/client/src/capture/chatgpt-autorun.ts index 0e345f6..1888f01 100644 --- a/client/src/capture/chatgpt-autorun.ts +++ b/client/src/capture/chatgpt-autorun.ts @@ -2,7 +2,7 @@ import { ChatGPTCaptureAdapter } from './chatgpt'; import { selectAcrossTimeline } from './select'; import { buildChatGptExport } from './chatgpt-export'; import { buildExtractionPrompt, buildExtractionSecondSweep, buildSynthesisFromEvidence, buildAuditPrompt } from './chatgpt-prompt'; -import { setComposer, clickSend, hasChallenge } from './chatgpt-bridge'; +import { setComposer, clickSend, hasChallenge, selectChatGptModel, type ChatGptModelPolicy } from './chatgpt-bridge'; import { importGptReply, CAPTURE_KEY } from '../run/import-chatgpt'; import { dedupeMoments, sameMoment, loadPool, savePool, mergePool, evictedConversations, type PoolUnit } from '../engine/evidence-pool'; import { dlog } from '../debug/dlog'; @@ -138,9 +138,10 @@ async function awaitConversationId( // Fill the composer and click ChatGPT's own send button, retrying while React enables the button // (and, in a freshly spawned worker tab, while the composer is still mounting). A visible challenge // means we cannot submit unattended, so surface it rather than hang. -async function submitPrompt(prompt: string, tries = 40, everyMs = 500): Promise { +async function submitPrompt(prompt: string, modelPolicy: ChatGptModelPolicy, tries = 40, everyMs = 500): Promise { for (let i = 0; i < tries; i++) { if (hasChallenge()) throw new Error('ChatGPT is showing a verification step. Open chatgpt.com, then run again.'); + await selectChatGptModel(modelPolicy).catch(() => false); if (setComposer(prompt) && clickSend()) return; await new Promise((r) => setTimeout(r, everyMs)); } @@ -241,13 +242,14 @@ interface TurnCtx { token: string; preTopId: string | null; id: string | null; + modelPolicy: ChatGptModelPolicy; lastNode?: string; onBound?: (id: string) => Promise | void; } async function runTurnIn(ctx: TurnCtx, prompt: string, timeoutMs: number, notify: Notify): Promise<{ text: string; node: string }> { for (let attempt = 1; ; attempt++) { try { - await submitPrompt(prompt); + await submitPrompt(prompt, ctx.modelPolicy); if (!ctx.id) { ctx.id = await awaitConversationId(ctx.token, ctx.preTopId, prompt); if (!ctx.id) throw new Error('ChatGPT did not start a conversation.'); @@ -454,7 +456,7 @@ async function loadCkpt(): Promise { export async function runExtractionBatch(batch: number, notify: Notify): Promise { const write = (out: { units?: RawUnit[]; failed?: true }) => chrome.storage.local.set({ [batchOutKey(batch)]: JSON.stringify(out) }); - const ctx: TurnCtx = { token: '', preTopId: null, id: null }; + const ctx: TurnCtx = { token: '', preTopId: null, id: null, modelPolicy: 'extract' }; try { const stored = (await chrome.storage.local.get(CAPTURE_KEY))[CAPTURE_KEY]; if (typeof stored !== 'string' || !stored) throw new Error('No capture bundle for the extraction batch.'); @@ -616,7 +618,7 @@ export async function runAutoProfile(notify: Notify): Promise { if (plan.staleConvoId) await deleteConversation(plan.staleConvoId, token); // old throwaway from the interrupted run await cleanupBatchLeftovers(token); // stray worker conversations/results from the interrupted run const startedAt = new Date().toISOString(); - const main: TurnCtx = { token, preTopId: await topConversationId(token), id: null }; + const main: TurnCtx = { token, preTopId: await topConversationId(token), id: null, modelPolicy: 'best' }; const ckpt = (synthText?: string): AutorunCheckpoint => ({ v: 2, startedAt, totalBatches: batches.length, doneBatches: [...doneBatches], diff --git a/client/src/capture/chatgpt-bridge.ts b/client/src/capture/chatgpt-bridge.ts index b06a4e7..18fd721 100644 --- a/client/src/capture/chatgpt-bridge.ts +++ b/client/src/capture/chatgpt-bridge.ts @@ -21,6 +21,132 @@ const COMPOSER_SELECTORS = [ const STOP_SELECTORS = ['[data-testid="stop-button"]', 'button[aria-label*="Stop"]', 'button[data-testid="composer-stop-button"]']; const SEND_SELECTORS = ['#composer-submit-button', 'button[data-testid="send-button"]', 'button[aria-label="Send prompt"]', 'button[aria-label*="Send"]', 'main form button[type="submit"]']; const CHALLENGE_SELECTORS = ['iframe[src*="challenges.cloudflare.com"]', '[id*="turnstile"]', '[class*="turnstile"]']; +const MODEL_PICKER_SELECTORS = [ + 'button[data-testid="model-switcher-dropdown-button"]', + 'button[aria-label*="model" i]', + 'button[aria-label*="ChatGPT" i]', +]; +const MODEL_ITEM_SELECTORS = [ + '[role="menuitem"]', + '[role="option"]', + 'button', +]; + +export type ChatGptModelPolicy = 'current' | 'extract' | 'best'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function normLabel(label: string): string { + return label.replace(/\s+/g, ' ').trim(); +} + +function isUnavailableModelLabel(label: string): boolean { + return /\b(upgrade|unavailable|limit reached|coming soon|disabled)\b/i.test(label); +} + +function isNonChatModelLabel(label: string): boolean { + return /\b(deep research|research|agent|operator|image|video|canvas|temporary chat|settings|customize)\b/i.test(label); +} + +export function scoreChatGptModelLabel(label: string, policy: Exclude): number { + const s = normLabel(label).toLowerCase(); + if (!s || isUnavailableModelLabel(s) || isNonChatModelLabel(s)) return -Infinity; + if (policy === 'extract') { + let score = 10; + if (/\binstant\b/.test(s)) score += 100; + if (/\bfast\b/.test(s)) score += 90; + if (/\bmini\b|4o-mini|o4-mini/.test(s)) score += 80; + if (/\bauto\b|\bdefault\b/.test(s)) score += 65; + if (/\b4o\b|gpt-4o/.test(s)) score += 55; + if (/\bgpt-5\b|\bgpt-4\b/.test(s)) score += 45; + if (/\bthinking\b|\breasoning\b|\bo[1-9]\b/.test(s)) score += 20; + if (/\bpro\b|\bextended\b/.test(s)) score -= 200; + return score; + } + + let score = 10; + if (/\bextended\b/.test(s)) score += 120; + if (/\bpro\b/.test(s)) score += 110; + if (/\bthinking\b|\breasoning\b/.test(s)) score += 90; + if (/\bgpt-5\b/.test(s)) score += 85; + if (/\bo[1-9]\b/.test(s)) score += 80; + if (/\bgpt-4\b|\b4o\b|gpt-4o/.test(s)) score += 60; + if (/\binstant\b|\bfast\b|\bmini\b/.test(s)) score += 25; + return score; +} + +export function chooseChatGptModelLabel( + labels: string[], policy: Exclude, +): string | null { + let best: { label: string; score: number; index: number } | null = null; + labels.forEach((raw, index) => { + const label = normLabel(raw); + const score = scoreChatGptModelLabel(label, policy); + if (!Number.isFinite(score)) return; + if (!best || score > best.score || (score === best.score && index < best.index)) best = { label, score, index }; + }); + return best?.label ?? null; +} + +function modelPickerText(el: HTMLElement): string { + return normLabel(`${el.getAttribute('aria-label') ?? ''} ${el.innerText ?? el.textContent ?? ''}`); +} + +function findModelPickerButton(): HTMLElement | null { + const direct = q1(MODEL_PICKER_SELECTORS); + if (direct) return direct; + const buttons = Array.from(document.querySelectorAll('button')) as HTMLElement[]; + return buttons + .map((button) => ({ button, score: Math.max(scoreChatGptModelLabel(modelPickerText(button), 'extract'), scoreChatGptModelLabel(modelPickerText(button), 'best')) })) + .filter((x) => Number.isFinite(x.score) && x.score > 0) + .sort((a, b) => b.score - a.score)[0]?.button ?? null; +} + +function modelMenuItems(): HTMLElement[] { + const seen = new Set(); + const out: HTMLElement[] = []; + for (const selector of MODEL_ITEM_SELECTORS) { + for (const el of Array.from(document.querySelectorAll(selector)) as HTMLElement[]) { + if (seen.has(el)) continue; + const text = normLabel(el.innerText ?? el.textContent ?? ''); + if (!text || isUnavailableModelLabel(text) || isNonChatModelLabel(text)) continue; + if (!Number.isFinite(Math.max(scoreChatGptModelLabel(text, 'extract'), scoreChatGptModelLabel(text, 'best')))) continue; + seen.add(el); + out.push(el); + } + } + return out; +} + +async function waitForModelItems(timeoutMs = 1500): Promise { + const start = Date.now(); + do { + const items = modelMenuItems(); + if (items.length) return items; + await sleep(100); + } while (Date.now() - start < timeoutMs); + return []; +} + +// Best-effort model switching for ChatGPT's web UI. The labels and DOM are not a stable API, so this +// intentionally fails open: if the menu cannot be read, the caller still submits with the current model. +export async function selectChatGptModel(policy: ChatGptModelPolicy): Promise { + if (policy === 'current') return true; + const picker = findModelPickerButton(); + if (!picker) return false; + picker.click(); + const items = await waitForModelItems(); + const labels = items.map((el) => normLabel(el.innerText ?? el.textContent ?? '')); + const choice = chooseChatGptModelLabel(labels, policy); + const item = choice ? items.find((el) => normLabel(el.innerText ?? el.textContent ?? '') === choice) : null; + if (!item) { + picker.click(); + return false; + } + item.click(); + await sleep(250); + return true; +} function q1(selectors: string[]): HTMLElement | null { for (const s of selectors) { const el = document.querySelector(s); if (el) return el as HTMLElement; } diff --git a/client/tests/capture/chatgpt-bridge.test.ts b/client/tests/capture/chatgpt-bridge.test.ts index 090b724..51919d8 100644 --- a/client/tests/capture/chatgpt-bridge.test.ts +++ b/client/tests/capture/chatgpt-bridge.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { watcherDecision, type WatchState } from '../../src/capture/chatgpt-bridge'; +import { chooseChatGptModelLabel, watcherDecision, type WatchState } from '../../src/capture/chatgpt-bridge'; const base: WatchState = { hasNewTurn: true, generating: false, everGenerated: true, hasText: true, textStableMs: 3000, elapsedMs: 5000, @@ -39,3 +39,21 @@ describe('watcherDecision', () => { expect(watcherDecision({ ...base, elapsedMs: 800000, hasNewTurn: false, hasText: false }, opts)).toBe('giveup'); }); }); + +describe('chooseChatGptModelLabel', () => { + it('uses a cheap capable model for extraction instead of Pro Extended', () => { + expect(chooseChatGptModelLabel(['Pro Extended', 'Thinking', 'Instant'], 'extract')).toBe('Instant'); + }); + + it('falls back to mini/fast style models for extraction when instant is absent', () => { + expect(chooseChatGptModelLabel(['GPT-5 Thinking', 'GPT-4o mini', 'Pro'], 'extract')).toBe('GPT-4o mini'); + }); + + it('uses the strongest normal chat model for synthesis and audit', () => { + expect(chooseChatGptModelLabel(['Instant', 'Thinking', 'Pro Extended'], 'best')).toBe('Pro Extended'); + }); + + it('ignores unavailable and non-chat tools in the model menu', () => { + expect(chooseChatGptModelLabel(['Deep research', 'Pro (limit reached)', 'Fast'], 'best')).toBe('Fast'); + }); +});