diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts index 3e6f7ffa..ad1d96b9 100644 --- a/cli/src/commands/sync.ts +++ b/cli/src/commands/sync.ts @@ -103,7 +103,7 @@ export async function runSync(options: SyncOptions = {}): Promise { try { providers = [getProvider(options.source)]; } catch { - throw new Error(`Unknown source: ${options.source}. Available: ${getAllProviders().map(p => p.getProviderName()).join(', ')}`); + throw new Error(`Unknown source: ${options.source}. Available: ${getAllProviders(true).map(p => p.getProviderName()).join(', ')}`); } } else { providers = getAllProviders(); diff --git a/cli/src/parser/jsonl.ts b/cli/src/parser/jsonl.ts index d6d2cc41..9e659557 100644 --- a/cli/src/parser/jsonl.ts +++ b/cli/src/parser/jsonl.ts @@ -122,7 +122,7 @@ export function classifyUserMessage(msg: ClaudeMessage): UserMessageClass { /** * Parse a single JSONL file and extract session data */ -export async function parseJsonlFile(filePath: string): Promise { +export async function parseJsonlFile(filePath: string, sessionIdOverride?: string): Promise { const entries: JsonlEntry[] = []; const fileStream = fs.createReadStream(filePath); @@ -147,15 +147,15 @@ export async function parseJsonlFile(filePath: string): Promise { home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-claude-desktop-')); }); +afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); }); + +function writeSession() { + const projectDir = path.join(getClaudeDesktopDir(home), 'account-1', 'project-1'); + const sessionDir = path.join(projectDir, 'session-1'); + fs.mkdirSync(sessionDir, { recursive: true }); + const audit = path.join(sessionDir, 'audit.jsonl'); + const rows = [ + { type: 'user', uuid: 'u1', sessionId: 'original', timestamp: '2026-09-01T10:00:00Z', message: { role: 'user', content: 'Investigate API latency' } }, + { type: 'assistant', uuid: 'a1', sessionId: 'original', timestamp: '2026-09-01T10:00:05Z', parentUuid: 'u1', message: { role: 'assistant', content: 'I will inspect the traces', model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 30 } } }, + { type: 'user', uuid: 'u2', sessionId: 'original', timestamp: '2026-09-01T10:00:15Z', parentUuid: 'a1', message: { role: 'user', content: 'Check the slow endpoint too' } }, + ]; + fs.writeFileSync(audit, rows.map(x => JSON.stringify(x)).join('\n')); + fs.writeFileSync(path.join(projectDir, 'session-1.json'), JSON.stringify({ title: 'Investigate latency', userSelectedFolders: ['/tmp/my-app'] })); + return audit; +} + +describe('ClaudeDesktopProvider', () => { + it('is manual opt-in rather than included in a regular sync', async () => { + expect(provider.getProviderName()).toBe('claude-desktop'); + expect(await provider.discover({ homeRoot: home })).toEqual([]); + }); + it('discovers local audit.jsonl from a selected home and parses non-UUID filename', async () => { + const audit = writeSession(); + expect(await provider.discover({ homeRoot: home })).toEqual([audit]); + expect(await provider.discover({ homeRoot: home, projectFilter: 'other' })).toEqual([]); + const parsed = await provider.parse(audit); + expect(parsed).not.toBeNull(); + expect(parsed!.sourceTool).toBe('claude-desktop'); + expect(parsed!.id).toMatch(/^claude-desktop-/); + expect(parsed!.messages).toHaveLength(3); + expect(parsed!.messages.every(m => m.sessionId === parsed!.id && m.id.startsWith(parsed!.id + ':'))).toBe(true); + expect(parsed!.messages[1].parentId).toBe(`${parsed!.id}:u1`); + expect(parsed!.projectPath).toBe('/tmp/my-app'); + expect(parsed!.customTitle).toBe('Investigate latency'); + expect((await provider.parse(audit))!.id).toBe(parsed!.id); + }); + it('does not import an empty audit session', async () => { + const audit = writeSession(); + fs.writeFileSync(audit, ''); + expect(await provider.parse(audit)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/cli/src/providers/__tests__/pi.test.ts b/cli/src/providers/__tests__/pi.test.ts new file mode 100644 index 00000000..11e4e1ed --- /dev/null +++ b/cli/src/providers/__tests__/pi.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PiProvider } from '../pi.js'; +import { getProvider, getAllProviders } from '../registry.js'; + +const provider = new PiProvider(); +let home: string; + +beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-pi-')); }); +afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); }); + +function writeSession() { + const dir = path.join(home, '.pi', 'agent', 'sessions', '--tmp-project--'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'sample.jsonl'); + const rows = [ + { type: 'session', cwd: '/tmp/project' }, + { type: 'message', id: '1', timestamp: '2026-09-01T10:00:00Z', message: { role: 'user', content: [{ type: 'text', text: 'Review this PR' }] } }, + { type: 'message', id: '2', parentId: '1', timestamp: '2026-09-01T10:00:05Z', message: { role: 'assistant', content: [{ type: 'text', text: 'I found a bug' }], model: 'gpt-4o-mini', usage: { input: 120, output: 40, cacheRead: 10 } } }, + { type: 'compaction' }, + { type: 'message', id: '3', parentId: '2', timestamp: '2026-09-01T10:00:15Z', message: { role: 'user', content: 'Please fix it' } }, + ]; + fs.writeFileSync(file, rows.map(x => JSON.stringify(x)).join('\n')); + return file; +} + +describe('PiProvider', () => { + it('is registered for explicit --source but excluded from default all-provider scans', () => { + expect(getProvider('pi').getProviderName()).toBe('pi'); + expect(getProvider('claude-desktop').getProviderName()).toBe('claude-desktop'); + const enabled = getAllProviders().map(p => p.getProviderName()); + expect(enabled).not.toContain('pi'); + expect(enabled).not.toContain('claude-desktop'); + expect(enabled).toContain('kilo'); + expect(getAllProviders(true).map(p => p.getProviderName())).toContain('pi'); + }); + it('discovers and parses Pi session JSONL under a selected home', async () => { + const file = writeSession(); + expect(await provider.discover({ homeRoot: home })).toEqual([file]); + expect(await provider.discover({ homeRoot: home, projectFilter: 'unmatched' })).toEqual([]); + const parsed = await provider.parse(file); + expect(parsed).not.toBeNull(); + expect(parsed!.sourceTool).toBe('pi'); + expect(parsed!.id).toMatch(/^pi-/); + expect(parsed!.messages).toHaveLength(3); + expect(parsed!.messages.every(m => m.id.startsWith(parsed!.id + ':'))).toBe(true); + expect(parsed!.messages[1].parentId).toBe(`${parsed!.id}:1`); + expect(parsed!.usage?.totalInputTokens).toBe(120); + expect(parsed!.usage?.cacheReadTokens).toBe(10); + expect(parsed!.compactCount).toBe(1); + expect((await provider.parse(file))!.id).toBe(parsed!.id); + }); + it('skips malformed and empty sessions', async () => { + const file = writeSession(); + fs.writeFileSync(file, '{not-json}\n'); + expect(await provider.parse(file)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/cli/src/providers/claude-desktop.ts b/cli/src/providers/claude-desktop.ts new file mode 100644 index 00000000..f7920507 --- /dev/null +++ b/cli/src/providers/claude-desktop.ts @@ -0,0 +1,110 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createHash } from 'crypto'; +import type { SessionProvider } from './types.js'; +import type { ParsedSession } from '../types.js'; +import { parseJsonlFile } from '../parser/jsonl.js'; +/** Claude Desktop stores local-agent audit files under an OS-specific per-home directory. */ +export function getClaudeDesktopDir(homeRoot?: string): string { + const home = homeRoot ?? os.homedir(); + if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions'); + if (process.platform === 'win32') return path.join(homeRoot ?? process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions'); + return path.join(home, '.config', 'Claude', 'local-agent-mode-sessions'); +} + +/** + * Claude Desktop Provider. + * Discovers and parses local agent mode sessions from Claude Desktop app. + */ +export class ClaudeDesktopProvider implements SessionProvider { + getProviderName(): string { + return 'claude-desktop'; + } + + async discover(options?: { projectFilter?: string; homeRoot?: string }): Promise { + const baseDir = getClaudeDesktopDir(options?.homeRoot); + + if (!fs.existsSync(baseDir)) { + return []; + } + + const files: string[] = []; + + // Directory structure: ///audit.jsonl + const accounts = fs.readdirSync(baseDir); + for (const account of accounts) { + if (account.startsWith('.')) continue; + const accountPath = path.join(baseDir, account); + if (!fs.statSync(accountPath).isDirectory()) continue; + + const projects = fs.readdirSync(accountPath); + for (const project of projects) { + if (project.startsWith('.')) continue; + const projectPath = path.join(accountPath, project); + if (!fs.statSync(projectPath).isDirectory()) continue; + + // Optional project filter (matches against project UUID for now) + if (options?.projectFilter && !project.includes(options.projectFilter)) { + continue; + } + + const sessions = fs.readdirSync(projectPath); + for (const session of sessions) { + if (session.startsWith('.')) continue; + const sessionPath = path.join(projectPath, session); + if (!fs.statSync(sessionPath).isDirectory()) continue; + + const auditPath = path.join(sessionPath, 'audit.jsonl'); + if (fs.existsSync(auditPath)) { + files.push(auditPath); + } + } + } + } + + return files; + } + + async parse(filePath: string): Promise { + // Filename audit.jsonl does not match the shared Claude UUID-based parser. + const sessionId = 'claude-desktop-' + createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 24); + const session = await parseJsonlFile(filePath, sessionId); + if (!session) return null; + + session.sourceTool = 'claude-desktop'; + session.messages = session.messages.map(message => ({ + ...message, + id: `${sessionId}:${message.id}`, + sessionId, + parentId: message.parentId ? `${sessionId}:${message.parentId}` : null, + })); + + // Enrich with metadata from .json + try { + const parentDir = path.dirname(filePath); + const sessionId = path.basename(parentDir); + const projectDir = path.dirname(parentDir); + const metaPath = path.join(projectDir, `${sessionId}.json`); + + if (fs.existsSync(metaPath)) { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + if (meta.title) session.customTitle = meta.title; + + // Enhance project path extraction: audit.jsonl messages lack cwd + if (meta.userSelectedFolders && meta.userSelectedFolders.length > 0) { + session.projectPath = meta.userSelectedFolders[0]; + session.projectName = path.basename(meta.userSelectedFolders[0]) || 'unknown'; + } else if (meta.cwd) { + session.projectPath = meta.cwd; + session.projectName = path.basename(meta.cwd) || 'unknown'; + } + } + } catch (err) { + // Ignore errors reading metadata + console.warn(`[claude-desktop] Error reading metadata for ${filePath}: ${err}`); + } + + return session; + } +} diff --git a/cli/src/providers/pi.ts b/cli/src/providers/pi.ts new file mode 100644 index 00000000..d8a054a5 --- /dev/null +++ b/cli/src/providers/pi.ts @@ -0,0 +1,267 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { createHash } from 'crypto'; +import * as readline from 'readline'; +import type { SessionProvider } from './types.js'; +import type { ParsedSession, ParsedMessage, ToolCall, ToolResult, SessionUsage } from '../types.js'; +import { generateTitle, detectSessionCharacter } from '../parser/titles.js'; +import { calculateCost } from '../utils/pricing.js'; + +function getPiSessionsDir(homeRoot?: string): string { + return path.join(homeRoot ?? os.homedir(), '.pi', 'agent', 'sessions'); +} + +export class PiProvider implements SessionProvider { + getProviderName(): string { + return 'pi'; + } + + async discover(options?: { projectFilter?: string; homeRoot?: string }): Promise { + const baseDir = getPiSessionsDir(options?.homeRoot); + if (!fs.existsSync(baseDir)) return []; + + const files: string[] = []; + let dirs: string[] = []; + try { + dirs = fs.readdirSync(baseDir); + } catch { + return []; + } + + for (const dir of dirs) { + if (dir.startsWith('.')) continue; + const dirPath = path.join(baseDir, dir); + + try { + if (!fs.statSync(dirPath).isDirectory()) continue; + + let decodedPath = dir; + if (dir.startsWith('--') && dir.endsWith('--')) { + decodedPath = dir.slice(2, -2).replace(/-/g, '/'); + if (process.platform !== 'win32' && !decodedPath.startsWith('/')) { + decodedPath = '/' + decodedPath; + } + } + + if (options?.projectFilter && !decodedPath.toLowerCase().includes(options.projectFilter.toLowerCase())) { + continue; + } + + const projectFiles = fs.readdirSync(dirPath); + for (const file of projectFiles) { + if (file.endsWith('.jsonl')) { + files.push(path.join(dirPath, file)); + } + } + } catch { + // Ignore errors + } + } + + return files; + } + + async parse(filePath: string): Promise { + const entries: any[] = []; + + try { + const fileStream = fs.createReadStream(filePath); + const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); + for await (const line of rl) { + if (line.trim()) { + try { entries.push(JSON.parse(line)); } catch { } + } + } + } catch (e) { + return null; + } + + if (entries.length === 0) return null; + + const sessionId = 'pi-' + createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 24); + let projectPath = ''; + + const parentDirName = path.basename(path.dirname(filePath)); + if (parentDirName.startsWith('--') && parentDirName.endsWith('--')) { + projectPath = parentDirName.slice(2, -2).replace(/-/g, '/'); + if (process.platform !== 'win32' && !projectPath.startsWith('/')) { + projectPath = '/' + projectPath; + } + } + + const messages: ParsedMessage[] = []; + let userMessageCount = 0; + let assistantMessageCount = 0; + let toolCallCount = 0; + let compactCount = 0; + + const usageEntries: any[] = []; + + for (const entry of entries) { + if (entry.type === 'session') { + if (entry.cwd) projectPath = entry.cwd; + continue; + } + + if (entry.type === 'compaction') { + compactCount++; + continue; + } + + if (entry.type === 'message' && entry.message) { + const role = entry.message.role; + if (role === 'custom' || role === 'branchSummary') continue; + + let type: 'user' | 'assistant' | 'system' = 'user'; + if (role === 'assistant') type = 'assistant'; + + if (type === 'user') userMessageCount++; + else if (type === 'assistant') assistantMessageCount++; + + let contentStr = ''; + let thinking: string | null = null; + const toolCalls: ToolCall[] = []; + const toolResults: ToolResult[] = []; + + if (role === 'bashExecution') { + toolResults.push({ + toolUseId: `bash-${entry.id}`, + output: entry.message.output || '', + }); + contentStr = `> ${entry.message.command}\n${entry.message.output || ''}`; + } else if (role === 'toolResult') { + toolResults.push({ + toolUseId: entry.message.toolCallId || entry.id, + output: this.extractPiText(entry.message.content) + }); + } else if (entry.message.content) { + if (typeof entry.message.content === 'string') { + contentStr = entry.message.content; + } else if (Array.isArray(entry.message.content)) { + for (const part of entry.message.content) { + if (part.type === 'text') contentStr += (contentStr ? '\n' : '') + part.text; + else if (part.type === 'thinking') thinking = (thinking ? thinking + '\n' : '') + part.thinking; + else if (part.type === 'toolCall') { + toolCalls.push({ + id: part.id, + name: part.name, + input: part.arguments || {} + }); + } else if (part.type === 'image') { + contentStr += (contentStr ? '\n' : '') + '[Image]'; + } + } + } + } + + toolCallCount += toolCalls.length; + + let msgUsage = null; + if (role === 'assistant' && entry.message.usage) { + const u = entry.message.usage; + const model = entry.message.model || 'unknown'; + const costEntries = [{ model, usage: { + input_tokens: u.input || 0, + output_tokens: u.output || 0, + cache_creation_input_tokens: u.cacheWrite || 0, + cache_read_input_tokens: u.cacheRead || 0 + }}]; + msgUsage = { + inputTokens: u.input || 0, + outputTokens: u.output || 0, + cacheCreationTokens: u.cacheWrite || 0, + cacheReadTokens: u.cacheRead || 0, + model, + estimatedCostUsd: calculateCost(costEntries) + }; + usageEntries.push(costEntries[0]); + } + + messages.push({ + id: `${sessionId}:${entry.id ?? messages.length}`, + sessionId, + type, + content: contentStr, + thinking, + toolCalls, + toolResults, + usage: msgUsage, + timestamp: new Date(entry.timestamp), + parentId: entry.parentId ? `${sessionId}:${entry.parentId}` : null + }); + } + } + + if (messages.length === 0) return null; + + let usage: SessionUsage | undefined; + if (usageEntries.length > 0) { + const totalInputTokens = usageEntries.reduce((sum, e) => sum + (e.usage.input_tokens ?? 0), 0); + const totalOutputTokens = usageEntries.reduce((sum, e) => sum + (e.usage.output_tokens ?? 0), 0); + const cacheCreationTokens = usageEntries.reduce((sum, e) => sum + (e.usage.cache_creation_input_tokens ?? 0), 0); + const cacheReadTokens = usageEntries.reduce((sum, e) => sum + (e.usage.cache_read_input_tokens ?? 0), 0); + + const modelCounts = new Map(); + for (const e of usageEntries) { + modelCounts.set(e.model, (modelCounts.get(e.model) ?? 0) + 1); + } + const modelsUsed = [...modelCounts.keys()]; + const primaryModel = [...modelsUsed].sort((a, b) => + (modelCounts.get(b) ?? 0) - (modelCounts.get(a) ?? 0) + )[0] ?? 'unknown'; + + usage = { + totalInputTokens, + totalOutputTokens, + cacheCreationTokens, + cacheReadTokens, + estimatedCostUsd: calculateCost(usageEntries), + modelsUsed, + primaryModel, + usageSource: 'jsonl', + }; + } + + const timestamps = messages.map(m => m.timestamp.getTime()); + const session: ParsedSession = { + id: sessionId, + projectPath, + projectName: path.basename(projectPath) || 'unknown', + summary: null, + generatedTitle: null, + titleSource: null, + sessionCharacter: null, + startedAt: new Date(Math.min(...timestamps)), + endedAt: new Date(Math.max(...timestamps)), + messageCount: userMessageCount + assistantMessageCount, + userMessageCount, + assistantMessageCount, + toolCallCount, + compactCount, + autoCompactCount: 0, + slashCommands: [], + gitBranch: null, + claudeVersion: null, + sourceTool: 'pi', + messages, + usage + }; + + const titleResult = generateTitle(session); + session.generatedTitle = titleResult.title; + session.titleSource = titleResult.source; + session.sessionCharacter = titleResult.character || detectSessionCharacter(session); + + return session; + } + + private extractPiText(content: any): string { + if (!content) return ''; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.filter(p => p.type === 'text').map(p => p.text).join('\n'); + } + return ''; + } +} diff --git a/cli/src/providers/registry.ts b/cli/src/providers/registry.ts index 19cef9ef..2ac0d372 100644 --- a/cli/src/providers/registry.ts +++ b/cli/src/providers/registry.ts @@ -1,5 +1,7 @@ import type { SessionProvider } from './types.js'; import { ClaudeCodeProvider } from './claude-code.js'; +import { ClaudeDesktopProvider } from './claude-desktop.js'; +import { PiProvider } from './pi.js'; import { CursorProvider } from './cursor.js'; import { CodexProvider } from './codex.js'; import { CopilotCliProvider } from './copilot-cli.js'; @@ -12,6 +14,11 @@ import { AntigravityProvider } from './antigravity.js'; import { MistralVibeProvider } from './mistral-vibe.js'; const providers = new Map(); +const MANUAL_ONLY_PROVIDERS = new Set(['claude-desktop', 'pi']); + +// New sources are opt-in: available with --source, but not scanned by plain sync. +providers.set('claude-desktop', new ClaudeDesktopProvider()); +providers.set('pi', new PiProvider()); // Register built-in providers const claudeCode = new ClaudeCodeProvider(); @@ -61,6 +68,8 @@ export function getProvider(name: string): SessionProvider { /** * Get all registered providers */ -export function getAllProviders(): SessionProvider[] { - return [...providers.values()]; +export function getAllProviders(includeManual = false): SessionProvider[] { + return [...providers.values()].filter(provider => + includeManual || !MANUAL_ONLY_PROVIDERS.has(provider.getProviderName()) + ); }