From 0dbb33700d1cd3e42bdec2d0cc170e325b7708a0 Mon Sep 17 00:00:00 2001 From: Nihaal Manaf Date: Sat, 8 Aug 2026 11:45:26 -0700 Subject: [PATCH] feat(cli): add Pi session support --- README.md | 6 +- cli/CHANGELOG.md | 6 + cli/README.md | 2 + cli/package.json | 1 + cli/src/commands/doctor/checks/database.ts | 2 +- cli/src/commands/stats/shared.ts | 2 +- cli/src/index.ts | 2 +- cli/src/parser/titles.ts | 4 +- cli/src/providers/pi.test.ts | 266 +++++++++++ cli/src/providers/pi.ts | 422 ++++++++++++++++++ cli/src/providers/registry.ts | 4 + cli/src/utils/telemetry.ts | 8 + .../components/chat/message/MessageBubble.tsx | 2 + .../components/filters/SourceToolSelect.tsx | 2 + .../components/sessions/CompactSessionRow.tsx | 1 + dashboard/src/lib/constants/colors.ts | 1 + dashboard/src/lib/share-card-icons.ts | 1 + dashboard/src/pages/SettingsPage.tsx | 2 +- docs/ARCHITECTURE.md | 4 +- docs/LANDSCAPE.md | 4 +- docs/PRODUCT.md | 7 +- docs/VISION.md | 4 +- 22 files changed, 737 insertions(+), 16 deletions(-) create mode 100644 cli/src/providers/pi.test.ts create mode 100644 cli/src/providers/pi.ts diff --git a/README.md b/README.md index 64eda6d0..0632c2ff 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npx @code-insights/cli Patterns — friction points, effective patterns, prompt quality, working style

