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
21 changes: 9 additions & 12 deletions client/entrypoints/claude.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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(); }
})()
Expand Down
55 changes: 51 additions & 4 deletions client/src/inference/in-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { ModelCaller } from './types';

type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;

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;
Expand Down Expand Up @@ -64,24 +67,50 @@ 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;
const payload = trimmed.slice('data:'.length).trim();
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<string>();
Expand All @@ -102,7 +131,9 @@ export class InSessionClaudeCaller implements ModelCaller {
}

async complete(prompt: string, opts?: { system?: string; model?: string; timeoutMs?: number }): Promise<string> {
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),
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions client/tests/inference/in-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;

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('');
});
Expand Down Expand Up @@ -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<string, unknown> | 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) => {
Expand Down