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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npx @code-insights/cli
<img src="docs/assets/screenshots/patterns-light.png" alt="Patterns — friction points, effective patterns, prompt quality, working style" width="800" />
</p>

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.**

Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand All @@ -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)
┌──────────────────┐
Expand Down
6 changes: 6 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"claude-code",
"cursor",
"codex",
"pi-coding-agent",
"copilot",
"ai-coding",
"insights",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/doctor/checks/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/stats/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function applySharedFlags(cmd: Command): Command {
return cmd
.option('-p, --period <period>', 'Time range: 7d, 30d, 90d, all', '7d')
.option('--project <name>', 'Scope to a single project')
.option('--source <tool>', 'Filter by source tool (claude-code, cursor, etc.)')
.option('--source <tool>', 'Filter by source tool (claude-code, cursor, pi, etc.)')
.option('--no-sync', 'Skip auto-sync before displaying stats');
}

Expand Down
2 changes: 1 addition & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>', 'Only sync sessions from a specific project')
.option('-s, --source <name>', 'Only sync sessions from a specific tool (e.g., claude-code, cursor)')
.option('-s, --source <name>', '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')
Expand Down
4 changes: 2 additions & 2 deletions cli/src/parser/titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
266 changes: 266 additions & 0 deletions cli/src/providers/pi.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading