From 25c4ecb9c3c2ba3c4194b57c8589d11a1fa57c3d Mon Sep 17 00:00:00 2001 From: "Horatiu A." <57032729+hora7ce@users.noreply.github.com> Date: Tue, 26 May 2026 10:34:33 +0300 Subject: [PATCH 1/3] fix: discover .vscode-server workspaceStorage dirs for WSL / Remote SSH / devcontainer findVsCodeDirs() only scanned desktop installation paths (~/.config/Code, AppData, ~/Library/...) and missed the VS Code Server path used by WSL2, Remote SSH, and Dev Containers: ~/.vscode-server/data/User/workspaceStorage ~/.vscode-server-insiders/data/User/workspaceStorage Add both server editions to the scan on non-Windows platforms. Also extend harnessFromPath() with .vscode-server-insiders and .vscode-server checks (ordered most-specific first to avoid the Insiders path matching the plain .vscode-server substring) so sessions discovered via these paths are labelled 'Local Agent (Server)' or 'Local Agent (Server Insiders)' rather than the fallback 'Local Agent'. Fixes #62 --- src/core/parser-vscode.test.ts | 19 ++++++++++++++++++- src/core/parser-vscode.ts | 11 +++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index 9547a90..edec87b 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -742,4 +742,21 @@ describe('parseSessionFile — skill detection', () => { expect(session!.requests[0].skillsUsed).toContain('playwright-cli'); }); }); -}); \ No newline at end of file +}); +describe('harnessFromPath — VS Code Server', () => { + it('returns "Local Agent (Server)" for .vscode-server paths', () => { + expect(harnessFromPath('/home/alice/.vscode-server/data/User/workspaceStorage')).toBe('Local Agent (Server)'); + }); + + it('returns "Local Agent (Server Insiders)" for .vscode-server-insiders paths', () => { + expect(harnessFromPath('/home/alice/.vscode-server-insiders/data/User/workspaceStorage')).toBe('Local Agent (Server Insiders)'); + }); + + it('does not match .vscode-server-insiders as plain .vscode-server', () => { + // .vscode-server-insiders contains the string ".vscode-server" — ensure + // the more-specific check fires first. + const result = harnessFromPath('/home/alice/.vscode-server-insiders/data/User/workspaceStorage'); + expect(result).toBe('Local Agent (Server Insiders)'); + expect(result).not.toBe('Local Agent (Server)'); + }); +}); diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index 048daca..dfe2a81 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -20,6 +20,8 @@ function isObj(v: unknown): v is Record { export function harnessFromPath(logsDir: string): string { if (logsDir.includes('Code - Insiders')) return 'Local Agent (Insiders)'; + if (logsDir.includes('.vscode-server-insiders')) return 'Local Agent (Server Insiders)'; + if (logsDir.includes('.vscode-server')) return 'Local Agent (Server)'; if (logsDir.includes('.copilot')) return 'GitHub Copilot CLI'; return 'Local Agent'; } @@ -42,6 +44,15 @@ export function findVsCodeDirs(): string[] { if (vsPath && fs.existsSync(vsPath) && !dirs.includes(vsPath)) dirs.push(vsPath); } + // VS Code Server (remote/SSH/devcontainer) paths + if (process.platform !== 'win32' && home) { + const serverEditions = ['.vscode-server', '.vscode-server-insiders']; + for (const serverDir of serverEditions) { + const serverPath = path.join(home, serverDir, 'data', 'User', 'workspaceStorage'); + if (fs.existsSync(serverPath) && !dirs.includes(serverPath)) dirs.push(serverPath); + } + } + // Copilot CLI paths const cliActive = path.join(home, '.copilot', 'session-state'); const cliLegacy = path.join(home, '.copilot', 'history-session-state'); From 25b2e2558a39eefbd104b65ea8882d31fb1f37cd Mon Sep 17 00:00:00 2001 From: "Horatiu A." <57032729+hora7ce@users.noreply.github.com> Date: Wed, 27 May 2026 08:52:59 +0300 Subject: [PATCH 2/3] docs+test: address code review feedback on PR 63 - README.extension.md: add Local Agent (Server) and Local Agent (Server Insiders) rows to Supported Harnesses table - docs/content/_index.md: add server harness row to Multi-Harness Support table - docs/content/getting-started/supported-tools.md: note Remote-WSL/SSH/devcontainer log paths under Local Agent section - parser-vscode.ts: tighten harnessFromPath ordering comment (substring collision) and findVsCodeDirs platform guard comment per reviewer suggestions - parser-vscode.test.ts: add findVsCodeDirs test covering server workspaceStorage path inclusion via temporary home directory --- README.extension.md | 2 ++ docs/content/_index.md | 1 + .../getting-started/supported-tools.md | 2 ++ src/core/parser-vscode.test.ts | 31 +++++++++++++++++-- src/core/parser-vscode.ts | 4 ++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.extension.md b/README.extension.md index 3b126f3..e5c2194 100644 --- a/README.extension.md +++ b/README.extension.md @@ -55,6 +55,8 @@ The extension is organized into three sections: **Observe**, **Measure**, and ** | --- | --- | | **Local Agent** | macOS: `~/Library/Application Support/Code/User/workspaceStorage/`
Linux: `~/.config/Code/User/workspaceStorage/`
Windows: `%APPDATA%\Code\User\workspaceStorage\` | | **Local Agent (Insiders)** | macOS: `~/Library/Application Support/Code - Insiders/User/workspaceStorage/`
Linux: `~/.config/Code - Insiders/User/workspaceStorage/`
Windows: `%APPDATA%\Code - Insiders\User\workspaceStorage\` | +| **Local Agent (Server)** | Linux/macOS remote host: `~/.vscode-server/data/User/workspaceStorage/` | +| **Local Agent (Server Insiders)** | Linux/macOS remote host: `~/.vscode-server-insiders/data/User/workspaceStorage/` | | **Xcode Copilot Chat** | `~/.config/github-copilot/xcode/` (requires `sqlite3`) | | **Claude** | macOS/Linux: `~/.claude/projects/`
Windows: `%USERPROFILE%\.claude\projects\` | | **Codex** | macOS/Linux: `~/.codex/sessions/`
Windows: `%USERPROFILE%\.codex\sessions\` | diff --git a/docs/content/_index.md b/docs/content/_index.md index 72f29e2..34d003b 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -13,6 +13,7 @@ AI Engineer Coach reads logs from multiple AI coding tools: | Harness | Source | |---|---| | **Local Agent / Local Agent (Insiders)** | Chat panel logs in the extension host directory (VS Code / VS Code Insiders) | +| **Local Agent (Server) / Local Agent (Server Insiders)** | Remote host chat panel logs under `~/.vscode-server/data/User/workspaceStorage/` or `~/.vscode-server-insiders/data/User/workspaceStorage/` | | **GitHub Copilot for Xcode** | Copilot Chat conversations from Apple's Xcode IDE | | **Claude** | Session files from Anthropic's CLI-based coding assistant | | **Codex** | Session history from OpenAI's terminal agent | diff --git a/docs/content/getting-started/supported-tools.md b/docs/content/getting-started/supported-tools.md index fdc71eb..55e81c8 100644 --- a/docs/content/getting-started/supported-tools.md +++ b/docs/content/getting-started/supported-tools.md @@ -12,6 +12,8 @@ AI Engineer Coach reads local log files from the following AI coding assistants. The primary harness. AI Engineer Coach parses the chat panel logs that GitHub Copilot writes to the VS Code extension host log directory. This captures every request, response, model used, token counts, tool calls, file references, and terminal commands. +When VS Code connects through Remote-WSL, Remote-SSH, or a Dev Container, the logs live on the remote host under `~/.vscode-server/data/User/workspaceStorage/` (or `~/.vscode-server-insiders/data/User/workspaceStorage/` for Insiders) and appear in the dashboard as `Local Agent (Server)` or `Local Agent (Server Insiders)`. + **What is tracked:** - Requests and responses with timestamps - Model selection (e.g., `claude-opus-4.6`, `gpt-5.4`, `auto`) diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index edec87b..a307bbe 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -6,10 +6,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { reconstructFromJsonl } from './parser-vscode-files'; import { parseCLIEventsFile } from './parser-vscode-cli'; -import { parseSessionFile, harnessFromPath, scanVsCodeDirs } from './parser-vscode'; +import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; function withTempFile(name: string, content: string, run: (filePath: string) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-')); @@ -760,3 +760,30 @@ describe('harnessFromPath — VS Code Server', () => { expect(result).not.toBe('Local Agent (Server)'); }); }); + +describe('findVsCodeDirs — VS Code Server', () => { + it('includes server workspaceStorage paths on non-Windows hosts', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-vscode-')); + const home = process.env.HOME; + const userProfile = process.env.USERPROFILE; + const expected = [ + path.join(root, '.config', 'Code', 'User', 'workspaceStorage'), + path.join(root, '.config', 'Code - Insiders', 'User', 'workspaceStorage'), + path.join(root, '.vscode-server', 'data', 'User', 'workspaceStorage'), + path.join(root, '.vscode-server-insiders', 'data', 'User', 'workspaceStorage'), + ]; + + for (const dir of expected) fs.mkdirSync(dir, { recursive: true }); + + process.env.HOME = root; + process.env.USERPROFILE = ''; + + try { + expect(findVsCodeDirs()).toEqual(expected); + } finally { + process.env.HOME = home; + process.env.USERPROFILE = userProfile; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index dfe2a81..00d0cdf 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -20,6 +20,8 @@ function isObj(v: unknown): v is Record { export function harnessFromPath(logsDir: string): string { if (logsDir.includes('Code - Insiders')) return 'Local Agent (Insiders)'; + // Check .vscode-server-insiders BEFORE .vscode-server — the latter is a + // substring of the former and would match incorrectly if checked first. if (logsDir.includes('.vscode-server-insiders')) return 'Local Agent (Server Insiders)'; if (logsDir.includes('.vscode-server')) return 'Local Agent (Server)'; if (logsDir.includes('.copilot')) return 'GitHub Copilot CLI'; @@ -44,7 +46,7 @@ export function findVsCodeDirs(): string[] { if (vsPath && fs.existsSync(vsPath) && !dirs.includes(vsPath)) dirs.push(vsPath); } - // VS Code Server (remote/SSH/devcontainer) paths + // VS Code Server only runs on the remote host (Linux/macOS), not on Windows directly. if (process.platform !== 'win32' && home) { const serverEditions = ['.vscode-server', '.vscode-server-insiders']; for (const serverDir of serverEditions) { From 7271273e78465d02519405f356fc9d699c11bd3a Mon Sep 17 00:00:00 2001 From: "Horatiu A." <57032729+hora7ce@users.noreply.github.com> Date: Wed, 27 May 2026 09:41:29 +0300 Subject: [PATCH 3/3] feat: parse GitHub.copilot-chat/transcripts/*.jsonl event-stream format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #64. VS Code stores Copilot Chat sessions in two locations inside each workspace's workspaceStorage entry: 1. chatSessions/*.{json,jsonl} — existing format (already parsed) 2. GitHub.copilot-chat/transcripts/*.jsonl — newer event-stream format (silently ignored until now) This commit adds support for the second format. ## New helpers (parser-vscode.ts) - listTranscriptFiles(dir) — lists *.jsonl files in a transcripts/ dir - parseTranscriptLines(raw) — parses JSONL text into TranscriptEvent[] - buildToolNameIndex(events) — pre-indexes toolCallId → toolName - collectToolsFromToolRequests(...) — extracts tool names from assistant message toolRequests arrays with fallback to the pre-built index - buildRequestsFromTranscriptEvents(events, toolNames) — groups events into per-turn SessionRequest[] (one request per user.message) - parseTranscriptFile(filePath, wsId, wsName, harness, customInstrBytes) — public API: reads a transcript file and returns a Session or null ## Integration processWorkspaceEntry / processWorkspaceEntryAsync now scan the transcript directory alongside chatSessions and wire discovered sessions into the same sessions[] / sessionSourceIndex pipeline so the dashboard picks them up transparently. The async path tracks transcript files in the same progress-reporting budget as chat files (totalUnits includes both). ## Tests (parser-vscode.test.ts) Five new cases in the parseTranscriptFile describe block: - full flow: session.start → user.message → assistant.message with tool calls → tool.execution_start/complete → final assistant.message - multi-turn: two user/assistant pairs produce two requests - empty session: no user messages → null - malformed file: all-corrupt lines → null (events.length === 0) - deduplication: same tool appearing in both toolRequests and tool.execution_start is deduplicated to a single entry --- src/core/parser-vscode.test.ts | 123 ++++++++++++++++++++- src/core/parser-vscode.ts | 194 ++++++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 4 deletions(-) diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index a307bbe..239f059 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -6,10 +6,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { reconstructFromJsonl } from './parser-vscode-files'; import { parseCLIEventsFile } from './parser-vscode-cli'; -import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; +import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs, parseTranscriptFile } from './parser-vscode'; function withTempFile(name: string, content: string, run: (filePath: string) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-')); @@ -787,3 +787,122 @@ describe('findVsCodeDirs — VS Code Server', () => { } }); }); + +describe('parseTranscriptFile', () => { + it('converts transcript events into a session with user/assistant turns', () => { + const lines = [ + JSON.stringify({ + type: 'session.start', + id: 'ev-1', + timestamp: '2026-05-15T08:52:24.985Z', + data: { sessionId: 'transcript-session-1', version: 1, producer: 'copilot-agent' }, + parentId: null, + }), + JSON.stringify({ + type: 'user.message', + id: 'ev-2', + timestamp: '2026-05-15T08:52:30.000Z', + data: { content: 'Explain the parser architecture.', attachments: [] }, + parentId: 'ev-1', + }), + JSON.stringify({ + type: 'assistant.message', + id: 'ev-3', + timestamp: '2026-05-15T08:52:35.000Z', + data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'list_dir', type: 'function' }] }, + parentId: 'ev-2', + }), + JSON.stringify({ + type: 'tool.execution_start', + id: 'ev-4', + timestamp: '2026-05-15T08:52:35.100Z', + data: { toolCallId: 'tc-1', toolName: 'list_dir', arguments: { path: '/src' } }, + parentId: 'ev-3', + }), + JSON.stringify({ + type: 'tool.execution_complete', + id: 'ev-5', + timestamp: '2026-05-15T08:52:35.500Z', + data: { toolCallId: 'tc-1', success: true }, + parentId: 'ev-4', + }), + JSON.stringify({ + type: 'assistant.message', + id: 'ev-6', + timestamp: '2026-05-15T08:52:38.000Z', + data: { content: 'The parser reads JSONL files line by line.', toolRequests: [] }, + parentId: 'ev-5', + }), + ].join('\n'); + + withTempFile('transcript-1.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-1', 'my-project', 'Local Agent (Server)'); + expect(session).not.toBeNull(); + expect(session!.sessionId).toBe('transcript-session-1'); + expect(session!.workspaceId).toBe('ws-1'); + expect(session!.workspaceName).toBe('my-project'); + expect(session!.harness).toBe('Local Agent (Server)'); + expect(session!.requests).toHaveLength(1); + + const req = session!.requests[0]; + expect(req.messageText).toBe('Explain the parser architecture.'); + expect(req.responseText).toBe('The parser reads JSONL files line by line.'); + expect(req.toolsUsed).toContain('list_dir'); + expect(req.agentMode).toBe('agent'); + expect(req.timestamp).toBe(new Date('2026-05-15T08:52:30.000Z').getTime()); + }); + }); + + it('groups multiple user turns into separate requests', () => { + const lines = [ + JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'multi-turn' }, parentId: null }), + JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'First question.' }, parentId: 'e0' }), + JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z', data: { content: 'First answer.' }, parentId: 'e1' }), + JSON.stringify({ type: 'user.message', id: 'e3', timestamp: '2026-05-15T09:00:05.000Z', data: { content: 'Second question.' }, parentId: 'e2' }), + JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:06.000Z', data: { content: 'Second answer.' }, parentId: 'e3' }), + ].join('\n'); + + withTempFile('transcript-multi.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-2', 'proj', 'Local Agent (Server)'); + expect(session).not.toBeNull(); + expect(session!.requests).toHaveLength(2); + expect(session!.requests[0].messageText).toBe('First question.'); + expect(session!.requests[0].responseText).toBe('First answer.'); + expect(session!.requests[1].messageText).toBe('Second question.'); + expect(session!.requests[1].responseText).toBe('Second answer.'); + }); + }); + + it('returns null for a session with no user messages', () => { + const lines = [ + JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'empty-session' }, parentId: null }), + ].join('\n'); + + withTempFile('transcript-empty.jsonl', lines, (filePath) => { + expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull(); + }); + }); + + it('returns null for a file with no parseable events (all lines corrupt)', () => { + withTempFile('transcript-corrupt.jsonl', 'not json\nalso not json\n', (filePath) => { + expect(parseTranscriptFile(filePath, 'ws-3', 'proj', 'Local Agent (Server)')).toBeNull(); + }); + }); + + it('deduplicates tool names collected from both toolRequests and tool.execution_start', () => { + const lines = [ + JSON.stringify({ type: 'session.start', id: 'e0', timestamp: '2026-05-15T09:00:00.000Z', data: { sessionId: 'dedup-tools' }, parentId: null }), + JSON.stringify({ type: 'user.message', id: 'e1', timestamp: '2026-05-15T09:00:01.000Z', data: { content: 'Do work.' }, parentId: 'e0' }), + JSON.stringify({ type: 'assistant.message', id: 'e2', timestamp: '2026-05-15T09:00:02.000Z', + data: { content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'read_file', type: 'function' }] }, parentId: 'e1' }), + JSON.stringify({ type: 'tool.execution_start', id: 'e3', timestamp: '2026-05-15T09:00:03.000Z', + data: { toolCallId: 'tc-1', toolName: 'read_file', arguments: {} }, parentId: 'e2' }), + JSON.stringify({ type: 'assistant.message', id: 'e4', timestamp: '2026-05-15T09:00:04.000Z', data: { content: 'Done.' }, parentId: 'e3' }), + ].join('\n'); + + withTempFile('transcript-dedup.jsonl', lines, (filePath) => { + const session = parseTranscriptFile(filePath, 'ws-4', 'proj', 'Local Agent (Server)'); + expect(session!.requests[0].toolsUsed).toEqual(['read_file']); + }); + }); +}); diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index 00d0cdf..b7c4c8a 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -156,6 +156,155 @@ function listEditStateFiles(esDir: string): string[] { } } +function listTranscriptFiles(transcriptDir: string): string[] { + try { + return fs.readdirSync(transcriptDir, { withFileTypes: true }) + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => path.join(transcriptDir, e.name)); + } catch { + return []; + } +} + +interface TranscriptEvent { + type: string; + id?: string; + timestamp?: string; + data?: Record; +} + +/** Parse the raw JSONL text of a transcript file into a list of events, skipping blank/corrupt lines. */ +function parseTranscriptLines(raw: string): TranscriptEvent[] { + const events: TranscriptEvent[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { events.push(JSON.parse(trimmed) as TranscriptEvent); } catch { /* skip corrupt line */ } + } + return events; +} + +/** Build a toolCallId → toolName index from tool.execution_start events. */ +function buildToolNameIndex(events: TranscriptEvent[]): Map { + const toolNames = new Map(); + for (const ev of events) { + if (ev.type === 'tool.execution_start' && ev.data) { + const id = ev.data.toolCallId as string | undefined; + const name = ev.data.toolName as string | undefined; + if (id && name) toolNames.set(id, name); + } + } + return toolNames; +} + +/** Collect tool names referenced in an assistant.message toolRequests array. */ +function collectToolsFromToolRequests( + toolRequests: unknown, + toolNames: Map, + out: string[], +): void { + if (!Array.isArray(toolRequests)) return; + for (const tr of toolRequests as Array<{ toolCallId?: string; name?: string }>) { + const name = tr.name ?? (tr.toolCallId ? toolNames.get(tr.toolCallId) : undefined); + if (name) out.push(name); + } +} + +/** Walk transcript events and group them into per-turn SessionRequests. */ +function buildRequestsFromTranscriptEvents( + events: TranscriptEvent[], + toolNames: Map, +): SessionRequest[] { + const requests: SessionRequest[] = []; + let currentUserMsg: string | null = null; + let currentUserTs: number | null = null; + let currentUserMsgId: string | null = null; + let currentResponseParts: string[] = []; + let currentTools: string[] = []; + + const flushTurn = () => { + if (currentUserMsg === null) return; + requests.push(createRequest({ + requestId: currentUserMsgId ?? '', + timestamp: currentUserTs, + messageText: currentUserMsg, + responseText: currentResponseParts.join('').trim(), + toolsUsed: [...new Set(currentTools)], + agentMode: 'agent', + })); + currentUserMsg = null; + currentUserTs = null; + currentUserMsgId = null; + currentResponseParts = []; + currentTools = []; + }; + + for (const ev of events) { + if (ev.type === 'user.message') { + flushTurn(); + currentUserMsg = (ev.data?.content as string | undefined) ?? ''; + currentUserTs = ev.timestamp ? new Date(ev.timestamp).getTime() : null; + currentUserMsgId = ev.id ?? null; + } else if (ev.type === 'assistant.message') { + const content = (ev.data?.content as string | undefined) ?? ''; + if (content) currentResponseParts.push(content); + // Collect tool names from inline toolRequests; fall back to the + // pre-indexed name when only a toolCallId is present. + collectToolsFromToolRequests(ev.data?.toolRequests, toolNames, currentTools); + } else if (ev.type === 'tool.execution_start') { + // Also push from execution_start so tool names appear even when the + // assistant.message toolRequests array is absent or empty. + const name = (ev.data?.toolName as string | undefined) ?? ''; + if (name) currentTools.push(name); + } + } + flushTurn(); + + return requests; +} + +/** + * Parses a Copilot Chat transcript JSONL file (GitHub.copilot-chat/transcripts/*.jsonl). + * Each file contains one session; events include session.start, user.message, + * assistant.message, tool.execution_start, and tool.execution_complete. + */ +export function parseTranscriptFile( + filePath: string, + wsId: string, + wsName: string, + harness: string, + customInstructionsBytes?: number, +): Session | null { + let raw: string; + try { raw = readFile(filePath); } catch (e) { + debugCore('parser-vscode', `Cannot read transcript file ${filePath}`, e); + return null; + } + + const events = parseTranscriptLines(raw); + if (events.length === 0) return null; + + const sessionStart = events.find(e => e.type === 'session.start'); + const sessionId = (sessionStart?.data?.sessionId as string | undefined) || path.basename(filePath, '.jsonl'); + const sessionStartTs = sessionStart?.timestamp ? new Date(sessionStart.timestamp).getTime() : null; + + const toolNames = buildToolNameIndex(events); + const requests = buildRequestsFromTranscriptEvents(events, toolNames); + + if (requests.length === 0) return null; + + return createSession({ + sessionId, + workspaceId: wsId, + workspaceName: wsName, + harness, + requests, + creationDate: sessionStartTs, + location: 'panel', + customInstructionsBytes, + }); +} + function countLinesAdded(edits: { text?: string }[] | undefined): number { let linesAdded = 0; for (const edit of (edits || [])) { @@ -270,6 +419,22 @@ export function processWorkspaceEntry( } } + // Transcript format: workspaceStorage//GitHub.copilot-chat/transcripts/*.jsonl + const transcriptDir = path.join(entryPath, 'GitHub.copilot-chat', 'transcripts'); + for (const transcriptFile of listTranscriptFiles(transcriptDir)) { + const session = parseTranscriptFile(transcriptFile, wsId, wsName, harness, customInstructionsBytes); + if (session) { + sessions.push(session); + sessionSourceIndex.set(session.sessionId, { + kind: 'vscode-session-file', + filePath: transcriptFile, + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } + } + const eventsFile = path.join(entryPath, 'events.jsonl'); const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); if (cliSession) { @@ -318,9 +483,10 @@ export async function processWorkspaceEntryAsync( } const chatFiles = listChatSessionFiles(path.join(entryPath, 'chatSessions')); + const transcriptFiles = listTranscriptFiles(path.join(entryPath, 'GitHub.copilot-chat', 'transcripts')); const editStateFiles = listEditStateFiles(path.join(entryPath, 'chatEditingSessions')); - const totalUnits = Math.max(1, chatFiles.length + editStateFiles.length); - const chatEvery = chunkInterval(chatFiles.length); + const totalUnits = Math.max(1, chatFiles.length + transcriptFiles.length + editStateFiles.length); + const chatEvery = chunkInterval(chatFiles.length + transcriptFiles.length); const editEvery = chunkInterval(editStateFiles.length); let completed = 0; @@ -350,6 +516,30 @@ export async function processWorkspaceEntryAsync( await yieldToLoop(); } + for (let i = 0; i < transcriptFiles.length; i++) { + const session = parseTranscriptFile(transcriptFiles[i], wsId, wsName, harness, customInstructionsBytes); + if (session) { + sessions.push(session); + sessionSourceIndex.set(session.sessionId, { + kind: 'vscode-session-file', + filePath: transcriptFiles[i], + workspaceId: wsId, + workspaceName: wsName, + harness, + }); + } + completed++; + if (shouldReportChunk(chatFiles.length + i, chatFiles.length + transcriptFiles.length, chatEvery)) { + onProgress?.({ + wsName, + detail: `transcript ${i + 1}/${transcriptFiles.length}`, + completed, + total: totalUnits, + }); + } + await yieldToLoop(); + } + const eventsFile = path.join(entryPath, 'events.jsonl'); const cliSession = parseCLIEventsFile(eventsFile, wsId, wsName, customInstructionsBytes); if (cliSession) {