diff --git a/client/entrypoints/claude.content.ts b/client/entrypoints/claude.content.ts index ea4b6ae..9049736 100644 --- a/client/entrypoints/claude.content.ts +++ b/client/entrypoints/claude.content.ts @@ -11,7 +11,6 @@ import { ensureUserKey } from '../src/store/userkey'; import { BackendSync, needsRepushKey } from '../src/sync/backend'; import { carryOverSharing } from '../src/sync/signal-state'; import { BACKEND_URL, INVITE_TOKEN } from '../src/config'; -import { pickModels } from '../src/engine/models'; import { selectAcrossTimeline } from '../src/capture/select'; import { maybeMountReveal } from '../src/ui/reveal'; import { captureGlobalErrors } from '../src/debug/dlog'; @@ -64,11 +63,10 @@ export default defineContentScript({ const fullList = await adapter.listConversations(); const conversations = selectAcrossTimeline(fullList, 90); - // Choose models from what this account actually has: Sonnet-or-better for evidence - // extraction (Haiku under-mined reaction evidence), the best for synthesis. Never - // assume a tier. - const { extract, best } = pickModels(conversations.map((c) => c.model)); - const caller = new InSessionClaudeCaller(org, best ?? conversations[0]?.model ?? null); + // The list records historical model ids, not the account's current entitlements. Let + // Claude.ai choose the active subscription-compatible default to avoid 403 rejections + // from stale or unavailable ids. + const caller = new InSessionClaudeCaller(org); activeCaller = caller; // Incremental extraction: fetch + extract only conversations that are new or changed since @@ -97,13 +95,12 @@ export default defineContentScript({ try { const profile = await buildProfile(convos, caller, { version, now, - modelProvenance: `claude-in-session (${extract ?? '?'} + ${best ?? '?'})`, - extractModel: extract ?? undefined, bestModel: best ?? undefined, + modelProvenance: 'claude-in-session (current Claude default)', // Budget sized for ~90 time-spread conversations at the calibration-validated // 2500 chars each (~225k total): still 6 chunks x 48k, and the scratch-conversation - // caller is API-parallel, so 3-wide extraction keeps wall-clock roughly flat vs - // the old 40-conversation run while more than doubling the window. - maxChars: 48000, maxChunks: 6, perConvoChars: 2500, concurrency: 3, + // Scratch conversation creation is rate-limited on Claude.ai. Two workers avoid the + // burst of 429s seen with three while still keeping the first full scan practical. + maxChars: 48000, maxChunks: 6, perConvoChars: 2500, concurrency: 2, priorEvidence: priorPool, onEvidencePool: (units) => { runPool = units.map(({ id: _id, ...u }) => u); }, sourceWindow: { @@ -151,7 +148,7 @@ export default defineContentScript({ } } } catch (e) { console.warn('[aibadges] sync failed (non-fatal)', e); synced = `error: ${String(e)}`; } - console.log('[aibadges] done', { version, capturedChars, extract, best, thinking: profile.thinking.length, type: profile.type?.code ?? null, shifts: profile.trajectory.shifts.length, synced }); + console.log('[aibadges] done', { version, capturedChars, model: 'current Claude default', thinking: profile.thinking.length, type: profile.type?.code ?? null, shifts: profile.trajectory.shifts.length, synced }); return version; } finally { activeCaller = null; await caller.dispose(); } })() diff --git a/client/src/inference/in-session.ts b/client/src/inference/in-session.ts index 52cecec..a0dbd16 100644 --- a/client/src/inference/in-session.ts +++ b/client/src/inference/in-session.ts @@ -2,6 +2,9 @@ import type { ModelCaller } from './types'; type FetchFn = (url: string, init?: RequestInit) => Promise; +interface ClaudeContentBlock { type?: string; text?: string } +interface ClaudeConversationMessage { sender?: string; text?: string; content?: ClaudeContentBlock[] } + const BASE = 'https://claude.ai/api'; const ROOT_PARENT = '00000000-0000-4000-8000-000000000000'; const DEFAULT_TIMEOUT_MS = 75000; @@ -64,6 +67,10 @@ function backoffMs(res: Response | null, attempt: number, baseMs: number): numbe export function extractTextFromSse(sse: string): string { let text = ''; + // Claude.ai has used both a legacy `completion` stream and the newer Messages API + // `content_block_delta` stream. A response should use one format, but lock onto the + // first text family we see so a transitional stream cannot duplicate its output. + let format: 'completion' | 'content_block_delta' | null = null; for (const line of sse.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; @@ -71,17 +78,39 @@ export function extractTextFromSse(sse: string): string { if (!payload || payload === '[DONE]') continue; try { const evt = JSON.parse(payload); - if (evt?.type === 'content_block_delta' && evt?.delta?.type === 'text_delta') text += evt.delta.text ?? ''; + if (evt?.type === 'completion' && typeof evt.completion === 'string' && format !== 'content_block_delta') { + format = 'completion'; + text += evt.completion; + } else if (evt?.type === 'content_block_delta' && evt?.delta?.type === 'text_delta' && format !== 'completion') { + format = 'content_block_delta'; + text += evt.delta.text ?? ''; + } } catch { /* keepalive / non-JSON */ } } return text; } +function assistantMessageText(body: unknown): string { + const messages = (body as { chat_messages?: unknown })?.chat_messages; + if (!Array.isArray(messages)) return ''; + const message = [...messages].reverse().find((m): m is ClaudeConversationMessage => + !!m && typeof m === 'object' && (m as ClaudeConversationMessage).sender === 'assistant', + ); + if (!message) return ''; + const blocks = Array.isArray(message.content) ? message.content : []; + const blockText = blocks + .filter((b) => b && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join(''); + return blockText || message.text || ''; +} + /** * Runs profiling prompts in the user's own Claude.ai session. Each call creates a labeled scratch * conversation, streams the completion, and deletes it. Every call is bounded by a timeout via * AbortController — a stalled/throttled completion aborts (and is cleaned up) instead of hanging the - * whole run forever. Model is chosen per call (fast for bulk extraction, best for synthesis). + * whole run forever. A caller may pin a known-valid model, but callers should otherwise let + * Claude.ai select the account's current default rather than replaying model ids from history. */ export class InSessionClaudeCaller implements ModelCaller { private scratchIds = new Set(); @@ -102,7 +131,9 @@ export class InSessionClaudeCaller implements ModelCaller { } async complete(prompt: string, opts?: { system?: string; model?: string; timeoutMs?: number }): Promise { - const model = opts?.model ?? this.model; + // Conversation metadata describes models used in the past, not models this account is allowed + // to start today. Sending those stale ids causes Claude.ai to reject every completion with 403. + const model = this.model; const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; let lastStatus = 0; // In-session completions are rate-limited (429) and occasionally overloaded (529), @@ -168,7 +199,23 @@ export class InSessionClaudeCaller implements ModelCaller { }) }, ); if (!compRes.ok) return { ok: false, status: compRes.status, phase: 'completion', res: compRes, body: await compRes.text().catch(() => '') }; - return { ok: true, text: extractTextFromSse(await compRes.text()) }; + const streamed = extractTextFromSse(await compRes.text()); + if (streamed.trim()) return { ok: true, text: streamed }; + + // Claude.ai's private endpoint has changed its SSE envelope more than once. The completed + // message is also persisted to the scratch conversation, which is a stable fallback when + // an unfamiliar envelope carries no extractable text. + try { + const messageRes = await this.fetchFn( + `${BASE}/organizations/${encodeURIComponent(this.orgId)}/chat_conversations/${convId}?tree=True&rendering_mode=messages`, + { credentials: 'include', signal: ctrl.signal }, + ); + if (messageRes.ok) { + const saved = assistantMessageText(await messageRes.json()); + if (saved.trim()) return { ok: true, text: saved }; + } + } catch { /* the stream result remains authoritative when this best-effort fallback fails */ } + return { ok: true, text: streamed }; } finally { clearTimeout(timer); this.active.delete(ctrl); diff --git a/client/tests/inference/in-session.test.ts b/client/tests/inference/in-session.test.ts index 85efdd6..cf721f8 100644 --- a/client/tests/inference/in-session.test.ts +++ b/client/tests/inference/in-session.test.ts @@ -17,12 +17,29 @@ const SSE = [ '', ].join('\r\n'); +// Claude.ai's older browser completion endpoint streams text in `completion`, not +// a Messages API content block. This is the shape returned in the reported failure. +const LEGACY_SSE = [ + 'event: completion', + 'data: {"type":"completion","completion":"{\\"aiFluency\\":"}', + '', + 'event: completion', + 'data: {"type":"completion","completion":"{}}"}', + '', +].join('\r\n'); + type FetchFn = (url: string, init?: RequestInit) => Promise; describe('extractTextFromSse', () => { it('concatenates text_delta chunks and ignores other events', () => { expect(extractTextFromSse(SSE)).toBe('OK'); }); + it('concatenates legacy completion chunks from Claude.ai', () => { + expect(extractTextFromSse(LEGACY_SSE)).toBe('{"aiFluency":{}}'); + }); + it('does not duplicate text when both stream shapes are present', () => { + expect(extractTextFromSse(`${SSE}\n${LEGACY_SSE}`)).toBe('OK'); + }); it('returns empty string when there are no text deltas', () => { expect(extractTextFromSse('event: ping\r\ndata: {"type":"ping"}\r\n\r\n')).toBe(''); }); @@ -51,6 +68,50 @@ describe('InSessionClaudeCaller', () => { expect(calls.some(c => c.method === 'DELETE')).toBe(true); }); + it('does not send a historical per-call model id when the caller has no pinned model', async () => { + let completionBody: Record | null = null; + const fetchFn: FetchFn = async (url, init) => { + const method = init?.method ?? 'GET'; + if (method === 'POST' && url.endsWith('/chat_conversations')) { + return { ok: true, status: 201, json: async () => ({ uuid: 's1' }) } as unknown as Response; + } + if (url.includes('/completion')) { + completionBody = JSON.parse(String(init?.body)); + return { ok: true, status: 200, text: async () => SSE } as unknown as Response; + } + if (method === 'DELETE') return { ok: true, status: 204 } as unknown as Response; + throw new Error(`unexpected ${method} ${url}`); + }; + const caller = new InSessionClaudeCaller('org1', null, fetchFn); + await caller.complete('hello', { model: 'stale-history-model' }); + expect(completionBody?.model).toBeUndefined(); + }); + + it('reads the saved assistant message when Claude uses an unknown stream envelope', async () => { + const fetchFn: FetchFn = async (url, init) => { + const method = init?.method ?? 'GET'; + if (method === 'POST' && url.endsWith('/chat_conversations')) { + return { ok: true, status: 201, json: async () => ({ uuid: 's1' }) } as unknown as Response; + } + if (url.includes('/completion')) { + return { ok: true, status: 200, text: async () => 'data: {"type":"unknown_envelope"}\n\n' } as unknown as Response; + } + if (method === 'GET' && url.includes('tree=True')) { + return { + ok: true, status: 200, + json: async () => ({ chat_messages: [ + { sender: 'human', text: 'ignored' }, + { sender: 'assistant', content: [{ type: 'text', text: '{"aiFluency":{}}' }] }, + ] }), + } as unknown as Response; + } + if (method === 'DELETE') return { ok: true, status: 204 } as unknown as Response; + throw new Error(`unexpected ${method} ${url}`); + }; + const caller = new InSessionClaudeCaller('org1', null, fetchFn); + await expect(caller.complete('hello')).resolves.toBe('{"aiFluency":{}}'); + }); + it('still deletes the scratch conversation when the completion fails', async () => { let deleted = false; const fetchFn: FetchFn = async (url, init) => {