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
45 changes: 44 additions & 1 deletion src/core/parser-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as path from 'path';
import { execSync } from 'child_process';
import { describe, it, expect } from 'vitest';
import { EditLocIndex } from './edit-loc-diff';
import { parseClaudeSessions } from './parser-claude';
import { parseClaudeSessions, extractClaudeImagesFromJsonl } from './parser-claude';

/** os.tmpdir() on Windows often returns 8.3 short names (e.g. TAMASB~1)
* that don't match readdirSync output. Resolve to the long form so
Expand Down Expand Up @@ -594,3 +594,46 @@ describe('parseClaudeSessions', () => {
fs.rmSync(cwd, { recursive: true, force: true });
});
});

describe('extractClaudeImagesFromJsonl', () => {
it('extracts base64 images from a Claude user turn by request id', () => {
const png = 'iVBORw0KGgoAAAANSUhEUg==';
const line = JSON.stringify({
type: 'user',
timestamp: '2025-06-15T10:00:00Z',
sessionId: 'sess-1',
uuid: 'req-with-image',
message: {
role: 'user',
content: [
{ type: 'text', text: 'what is wrong here?' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } },
],
},
});
const other = JSON.stringify({ type: 'user', uuid: 'other-req', message: { role: 'user', content: [] } });

const images = extractClaudeImagesFromJsonl([other, line].join('\n'), 'req-with-image');
expect(images).toEqual([`data:image/png;base64,${png}`]);
});

it('returns no images for a request id that has none', () => {
const line = JSON.stringify({
type: 'user',
uuid: 'text-only',
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
});
expect(extractClaudeImagesFromJsonl(line, 'text-only')).toEqual([]);
expect(extractClaudeImagesFromJsonl(line, 'missing-id')).toEqual([]);
});

it('caps extracted images at four per request', () => {
const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } };
const line = JSON.stringify({
type: 'user',
uuid: 'many-images',
message: { role: 'user', content: [img, img, img, img, img, img] },
});
expect(extractClaudeImagesFromJsonl(line, 'many-images')).toHaveLength(4);
});
});
61 changes: 54 additions & 7 deletions src/core/parser-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,39 @@ function countClaudeImages(line: ClaudeLine): number {
.filter(block => block.type === 'image').length;
}

/**
* Extract inline screenshots for one Claude request. Claude Code stores images
* as base64 inside the user turn, and the request id is the entry uuid, so the
* matching line can be found directly. Capped at 4 images to bound memory, the
* same limit the VS Code extractor uses.
*/
export function extractClaudeImagesFromJsonl(raw: string, requestId: string): string[] {
const images: string[] = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
// Quick reject before the parse: most lines are not the one we want.
if (!trimmed || !trimmed.includes(requestId)) continue;

let entry: ClaudeLine;
try {
entry = JSON.parse(trimmed) as ClaudeLine;
} catch {
continue;
}
if (entry.uuid !== requestId) continue;

for (const block of toContentArray(entry.message?.content)) {
if (block.type !== 'image') continue;
const source = (block as { source?: { type?: string; media_type?: string; data?: string } }).source;
if (source?.type !== 'base64' || !source.data) continue;
images.push(`data:${source.media_type || 'image/png'};base64,${source.data}`);
if (images.length >= 4) return images;
}
return images;
}
return images;
}

