Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions client/src/capture/chatgpt-autorun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
async function submitPrompt(prompt: string, modelPolicy: ChatGptModelPolicy, tries = 40, everyMs = 500): Promise<void> {
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));
}
Expand Down Expand Up @@ -241,13 +242,14 @@ interface TurnCtx {
token: string;
preTopId: string | null;
id: string | null;
modelPolicy: ChatGptModelPolicy;
lastNode?: string;
onBound?: (id: string) => Promise<void> | 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.');
Expand Down Expand Up @@ -454,7 +456,7 @@ async function loadCkpt(): Promise<unknown> {
export async function runExtractionBatch(batch: number, notify: Notify): Promise<void> {
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.');
Expand Down Expand Up @@ -616,7 +618,7 @@ export async function runAutoProfile(notify: Notify): Promise<void> {
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],
Expand Down
126 changes: 126 additions & 0 deletions client/src/capture/chatgpt-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatGptModelPolicy, 'current'>): 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<ChatGptModelPolicy, 'current'>,
): 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<HTMLElement>();
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<HTMLElement[]> {
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<boolean> {
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; }
Expand Down
20 changes: 19 additions & 1 deletion client/tests/capture/chatgpt-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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');
});
});