-Analyzes your sessions from **Claude Code, Cursor, Codex CLI, Copilot CLI, and VS Code Copilot Chat** to extract structured insights — decisions with trade-offs, learnings with root causes, prompt quality with actionable feedback, and cross-session patterns that surface what's working and what's not. All stored locally in SQLite, browsable through terminal analytics and a built-in dashboard. +Analyzes your sessions from **Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, and VS Code Copilot Chat** to extract structured insights — decisions with trade-offs, learnings with root causes, prompt quality with actionable feedback, and cross-session patterns that surface what's working and what's not. All stored locally in SQLite, browsable through terminal analytics and a built-in dashboard. **No accounts. No cloud. No data leaves your machine.** @@ -100,6 +100,7 @@ Don't need a browser? `code-insights stats` gives you the full picture from the | Claude Code | `~/.claude/projects/**/*.jsonl` | | Cursor | Workspace storage SQLite (macOS, Linux, Windows) | | Codex CLI | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | +| Pi | `~/.pi/agent/sessions/**/*.jsonl` | | Copilot CLI | `~/.copilot/session-state/{id}/events.jsonl` | | VS Code Copilot Chat | Platform-specific Copilot Chat storage | @@ -129,6 +130,7 @@ code-insights stats cost # cost breakdown by project and model code-insights dashboard # start dashboard server code-insights sync # sync sessions only code-insights sync --source cursor # sync from a specific tool +code-insights sync --source pi # sync only Pi sessions code-insights reflect # cross-session pattern synthesis code-insights reflect --week 2026-W11 # reflect on a specific week code-insights config llm # configure LLM provider @@ -142,7 +144,7 @@ See [`cli/README.md`](cli/README.md) for the full CLI reference. ## Architecture ``` -Session files (Claude Code, Cursor, Codex CLI, Copilot CLI, VS Code Copilot Chat) +Session files (Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, VS Code Copilot Chat) │ ▼ ┌──────────────────┐ diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 835eaa87..96f8853b 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `@code-insights/cli` will be documented in this file. +## Unreleased + +### Added + +- Pi Coding Agent session discovery, parsing, usage accounting, and dashboard source filtering. + ## [4.11.0] - 2026-05-19 ### Added diff --git a/cli/README.md b/cli/README.md index 140e93c6..762f7046 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,6 +60,7 @@ code-insights doctor # diagnose your installation (start here | **Claude Code** | `~/.claude/projects/**/*.jsonl` | | **Cursor** | Workspace storage SQLite (macOS, Linux, Windows) | | **Codex CLI** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | +| **Pi** | `~/.pi/agent/sessions/**/*.jsonl` | | **Copilot CLI** | `~/.copilot/session-state/{id}/events.jsonl` | | **VS Code Copilot Chat** | Platform-specific Copilot Chat storage | @@ -138,6 +139,7 @@ code-insights sync --dry-run code-insights sync --source cursor code-insights sync --source claude-code code-insights sync --source codex-cli +code-insights sync --source pi code-insights sync --source copilot-cli # Sync only sessions from a specific project diff --git a/cli/package.json b/cli/package.json index 45f7f504..a9ba650c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -63,6 +63,7 @@ "claude-code", "cursor", "codex", + "pi-coding-agent", "copilot", "ai-coding", "insights", diff --git a/cli/src/commands/doctor/checks/database.ts b/cli/src/commands/doctor/checks/database.ts index 61f06728..ba75a435 100644 --- a/cli/src/commands/doctor/checks/database.ts +++ b/cli/src/commands/doctor/checks/database.ts @@ -136,7 +136,7 @@ export function databaseChecks(): Check[] { run: async () => { try { const db = getDb(); - const knownTools = ['claude-code', 'cursor', 'codex-cli', 'copilot-cli', 'copilot']; + const knownTools = ['claude-code', 'cursor', 'codex-cli', 'copilot-cli', 'copilot', 'pi']; const placeholders = knownTools.map(() => '?').join(', '); const row = db.prepare( `SELECT COUNT(*) as cnt FROM sessions WHERE source_tool NOT IN (${placeholders})` diff --git a/cli/src/commands/stats/shared.ts b/cli/src/commands/stats/shared.ts index 80acedb9..6cd1f50e 100644 --- a/cli/src/commands/stats/shared.ts +++ b/cli/src/commands/stats/shared.ts @@ -10,7 +10,7 @@ export function applySharedFlags(cmd: Command): Command { return cmd .option('-p, --period ', 'Time range: 7d, 30d, 90d, all', '7d') .option('--project ', 'Scope to a single project') - .option('--source ', 'Filter by source tool (claude-code, cursor, etc.)') + .option('--source ', 'Filter by source tool (claude-code, cursor, pi, etc.)') .option('--no-sync', 'Skip auto-sync before displaying stats'); } diff --git a/cli/src/index.ts b/cli/src/index.ts index a1b98be0..64f62de2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -38,7 +38,7 @@ const syncCmd = program .description('Sync AI coding sessions to local SQLite database') .option('-f, --force', 'Force re-sync all sessions (also restores hidden sessions)') .option('-p, --project ', 'Only sync sessions from a specific project') - .option('-s, --source ', 'Only sync sessions from a specific tool (e.g., claude-code, cursor)') + .option('-s, --source ', 'Only sync sessions from a specific tool (e.g., claude-code, cursor, pi)') .option('--dry-run', 'Show what would be synced without making changes') .option('-q, --quiet', 'Suppress output (useful for hooks)') .option('-v, --verbose', 'Show diagnostic warnings from providers') diff --git a/cli/src/parser/titles.ts b/cli/src/parser/titles.ts index 7faad6cd..9b252d6a 100644 --- a/cli/src/parser/titles.ts +++ b/cli/src/parser/titles.ts @@ -24,7 +24,7 @@ const EDIT_TOOLS = new Set([ 'replace_string_in_file', 'copilot_replaceString', // Copilot VS Code 'create_file', 'copilot_createFile', // Copilot VS Code 'apply_patch', // Codex CLI (primary edit tool) - 'edit', 'write_file', 'patch', // generic fallbacks + 'edit', 'write', 'write_file', 'patch', // Pi + generic fallbacks ]); // Tools that read, search, or browse file content @@ -35,7 +35,7 @@ const READ_TOOLS = new Set([ 'copilot_searchCodebase', 'semantic_search', // Copilot VS Code 'file_search', 'copilot_findFiles', // Copilot VS Code 'list_dir', 'copilot_listDirectory', // Copilot VS Code - 'grep', 'view', // Copilot CLI / Codex + 'read', 'grep', 'view', // Pi / Copilot CLI / Codex ]); function isEditTool(name: string): boolean { diff --git a/cli/src/providers/pi.test.ts b/cli/src/providers/pi.test.ts new file mode 100644 index 00000000..e753e01b --- /dev/null +++ b/cli/src/providers/pi.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PiProvider } from './pi.js'; + +function line(value: unknown): string { + return JSON.stringify(value); +} + +function header(id = 'session-1', cwd = '/home/user/project'): string { + return line({ + type: 'session', + version: 3, + id, + timestamp: '2026-04-01T10:00:00.000Z', + cwd, + }); +} + +function user(content: unknown, id = 'user-1'): string { + return line({ + type: 'message', + id, + parentId: null, + timestamp: '2026-04-01T10:01:00.000Z', + message: { role: 'user', content, timestamp: 1_775_037_660_000 }, + }); +} + +function assistant(id = 'assistant-1'): string { + return line({ + type: 'message', + id, + parentId: 'user-1', + timestamp: '2026-04-01T10:02:00.000Z', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'I should inspect the file.' }, + { type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'src/main.ts' } }, + { type: 'text', text: 'I inspected the file.' }, + ], + provider: 'anthropic', + model: 'claude-sonnet-4-6', + usage: { + input: 100, + output: 50, + cacheRead: 25, + cacheWrite: 10, + totalTokens: 185, + cost: { input: 0.001, output: 0.002, cacheRead: 0.0001, cacheWrite: 0.0002, total: 0.0033 }, + }, + stopReason: 'toolUse', + timestamp: 1_775_037_720_000, + }, + }); +} + +function toolResult(): string { + return line({ + type: 'message', + id: 'result-1', + parentId: 'assistant-1', + timestamp: '2026-04-01T10:03:00.000Z', + message: { + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'read', + content: [{ type: 'text', text: 'file contents' }], + isError: false, + timestamp: 1_775_037_780_000, + }, + }); +} + +describe('PiProvider', () => { + let tempDir: string; + let previousSessionDir: string | undefined; + let previousAgentDir: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-provider-test-')); + previousSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR; + previousAgentDir = process.env.PI_CODING_AGENT_DIR; + delete process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_SESSION_DIR = tempDir; + }); + + afterEach(() => { + if (previousSessionDir === undefined) delete process.env.PI_CODING_AGENT_SESSION_DIR; + else process.env.PI_CODING_AGENT_SESSION_DIR = previousSessionDir; + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('discovers nested Pi JSONL sessions and filters by header cwd', async () => { + const nested = path.join(tempDir, '--home-user-project--'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'session.jsonl'), [header(), user('Fix the bug')].join('\n')); + fs.writeFileSync(path.join(nested, 'ignore.txt'), 'not a session'); + + const provider = new PiProvider(); + expect(provider.getProviderName()).toBe('pi'); + expect(await provider.discover()).toHaveLength(1); + expect(await provider.discover({ projectFilter: 'PROJECT' })).toHaveLength(1); + expect(await provider.discover({ projectFilter: 'different' })).toHaveLength(0); + }); + + it('honors PI_CODING_AGENT_DIR when the session directory override is absent', async () => { + delete process.env.PI_CODING_AGENT_SESSION_DIR; + process.env.PI_CODING_AGENT_DIR = tempDir; + const sessionsDir = path.join(tempDir, 'sessions', '--project--'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.writeFileSync(path.join(sessionsDir, 'session.jsonl'), [header(), user('Fix the bug')].join('\n')); + + expect(await new PiProvider().discover()).toHaveLength(1); + }); + + it('normalizes prompts, assistant content, tool calls, results, thinking, and exact Pi usage', async () => { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync(filePath, [header(), user('Fix the bug'), assistant(), toolResult()].join('\n')); + + const session = await new PiProvider().parse(filePath); + + expect(session).not.toBeNull(); + expect(session!.id).toBe('pi:session-1'); + expect(session!.sourceTool).toBe('pi'); + expect(session!.messages[0].id).toBe('pi:session-1:user-1'); + expect(session!.messages[1].parentId).toBe('pi:session-1:user-1'); + expect(session!.projectPath).toBe('/home/user/project'); + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(1); + expect(session!.messageCount).toBe(2); + expect(session!.toolCallCount).toBe(1); + expect(session!.messages[1].thinking).toBe('I should inspect the file.'); + expect(session!.messages[1].toolCalls[0]).toEqual({ + id: 'call-1', + name: 'read', + input: { path: 'src/main.ts' }, + }); + expect(session!.messages[1].toolResults[0]).toEqual({ + toolUseId: 'call-1', + output: 'file contents', + }); + expect(session!.usage).toMatchObject({ + totalInputTokens: 100, + totalOutputTokens: 50, + cacheReadTokens: 25, + cacheCreationTokens: 10, + estimatedCostUsd: 0.0033, + primaryModel: 'claude-sonnet-4-6', + }); + }); + + it('deduplicates entries copied from a parent session when Pi forks', async () => { + const parentPath = path.join(tempDir, 'parent.jsonl'); + fs.writeFileSync(parentPath, [header('parent'), user('Original request'), assistant()].join('\n')); + + const childPath = path.join(tempDir, 'child.jsonl'); + const childHeader = line({ + type: 'session', + version: 3, + id: 'child', + timestamp: '2026-04-01T11:00:00.000Z', + cwd: '/home/user/project', + parentSession: parentPath, + }); + const newUser = line({ + type: 'message', + id: 'user-2', + parentId: 'assistant-1', + timestamp: '2026-04-01T11:01:00.000Z', + message: { role: 'user', content: 'Try a different approach', timestamp: 1_775_041_260_000 }, + }); + fs.writeFileSync(childPath, [childHeader, user('Original request'), assistant(), newUser].join('\n')); + + const session = await new PiProvider().parse(childPath); + + expect(session!.userMessageCount).toBe(1); + expect(session!.assistantMessageCount).toBe(0); + expect(session!.messages[0].content).toBe('Try a different approach'); + expect(session!.messages[0].id).toBe('pi:child:user-2'); + }); + + it('counts compactions once and uses a Pi session name as the title', async () => { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync(filePath, [ + header(), + user('Fix the bug'), + line({ + type: 'compaction', + id: 'compact-1', + parentId: 'user-1', + timestamp: '2026-04-01T10:02:00Z', + summary: 'summary', + usage: { input: 20, output: 5, cacheRead: 2, cacheWrite: 1, cost: { total: 0.0004 } }, + }), + line({ type: 'session_info', id: 'info-1', parentId: 'compact-1', timestamp: '2026-04-01T10:03:00Z', name: 'Auth cleanup' }), + ].join('\n')); + + const session = await new PiProvider().parse(filePath); + + expect(session!.compactCount).toBe(1); + expect(session!.autoCompactCount).toBe(0); + expect(session!.generatedTitle).toBe('Auth cleanup'); + expect(session!.usage).toMatchObject({ + totalInputTokens: 20, + totalOutputTokens: 5, + cacheReadTokens: 2, + cacheCreationTokens: 1, + estimatedCostUsd: 0.0004, + }); + }); + + it('preserves assistant errors as outcome signals', async () => { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync(filePath, [ + header(), + user('Fix the bug'), + line({ + type: 'message', + id: 'assistant-error', + parentId: 'user-1', + timestamp: '2026-04-01T10:02:00Z', + message: { + role: 'assistant', + content: [], + model: 'test-model', + usage: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } }, + stopReason: 'error', + errorMessage: 'Provider request failed', + }, + }), + ].join('\n')); + + const session = await new PiProvider().parse(filePath); + + expect(session!.assistantMessageCount).toBe(1); + expect(session!.messages[1].content).toBe('[error] Provider request failed'); + }); + + it('keeps image-only user turns without storing base64 image data', async () => { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync(filePath, [ + header(), + user([{ type: 'image', data: 'sensitive-base64', mimeType: 'image/png' }]), + ].join('\n')); + + const session = await new PiProvider().parse(filePath); + + expect(session!.userMessageCount).toBe(1); + expect(session!.messages[0].content).toBe('[Image]'); + }); + + it('returns null for malformed or empty sessions', async () => { + const malformed = path.join(tempDir, 'malformed.jsonl'); + fs.writeFileSync(malformed, 'not json'); + expect(await new PiProvider().parse(malformed)).toBeNull(); + + const empty = path.join(tempDir, 'empty.jsonl'); + fs.writeFileSync(empty, header()); + expect(await new PiProvider().parse(empty)).toBeNull(); + }); +}); diff --git a/cli/src/providers/pi.ts b/cli/src/providers/pi.ts new file mode 100644 index 00000000..e97136d5 --- /dev/null +++ b/cli/src/providers/pi.ts @@ -0,0 +1,422 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { SessionProvider } from './types.js'; +import type { + MessageUsage, + ParsedMessage, + ParsedSession, + SessionUsage, + ToolCall, + ToolResult, +} from '../types.js'; +import { detectSessionCharacter, generateTitle } from '../parser/titles.js'; + +interface PiSessionHeader { + type: 'session'; + version?: number; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +interface PiContentBlock { + type: string; + text?: string; + thinking?: string; + id?: string; + name?: string; + arguments?: Record; +} + +interface PiUsage { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + cost?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; +} + +interface PiMessage { + role: string; + content: string | PiContentBlock[]; + timestamp?: number; + model?: string; + usage?: PiUsage; + toolCallId?: string; + toolName?: string; + isError?: boolean; + errorMessage?: string; +} + +interface PiEntry { + type: string; + id?: string; + parentId?: string | null; + timestamp?: string; + message?: PiMessage; + summary?: string; + name?: string; + usage?: PiUsage; +} + +/** + * Pi Coding Agent session provider. + * Discovers and parses JSONL sessions from ~/.pi/agent/sessions/ (or Pi's + * documented environment-variable overrides). + */ +export class PiProvider implements SessionProvider { + getProviderName(): string { + return 'pi'; + } + + async discover(options?: { projectFilter?: string }): Promise { + const sessionsDir = getPiSessionsDir(); + if (!fs.existsSync(sessionsDir)) return []; + + const files: string[] = []; + collectJsonlFiles(sessionsDir, files); + + if (!options?.projectFilter) return files; + return filterByProject(files, options.projectFilter); + } + + async parse(filePath: string): Promise { + return parsePiSession(filePath); + } +} + +function getPiSessionsDir(): string { + if (process.env.PI_CODING_AGENT_SESSION_DIR) { + return path.resolve(expandHome(process.env.PI_CODING_AGENT_SESSION_DIR)); + } + + const agentDir = process.env.PI_CODING_AGENT_DIR + ? path.resolve(expandHome(process.env.PI_CODING_AGENT_DIR)) + : path.join(os.homedir(), '.pi', 'agent'); + return path.join(agentDir, 'sessions'); +} + +function expandHome(value: string): string { + if (value === '~') return os.homedir(); + if (/^~[\\/]/.test(value)) return path.join(os.homedir(), value.slice(2)); + return value; +} + +function collectJsonlFiles(dir: string, files: string[], depth = 0): void { + if (depth > 20) return; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectJsonlFiles(fullPath, files, depth + 1); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } +} + +function filterByProject(files: string[], projectFilter: string): string[] { + const lowerFilter = projectFilter.toLowerCase(); + return files.filter((filePath) => { + const header = readHeader(filePath); + // Keep unreadable files so parse() can make the final decision. + return !header || header.cwd.toLowerCase().includes(lowerFilter); + }); +} + +function readHeader(filePath: string): PiSessionHeader | null { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(8192); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + const firstLine = buffer.toString('utf8', 0, bytesRead).split('\n')[0]; + const parsed = JSON.parse(firstLine) as PiSessionHeader; + return parsed.type === 'session' && parsed.id && parsed.cwd ? parsed : null; + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +} + +function parsePiSession(filePath: string): ParsedSession | null { + let lines: string[]; + try { + lines = fs.readFileSync(filePath, 'utf8').split('\n').filter((line) => line.trim()); + } catch { + return null; + } + if (lines.length === 0) return null; + + let header: PiSessionHeader; + try { + header = JSON.parse(lines[0]) as PiSessionHeader; + } catch { + return null; + } + if (header.type !== 'session' || !header.id || !header.cwd) return null; + + const sessionId = `pi:${header.id}`; + const inheritedEntryIds = readInheritedEntryIds(filePath, header.parentSession); + const messages: ParsedMessage[] = []; + const usageEntries: Array<{ model?: string; usage: PiUsage }> = []; + let compactCount = 0; + let customTitle: string | undefined; + + for (let index = 1; index < lines.length; index++) { + let entry: PiEntry; + try { + entry = JSON.parse(lines[index]) as PiEntry; + } catch { + continue; + } + + // Forked Pi sessions can copy the parent's entries into the new JSONL file. + // Analyze those entries only in the parent session to avoid double-counting. + if (entry.id && inheritedEntryIds.has(entry.id)) continue; + + if (entry.type === 'compaction') { + compactCount++; + if (entry.usage) usageEntries.push({ usage: entry.usage }); + continue; + } + if (entry.type === 'branch_summary') { + if (entry.usage) usageEntries.push({ usage: entry.usage }); + continue; + } + if (entry.type === 'session_info' && entry.name) { + customTitle = entry.name; + continue; + } + if (entry.type !== 'message' || !entry.message) continue; + + const message = entry.message; + if (message.role === 'toolResult') { + attachToolResult(messages, message); + continue; + } + if (message.role !== 'user' && message.role !== 'assistant') continue; + + const parsed = parseMessage(entry, message, sessionId, index); + if (!parsed) continue; + messages.push(parsed); + + if (message.role === 'assistant' && message.model && message.usage) { + usageEntries.push({ model: message.model, usage: message.usage }); + } + } + + const userMessages = messages.filter((message) => message.type === 'user'); + const assistantMessages = messages.filter((message) => message.type === 'assistant'); + if (userMessages.length === 0 && assistantMessages.length === 0) return null; + + const timestamps = messages + .map((message) => message.timestamp.getTime()) + .filter((timestamp) => Number.isFinite(timestamp)); + const headerDate = safeDate(header.timestamp) ?? new Date(0); + const startedAt = timestamps.length ? new Date(Math.min(...timestamps)) : headerDate; + const endedAt = timestamps.length ? new Date(Math.max(...timestamps)) : headerDate; + const usage = buildSessionUsage(usageEntries); + const toolCallCount = messages.reduce((sum, message) => sum + message.toolCalls.length, 0); + + const session: ParsedSession = { + id: sessionId, + projectPath: header.cwd, + projectName: path.basename(header.cwd) || 'unknown', + summary: null, + generatedTitle: customTitle ?? null, + titleSource: null, + sessionCharacter: null, + startedAt, + endedAt, + messageCount: userMessages.length + assistantMessages.length, + userMessageCount: userMessages.length, + assistantMessageCount: assistantMessages.length, + toolCallCount, + compactCount, + autoCompactCount: 0, + slashCommands: [], + gitBranch: null, + claudeVersion: null, + sourceTool: 'pi', + usage, + messages, + }; + + if (!customTitle) { + const title = generateTitle(session); + session.generatedTitle = title.title; + session.titleSource = title.source; + session.sessionCharacter = title.character; + } else { + session.sessionCharacter = detectSessionCharacter(session); + } + + return session; +} + +function parseMessage( + entry: PiEntry, + message: PiMessage, + sessionId: string, + index: number, +): ParsedMessage | null { + const extractedContent = extractText(message.content); + const content = extractedContent || (message.errorMessage ? `[error] ${message.errorMessage}` : ''); + const thinking = extractThinking(message.content); + const toolCalls = extractToolCalls(message.content); + if (!content && !thinking && toolCalls.length === 0) return null; + + const messageDate = typeof message.timestamp === 'number' ? new Date(message.timestamp) : null; + const timestamp = messageDate && !Number.isNaN(messageDate.getTime()) + ? messageDate + : safeDate(entry.timestamp) ?? new Date(0); + + let usage: MessageUsage | null = null; + if (message.role === 'assistant' && message.model && message.usage) { + usage = { + inputTokens: message.usage.input ?? 0, + outputTokens: message.usage.output ?? 0, + cacheCreationTokens: message.usage.cacheWrite ?? 0, + cacheReadTokens: message.usage.cacheRead ?? 0, + model: message.model, + estimatedCostUsd: message.usage.cost?.total ?? 0, + }; + } + + return { + // Pi entry IDs are short and can be copied into forked sessions. Namespace + // them because the Code Insights messages table requires globally unique IDs. + id: entry.id ? `${sessionId}:${entry.id}` : `${sessionId}:message-${index}`, + sessionId, + type: message.role as 'user' | 'assistant', + content, + thinking, + toolCalls, + toolResults: [], + usage, + timestamp, + parentId: entry.parentId ? `${sessionId}:${entry.parentId}` : null, + }; +} + +function extractText(content: string | PiContentBlock[]): string { + if (typeof content === 'string') return content; + const parts = content + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text as string); + if (parts.length === 0 && content.some((block) => block.type === 'image')) { + return '[Image]'; + } + return parts.join('\n'); +} + +function extractThinking(content: string | PiContentBlock[]): string | null { + if (typeof content === 'string') return null; + const parts = content + .filter((block) => block.type === 'thinking' && block.thinking) + .map((block) => block.thinking as string); + return parts.length ? parts.join('\n') : null; +} + +function extractToolCalls(content: string | PiContentBlock[]): ToolCall[] { + if (typeof content === 'string') return []; + return content + .filter((block) => block.type === 'toolCall' && block.name) + .map((block) => ({ + id: block.id || '', + name: block.name as string, + input: block.arguments || {}, + })); +} + +function attachToolResult(messages: ParsedMessage[], message: PiMessage): void { + if (!message.toolCallId) return; + const output = extractText(message.content).slice(0, 1000); + const result: ToolResult = { + toolUseId: message.toolCallId, + output: message.isError ? `[error] ${output}` : output, + }; + + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index].toolCalls.some((call) => call.id === message.toolCallId)) { + messages[index].toolResults.push(result); + return; + } + } +} + +function buildSessionUsage(entries: Array<{ model?: string; usage: PiUsage }>): SessionUsage | undefined { + if (entries.length === 0) return undefined; + + const modelCounts = new Map(); + for (const entry of entries) { + if (entry.model) { + modelCounts.set(entry.model, (modelCounts.get(entry.model) ?? 0) + 1); + } + } + const modelsUsed = [...modelCounts.keys()]; + const primaryModel = [...modelsUsed].sort( + (a, b) => (modelCounts.get(b) ?? 0) - (modelCounts.get(a) ?? 0), + )[0] ?? 'unknown'; + + return { + totalInputTokens: entries.reduce((sum, entry) => sum + (entry.usage.input ?? 0), 0), + totalOutputTokens: entries.reduce((sum, entry) => sum + (entry.usage.output ?? 0), 0), + cacheCreationTokens: entries.reduce((sum, entry) => sum + (entry.usage.cacheWrite ?? 0), 0), + cacheReadTokens: entries.reduce((sum, entry) => sum + (entry.usage.cacheRead ?? 0), 0), + estimatedCostUsd: entries.reduce((sum, entry) => sum + (entry.usage.cost?.total ?? 0), 0), + modelsUsed, + primaryModel, + usageSource: 'jsonl', + }; +} + +function readInheritedEntryIds(filePath: string, parentSession: string | undefined): Set { + const ids = new Set(); + if (!parentSession) return ids; + + const parentPath = path.isAbsolute(parentSession) + ? parentSession + : path.resolve(path.dirname(filePath), parentSession); + if (path.resolve(parentPath) === path.resolve(filePath)) return ids; + + try { + for (const line of fs.readFileSync(parentPath, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as PiEntry; + if (entry.id) ids.add(entry.id); + } catch { + // Ignore malformed parent lines; the child remains parseable. + } + } + } catch { + // The parent may have moved or been deleted. Namespaced message IDs still + // prevent database collisions, though inherited turns cannot be deduped. + } + return ids; +} + +function safeDate(value: string | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} diff --git a/cli/src/providers/registry.ts b/cli/src/providers/registry.ts index 91643ba9..91abcb58 100644 --- a/cli/src/providers/registry.ts +++ b/cli/src/providers/registry.ts @@ -4,6 +4,7 @@ import { CursorProvider } from './cursor.js'; import { CodexProvider } from './codex.js'; import { CopilotCliProvider } from './copilot-cli.js'; import { CopilotProvider } from './copilot.js'; +import { PiProvider } from './pi.js'; const providers = new Map(); @@ -23,6 +24,9 @@ providers.set(copilotCli.getProviderName(), copilotCli); const copilot = new CopilotProvider(); providers.set(copilot.getProviderName(), copilot); +const pi = new PiProvider(); +providers.set(pi.getProviderName(), pi); + /** * Get a provider by name */ diff --git a/cli/src/utils/telemetry.ts b/cli/src/utils/telemetry.ts index 1cb4f3dd..00627d6e 100644 --- a/cli/src/utils/telemetry.ts +++ b/cli/src/utils/telemetry.ts @@ -371,6 +371,14 @@ function detectProviders(): string[] { detected.push('codex-cli'); } + const expandPiHome = (value: string): string => value.replace(/^~(?=$|[\\/])/, home); + const piSessionsDir = process.env.PI_CODING_AGENT_SESSION_DIR + ? expandPiHome(process.env.PI_CODING_AGENT_SESSION_DIR) + : path.join(expandPiHome(process.env.PI_CODING_AGENT_DIR || path.join(home, '.pi', 'agent')), 'sessions'); + if (fs.existsSync(piSessionsDir)) { + detected.push('pi'); + } + if (fs.existsSync(path.join(home, '.copilot', 'session-state'))) { detected.push('copilot-cli'); } diff --git a/dashboard/src/components/chat/message/MessageBubble.tsx b/dashboard/src/components/chat/message/MessageBubble.tsx index 14ad15d7..407e5c88 100644 --- a/dashboard/src/components/chat/message/MessageBubble.tsx +++ b/dashboard/src/components/chat/message/MessageBubble.tsx @@ -30,6 +30,8 @@ function getAssistantConfig(sourceTool?: string): { name: string; avatarColor: s return { name: 'Cursor', avatarColor: 'bg-blue-500' }; case 'codex-cli': return { name: 'Codex', avatarColor: 'bg-green-500' }; + case 'pi': + return { name: 'Pi', avatarColor: 'bg-fuchsia-500' }; case 'copilot': return { name: 'Copilot', avatarColor: 'bg-violet-500' }; case 'copilot-cli': diff --git a/dashboard/src/components/filters/SourceToolSelect.tsx b/dashboard/src/components/filters/SourceToolSelect.tsx index 1c83a61c..931ec143 100644 --- a/dashboard/src/components/filters/SourceToolSelect.tsx +++ b/dashboard/src/components/filters/SourceToolSelect.tsx @@ -11,6 +11,7 @@ export const SOURCE_TOOLS = [ { value: 'claude-code', label: 'Claude Code' }, { value: 'cursor', label: 'Cursor' }, { value: 'codex-cli', label: 'Codex CLI' }, + { value: 'pi', label: 'Pi' }, { value: 'copilot-cli', label: 'Copilot CLI' }, { value: 'copilot', label: 'Copilot' }, ] as const; @@ -21,6 +22,7 @@ const DOT_COLORS: Record = { 'claude-code': 'bg-orange-500', 'cursor': 'bg-blue-500', 'codex-cli': 'bg-green-500', + 'pi': 'bg-fuchsia-500', 'copilot-cli': 'bg-cyan-500', 'copilot': 'bg-violet-500', }; diff --git a/dashboard/src/components/sessions/CompactSessionRow.tsx b/dashboard/src/components/sessions/CompactSessionRow.tsx index 762f1782..b7995050 100644 --- a/dashboard/src/components/sessions/CompactSessionRow.tsx +++ b/dashboard/src/components/sessions/CompactSessionRow.tsx @@ -10,6 +10,7 @@ const SOURCE_LABELS: Record = { 'claude-code': 'Claude Code', cursor: 'Cursor', 'codex-cli': 'Codex CLI', + pi: 'Pi', 'copilot-cli': 'Copilot CLI', copilot: 'Copilot', }; diff --git a/dashboard/src/lib/constants/colors.ts b/dashboard/src/lib/constants/colors.ts index 919a841b..4a2ddb50 100644 --- a/dashboard/src/lib/constants/colors.ts +++ b/dashboard/src/lib/constants/colors.ts @@ -84,6 +84,7 @@ export const SOURCE_TOOL_COLORS: Record = { 'claude-code': 'bg-orange-500/10 text-orange-600 border-orange-500/20', 'cursor': 'bg-blue-500/10 text-blue-600 border-blue-500/20', 'codex-cli': 'bg-green-500/10 text-green-600 border-green-500/20', + 'pi': 'bg-fuchsia-500/10 text-fuchsia-600 border-fuchsia-500/20', 'copilot-cli': 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20', 'copilot': 'bg-violet-500/10 text-violet-600 border-violet-500/20', }; diff --git a/dashboard/src/lib/share-card-icons.ts b/dashboard/src/lib/share-card-icons.ts index 1f92a66a..f05b6c4d 100644 --- a/dashboard/src/lib/share-card-icons.ts +++ b/dashboard/src/lib/share-card-icons.ts @@ -182,6 +182,7 @@ export const SOURCE_TOOL_DISPLAY_NAMES: Record = { 'claude-code': 'Claude Code', 'cursor': 'Cursor', 'codex-cli': 'Codex CLI', + 'pi': 'Pi', 'copilot': 'GitHub Copilot', 'copilot-cli': 'GitHub Copilot', }; diff --git a/dashboard/src/pages/SettingsPage.tsx b/dashboard/src/pages/SettingsPage.tsx index 47ce1683..99d32e5a 100644 --- a/dashboard/src/pages/SettingsPage.tsx +++ b/dashboard/src/pages/SettingsPage.tsx @@ -701,7 +701,7 @@ export default function SettingsPage() {

code-insights dashboard

- The CLI parses sessions from Claude Code, Cursor, Codex CLI, and Copilot CLI into a + The CLI parses sessions from Claude Code, Cursor, Codex CLI, Pi, and Copilot CLI into a local SQLite database. All data stays on your machine.

diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 30179ba8..dd5de014 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ code-insights/ │ ├── commands/ # CLI commands (init, sync, status, stats, dashboard, config, insights) │ ├── commands/stats/ # Stats command suite (4-layer architecture) │ ├── analysis/ # Prompt builders, response parsers, normalizers, runner interface (shared by CLI + server) -│ ├── providers/ # Source tool providers (claude-code, cursor, codex, copilot, copilot-cli) +│ ├── providers/ # Source tool providers (claude-code, cursor, codex, pi, copilot, copilot-cli) │ ├── parser/ # JSONL parsing, title generation │ ├── db/ # SQLite schema, migrations, queries │ ├── utils/ # Config, device, paths, telemetry @@ -66,7 +66,7 @@ code-insights/ - `actions/` — Action handlers for each subcommand + shared error handler - `index.ts` — Command tree with lazy imports - `shared.ts` — Shared CLI flags -- `providers/` — Source tool providers (claude-code, cursor, codex, copilot, copilot-cli) +- `providers/` — Source tool providers (claude-code, cursor, codex, pi, copilot, copilot-cli) - `providers/types.ts` — `SessionProvider` interface - `providers/registry.ts` — Provider registration and lookup - `parser/jsonl.ts` — JSONL file parsing (used by ClaudeCodeProvider) diff --git a/docs/LANDSCAPE.md b/docs/LANDSCAPE.md index e8a10ac5..bfe8895a 100644 --- a/docs/LANDSCAPE.md +++ b/docs/LANDSCAPE.md @@ -16,7 +16,7 @@ Code Insights occupies the **developer self-reflection analytics** layer — it | **Developer analytics** | Helps humans understand their AI usage patterns | **Code Insights** | | **Session capture** | Records what happened during a session | Entire.io CLI, Claude Code JSONL | -Code Insights is neutral across runtimes (parses 5 providers), analytics-focused (friction taxonomy, pattern taxonomy, weekly reflect), and local-first. Competing tools are typically tied to a single runtime and focused on forward-looking agent continuity rather than backward-looking human learning. +Code Insights is neutral across runtimes (parses 6 providers), analytics-focused (friction taxonomy, pattern taxonomy, weekly reflect), and local-first. Competing tools are typically tied to a single runtime and focused on forward-looking agent continuity rather than backward-looking human learning. --- @@ -43,7 +43,7 @@ Entire CLI captures rich context behind code changes: prompts, transcripts, and |-----------|-----------------|---------------| | Primary consumer | The AI agent (runtime) | The human developer (analytics) | | Time orientation | Present task continuity | Cross-session pattern aggregation | -| Source support | Entire CLI only | 5 providers (Claude Code, Cursor, Codex CLI, Copilot CLI, VS Code Copilot Chat) | +| Source support | Entire CLI only | 6 providers (Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, VS Code Copilot Chat) | | Output | Agent context window | Dashboard, CLI stats, `.code-insights.md` | | Core question | "What was happening in this session?" | "How are my patterns changing over time?" | | Freshness gate | None | `code-insights attach --check` (CI staleness gate) | diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 52c8bfbc..ae3519ab 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -6,7 +6,7 @@ Turn your AI coding sessions into knowledge. Code Insights extracts patterns fro ## The Problem -AI coding tools store every conversation as files on your machine. Claude Code uses JSONL in `~/.claude/projects/`. Cursor stores state in SQLite. Codex CLI and Copilot CLI write their own session formats. This is valuable data: +AI coding tools store every conversation as files on your machine. Claude Code and Pi use JSONL session files. Cursor stores state in SQLite. Codex CLI and Copilot CLI write their own session formats. This is valuable data: - What features did you work on last week? - Why did you choose that architecture? - What mistakes did you make (and fix)? @@ -29,7 +29,7 @@ Code Insights provides: ## Who It's For -- **Developers using multiple AI coding tools** who want to understand their AI-assisted work patterns across Claude Code, Cursor, Codex CLI, Copilot CLI, and VS Code Copilot Chat +- **Developers using multiple AI coding tools** who want to understand their AI-assisted work patterns across Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, and VS Code Copilot Chat - **Learners** who want to review and reinforce what they've built with AI assistants - **Privacy-conscious developers** who want insights without giving up their data to a cloud service @@ -52,6 +52,7 @@ LLM analysis uses your own API key, stored in `~/.code-insights/config.json` (mo | **Claude Code** | JSONL sessions from `~/.claude/projects/` | | **Cursor** | Sessions from Cursor's local SQLite state | | **Codex CLI** | Rollout files from `~/.codex/sessions/` | +| **Pi** | JSONL sessions from `~/.pi/agent/sessions/` | | **Copilot CLI** | Event files from `~/.copilot/session-state/` | | **VS Code Copilot Chat** | Sessions from VS Code Copilot Chat local storage | @@ -316,6 +317,7 @@ Each provider implements the `SessionProvider` interface (`discover()`, `parse() | **Claude Code** | JSONL (append-only, one JSON object per line) | `~/.claude/projects//.jsonl` | | **Cursor** | SQLite key-value (`state.vscdb`, JSON blobs) | `~/Library/Application Support/Cursor/User/` | | **Codex CLI** | JSONL (event-based stream) | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | +| **Pi** | JSONL (append-only session tree) | `~/.pi/agent/sessions//.jsonl` | | **Copilot CLI** | JSONL (events) | `~/.copilot/session-state/{id}/events.jsonl` | | **VS Code Copilot Chat** | JSON | Platform-specific Copilot Chat storage | @@ -326,6 +328,7 @@ Each provider implements the `SessionProvider` interface (`discover()`, `parse() | Claude Code | `~/.claude/projects/` | `~/.claude/projects/` | `%USERPROFILE%\.claude\projects\` | | Cursor | `~/Library/Application Support/Cursor/User/` | `~/.config/Cursor/User/` | `%APPDATA%\Cursor\User\` | | Codex CLI | `~/.codex/sessions/` | `~/.codex/sessions/` | `%USERPROFILE%\.codex\sessions\` | +| Pi | `~/.pi/agent/sessions/` | `~/.pi/agent/sessions/` | `%USERPROFILE%\.pi\agent\sessions\` | | Copilot CLI | `~/.copilot/session-state/` | `~/.copilot/session-state/` | `%USERPROFILE%\.copilot\session-state\` | Adding a new source tool requires implementing the `SessionProvider` interface in `cli/src/providers/`, registering it in the provider registry, and adding dashboard display support (colors, avatars, filter options). diff --git a/docs/VISION.md b/docs/VISION.md index a34bfbd8..f07293b0 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -35,7 +35,7 @@ No hosted infrastructure. No Vercel. No Firebase. No Supabase. One install, zero Code Insights is a utility, not a product. It should: - Do one thing well (extract insights from AI coding sessions) -- Support multiple source tools (Claude Code, Cursor, Codex CLI, Copilot CLI, VS Code Copilot Chat) +- Support multiple source tools (Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, VS Code Copilot Chat) - Be easy to install and configure - Stay out of the way once set up @@ -59,7 +59,7 @@ Code Insights is a utility, not a product. It should: ### Phase 4: Feature Parity ✅ - Vite + React SPA replacing the hosted web dashboard - Hono server embedding the SPA — served via `code-insights dashboard` -- Multi-source support: Claude Code, Cursor, Codex CLI, Copilot CLI, VS Code Copilot Chat +- Multi-source support: Claude Code, Cursor, Codex CLI, Pi, Copilot CLI, VS Code Copilot Chat - Full feature parity between CLI stats and dashboard views ### Phase 5: Telemetry ✅