function applyClaudeToolBlock(
block: ClaudeContentBlock,
data: Pick<ClaudeAssistantData, 'toolsUsed' | 'editedFiles' | 'referencedFiles' | 'skillsUsed' | 'editLocs'>,
Expand Down Expand Up @@ -467,11 +500,20 @@ function projectNameFromEncoded(encoded: string, _projectsDir: string): string {
return path.basename(resolved);
}

/** One Claude project directory's sessions, plus the file each came from. */
export interface ClaudeProjectResult {
sessions: Session[];
workspaceId: string;
workspaceName: string;
/** sessionId -> absolute path of the .jsonl the session was parsed from. */
sessionFiles: Map<string, string>;
}

function parseClaudeProjectSessions(
projectsDir: string,
dirName: string,
editLocIndex?: EditLocIndex,
): { sessions: Session[]; workspaceId: string; workspaceName: string } | null {
): ClaudeProjectResult | null {
const projPath = path.join(projectsDir, dirName);
const workspaceId = `claude-${dirName}`;
const workspaceName = projectNameFromEncoded(dirName, projectsDir);
Expand All @@ -488,12 +530,17 @@ function parseClaudeProjectSessions(
// can be merged into the right parent in pass 2.
const sessionsById = new Map<string, Session>();
const sessions: Session[] = [];
// sessionId -> originating file, so the image gallery can read screenshots
// back out of the raw log.
const sessionFiles = new Map<string, string>();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const session = parseClaudeSessionFile(path.join(projPath, entry.name), workspaceId, workspaceName, editLocIndex);
const filePath = path.join(projPath, entry.name);
const session = parseClaudeSessionFile(filePath, workspaceId, workspaceName, editLocIndex);
if (!session) continue;
sessions.push(session);
sessionsById.set(session.sessionId, session);
sessionFiles.set(session.sessionId, filePath);
}

// Pass 2: walk `<sessionId>/subagents/agent-*.jsonl` directories. Each
Expand Down Expand Up @@ -580,14 +627,14 @@ function parseClaudeProjectSessions(
session.requestCount = session.requests.length;
}

return sessions.length > 0 ? { sessions, workspaceId, workspaceName } : null;
return sessions.length > 0 ? { sessions, workspaceId, workspaceName, sessionFiles } : null;
}

export function parseClaudeSessions(
projectsDir: string,
editLocIndex?: EditLocIndex,
): { sessions: Session[]; workspaceId: string; workspaceName: string }[] {
const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = [];
): ClaudeProjectResult[] {
const results: ClaudeProjectResult[] = [];

let projectDirs: fs.Dirent[];
try {
Expand All @@ -608,8 +655,8 @@ export async function parseClaudeSessionsAsync(
projectsDir: string,
onProject?: (idx: number, total: number, name: string) => void,
editLocIndex?: EditLocIndex,
): Promise<{ sessions: Session[]; workspaceId: string; workspaceName: string }[]> {
const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = [];
): Promise<ClaudeProjectResult[]> {
const results: ClaudeProjectResult[] = [];

let projectDirs: string[];
try {
Expand Down
39 changes: 33 additions & 6 deletions src/core/parser-harnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,36 @@ import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from '.
import { findCodexDirs, parseCodexSessions } from './parser-codex';
import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode';
import { EditLocIndex } from './edit-loc-diff';
import { SessionSource } from './cache';

type WorkspaceMap = Map<string, Workspace>;

interface HarnessCollectionContext {
workspaces: WorkspaceMap;
sessions: Session[];
editLocIndex: EditLocIndex;
/** Lets the image gallery read raw screenshots back out of a session file. */
sessionSourceIndex?: Map<string, SessionSource>;
}

/** Register where each session was parsed from, when the caller tracks it. */
function recordSources(
ctx: HarnessCollectionContext,
result: { sessions: Session[]; workspaceId: string; workspaceName: string; sessionFiles: Map<string, string> },
harness: string,
): void {
if (!ctx.sessionSourceIndex) return;
for (const session of result.sessions) {
const filePath = result.sessionFiles.get(session.sessionId);
if (!filePath) continue;
ctx.sessionSourceIndex.set(session.sessionId, {
kind: 'cli-events',
filePath,
workspaceId: result.workspaceId,
workspaceName: result.workspaceName,
harness,
});
}
}

interface ExternalHarnessCollector {
Expand All @@ -39,8 +62,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [
name: 'Claude Code',
collectSync(ctx) {
for (const claudeDir of findClaudeDirs()) {
for (const { sessions } of parseClaudeSessions(claudeDir, ctx.editLocIndex)) {
for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
for (const result of parseClaudeSessions(claudeDir, ctx.editLocIndex)) {
for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
recordSources(ctx, result, 'Claude');
}
}
},
Expand All @@ -49,8 +73,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [
const results = await parseClaudeSessionsAsync(claudeDir, (idx, total, name) => {
reportDetail?.(`${idx}/${total}: ${name}`);
}, ctx.editLocIndex);
for (const { sessions } of results) {
for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
for (const result of results) {
for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir);
recordSources(ctx, result, 'Claude');
}
}
},
Expand Down Expand Up @@ -97,8 +122,9 @@ export function collectExternalHarnessesSync(
workspaces: WorkspaceMap,
sessions: Session[],
editLocIndex: EditLocIndex,
sessionSourceIndex?: Map<string, SessionSource>,
): void {
const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex };
const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex };
for (const harness of EXTERNAL_HARNESSES) {
harness.collectSync(ctx);
}
Expand All @@ -119,8 +145,9 @@ export async function collectExternalHarnessesAsync(
sessions: Session[],
editLocIndex: EditLocIndex,
handlers: ExternalHarnessProgressHandlers = {},
sessionSourceIndex?: Map<string, SessionSource>,
): Promise<void> {
const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex };
const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex };
const total = EXTERNAL_HARNESSES.length;

for (let index = 0; index < EXTERNAL_HARNESSES.length; index++) {
Expand Down
3 changes: 3 additions & 0 deletions src/core/parser-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ describe('parseAllLogs', () => {
result.workspaces,
result.sessions,
result.editLocIndex,
result.sessionSourceIndex,
);
});

Expand Down Expand Up @@ -293,6 +294,7 @@ describe('parseAllLogsAsyncDetailed', () => {
cachedResult.sessions,
cachedResult.editLocIndex,
expect.any(Object),
cachedResult.sessionSourceIndex,
);
});

Expand Down Expand Up @@ -435,6 +437,7 @@ describe('parseAllLogsAsyncDetailed', () => {
parsed.result.sessions,
parsed.result.editLocIndex,
expect.any(Object),
parsed.result.sessionSourceIndex,
);
});

Expand Down
6 changes: 5 additions & 1 deletion src/core/parser-vscode-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { StringDecoder } from 'string_decoder';
import { assertTrustedPath, prefetchCache, readFileSafe, recordFailedFile, recordSkippedLines } from './parser-shared';
import { fileUriToPath } from './helpers';
import { debugCore, warnCore } from './log';
import { extractClaudeImagesFromJsonl } from './parser-claude';

export function readFile(fpath: string): string {
assertTrustedPath(fpath);
Expand Down Expand Up @@ -480,7 +481,10 @@ export function extractSessionImages(filePath: string, requestId: string): strin
if (raw === null) return [];

if (filePath.endsWith('.jsonl')) {
return extractImagesFromJsonl(raw, requestId);
const images = extractImagesFromJsonl(raw, requestId);
// Claude Code also uses .jsonl but stores images as base64 in the user
// turn rather than in VS Code's variableData shape.
return images.length > 0 ? images : extractClaudeImagesFromJsonl(raw, requestId);
}
return extractImagesFromJson(raw, requestId);
} catch (e) {
Expand Down
13 changes: 7 additions & 6 deletions src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ async function tryMemoryCache(
removeRefreshableExternalEditLocs(mem.result.sessions, mem.result.editLocIndex);
mem.result.sessions = mem.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness));
await collectVolatileHarnesses(logsDirs, mem.result, onProgress);
await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress);
await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress, mem.result.sessionSourceIndex);
report({
phase: 1, detail: 'Loaded from memory', pct: pct(1, 1),
sessions: mem.result.sessions.length,
Expand All @@ -373,7 +373,7 @@ async function tryDiskCache(
removeRefreshableExternalEditLocs(cached.result.sessions, cached.result.editLocIndex);
cached.result.sessions = cached.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness));
await collectVolatileHarnesses(logsDirs, cached.result, onProgress);
await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress);
await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress, cached.result.sessionSourceIndex);
setMemoryCache(cached.result, currentMetas);
report({
phase: 1, detail: 'Loaded from cache', pct: pct(1, 1),
Expand Down Expand Up @@ -580,6 +580,7 @@ async function collectExternalHarnesses(
sessions: import('./types').Session[],
editLocIndex: EditLocIndex,
onProgress?: ProgressCallback,
sessionSourceIndex?: Map<string, SessionSource>,
): Promise<void> {
await collectExternalHarnessesAsync(workspaces, sessions, editLocIndex, {
onHarnessStart: (name, index, total, sessionCount) => {
Expand All @@ -592,7 +593,7 @@ async function collectExternalHarnesses(
warnCore('parser', `${name} scan failed`, error);
},
yieldToLoop,
});
}, sessionSourceIndex);
}

export function parseAllLogs(logsDirs: string[]): ParseResult {
Expand All @@ -619,7 +620,7 @@ export function parseAllLogs(logsDirs: string[]): ParseResult {
}
}

collectExternalHarnessesSync(workspaces, sessions, editLocIndex);
collectExternalHarnessesSync(workspaces, sessions, editLocIndex, sessionSourceIndex);

stripSessionsForMemory(sessions);
return { workspaces, sessions, editLocIndex, sessionSourceIndex };
Expand Down Expand Up @@ -737,7 +738,7 @@ export async function parseAllLogsAsyncDetailed(
editLocIndex,
sessionSourceIndex: freshSessionSourceIndex,
}, onProgress);
await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress);
await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress, freshSessionSourceIndex);

const result: ParseResult = { workspaces, sessions: freshSessions, editLocIndex, sessionSourceIndex: freshSessionSourceIndex };
stripSessionsForMemory(result.sessions);
Expand All @@ -760,7 +761,7 @@ export async function parseAllLogsAsyncDetailed(
await processWorkspaces(entries, totalDirs, ctx, onProgress);

await collectXcode(xcodeDirs, workspaces, sessions, editLocIndex, onProgress);
await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress);
await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress, sessionSourceIndex);

const result: ParseResult = { workspaces, sessions, editLocIndex, sessionSourceIndex };
stripSessionsForMemory(result.sessions);
Expand Down