mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
Merge branch 'feat/pi-provider'
This commit is contained in:
commit
55d82a4526
9 changed files with 600 additions and 10 deletions
13
README.md
13
README.md
|
|
@ -19,7 +19,7 @@
|
|||
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||
</p>
|
||||
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, and **OpenCode** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, and **Pi** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
|
||||
|
||||
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ npx codeburn
|
|||
### Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, and/or OpenCode
|
||||
- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, and/or Pi (`~/.pi/agent/sessions/`)
|
||||
- For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency
|
||||
|
||||
## Usage
|
||||
|
|
@ -67,6 +67,7 @@ codeburn report --provider claude # Claude Code only
|
|||
codeburn report --provider codex # Codex only
|
||||
codeburn report --provider cursor # Cursor only
|
||||
codeburn report --provider opencode # OpenCode only
|
||||
codeburn report --provider pi # Pi only
|
||||
codeburn today --provider codex # Codex today
|
||||
codeburn export --provider claude # export Claude data only
|
||||
```
|
||||
|
|
@ -82,7 +83,8 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status
|
|||
| Codex (OpenAI) | `~/.codex/sessions/` | Supported |
|
||||
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
|
||||
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
|
||||
| Pi, Amp | -- | Planned (provider plugin system) |
|
||||
| Pi | `~/.pi/agent/sessions/` | Supported |
|
||||
| Amp | -- | Planned (provider plugin system) |
|
||||
|
||||
Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers.
|
||||
|
||||
|
|
@ -157,7 +159,9 @@ Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask
|
|||
|
||||
**OpenCode** stores sessions in SQLite databases at `~/.local/share/opencode/opencode*.db`. CodeBurn queries the `session`, `message`, and `part` tables read-only, extracts token counts and tool usage, and recalculates cost using the LiteLLM pricing engine. Falls back to OpenCode's own cost field for models not in our pricing data. Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double-counting. Supports multiple channel databases and respects `XDG_DATA_HOME`.
|
||||
|
||||
CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode), filters by date range per entry, and classifies each turn.
|
||||
**Pi** stores sessions as JSONL at `~/.pi/agent/sessions/<sanitized-cwd>/*.jsonl`. Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes Pi's lowercase tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown.
|
||||
|
||||
CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi), filters by date range per entry, and classifies each turn.
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
|
@ -190,6 +194,7 @@ src/
|
|||
codex.ts Codex session discovery and JSONL parsing
|
||||
cursor.ts Cursor SQLite parsing, language extraction
|
||||
opencode.ts OpenCode SQLite session discovery and parsing
|
||||
pi.ts Pi agent JSONL session discovery and parsing
|
||||
```
|
||||
|
||||
## License
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cursor",
|
||||
"codex",
|
||||
"opencode",
|
||||
"pi",
|
||||
"ai-coding",
|
||||
"token-usage",
|
||||
"cost-tracking",
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ export function extractBashCommands(command: string): string[] {
|
|||
const segment = command.slice(start, end).trim()
|
||||
if (!segment) continue
|
||||
|
||||
const firstToken = segment.split(/\s+/)[0]
|
||||
const base = basename(firstToken)
|
||||
const tokens = segment.split(/\s+/)
|
||||
let i = 0
|
||||
while (i < tokens.length && /^\w+=/.test(tokens[i]!)) i++
|
||||
const base = i < tokens.length ? basename(tokens[i]!) : ''
|
||||
|
||||
if (base && base !== 'cd') {
|
||||
if (base && base !== 'cd' && base !== 'true' && base !== 'false') {
|
||||
commands.push(base)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
claude: '#FF8C42',
|
||||
codex: '#5BF5A0',
|
||||
cursor: '#00B4D8',
|
||||
opencode: '#A78BFA',
|
||||
pi: '#F472B6',
|
||||
all: '#FF8C42',
|
||||
}
|
||||
|
||||
|
|
@ -427,6 +429,8 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
}
|
||||
|
||||
function getProviderDisplayName(name: string): string {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { claude } from './claude.js'
|
||||
import { codex } from './codex.js'
|
||||
import { pi } from './pi.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
let cursorProvider: Provider | null = null
|
||||
|
|
@ -32,7 +33,7 @@ async function loadOpenCode(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex]
|
||||
const coreProviders: Provider[] = [claude, codex, pi]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
|
||||
|
|
@ -68,4 +69,3 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
|
|||
}
|
||||
return coreProviders.find(p => p.name === name)
|
||||
}
|
||||
|
||||
|
|
|
|||
227
src/providers/pi.ts
Normal file
227
src/providers/pi.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||
'gpt-5': 'GPT-5',
|
||||
'gpt-4o': 'GPT-4o',
|
||||
'gpt-4o-mini': 'GPT-4o Mini',
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
read: 'Read',
|
||||
edit: 'Edit',
|
||||
write: 'Write',
|
||||
glob: 'Glob',
|
||||
grep: 'Grep',
|
||||
task: 'Agent',
|
||||
dispatch_agent: 'Agent',
|
||||
fetch: 'WebFetch',
|
||||
search: 'WebSearch',
|
||||
todo: 'TodoWrite',
|
||||
patch: 'Patch',
|
||||
}
|
||||
|
||||
// Pre-sorted by key length descending so longer/more-specific keys match first
|
||||
const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
|
||||
|
||||
type PiEntry = {
|
||||
type: string
|
||||
id?: string
|
||||
timestamp?: string
|
||||
cwd?: string
|
||||
message?: {
|
||||
role?: string
|
||||
content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record<string, unknown> }>
|
||||
model?: string
|
||||
responseId?: string
|
||||
usage?: {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPiSessionsDir(override?: string): string {
|
||||
return override ?? join(homedir(), '.pi', 'agent', 'sessions')
|
||||
}
|
||||
|
||||
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const line = content.split('\n')[0]
|
||||
if (!line?.trim()) return null
|
||||
return JSON.parse(line) as PiEntry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let projectDirs: string[]
|
||||
try {
|
||||
projectDirs = await readdir(sessionsDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const dirName of projectDirs) {
|
||||
const dirPath = join(sessionsDir, dirName)
|
||||
const dirStat = await stat(dirPath).catch(() => null)
|
||||
if (!dirStat?.isDirectory()) continue
|
||||
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(dirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const filePath = join(dirPath, file)
|
||||
const fileStat = await stat(filePath).catch(() => null)
|
||||
if (!fileStat?.isFile()) continue
|
||||
|
||||
const first = await readFirstEntry(filePath)
|
||||
if (!first || first.type !== 'session') continue
|
||||
|
||||
const cwd = first.cwd ?? dirName
|
||||
sources.push({ path: filePath, project: basename(cwd), provider: 'pi' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(source.path, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let sessionId = basename(source.path, '.jsonl')
|
||||
let pendingUserMessage = ''
|
||||
|
||||
for (const [lineIdx, line] of lines.entries()) {
|
||||
let entry: PiEntry
|
||||
try {
|
||||
entry = JSON.parse(line) as PiEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'session') {
|
||||
sessionId = entry.id ?? sessionId
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type !== 'message') continue
|
||||
|
||||
const msg = entry.message
|
||||
if (!msg) continue
|
||||
|
||||
if (msg.role === 'user') {
|
||||
const texts = (msg.content ?? [])
|
||||
.filter(c => c.type === 'text')
|
||||
.map(c => c.text ?? '')
|
||||
.filter(Boolean)
|
||||
if (texts.length > 0) pendingUserMessage = texts.join(' ')
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role !== 'assistant' || !msg.usage) continue
|
||||
|
||||
const { input, output, cacheRead, cacheWrite } = msg.usage
|
||||
if (input === 0 && output === 0) continue
|
||||
|
||||
const model = msg.model ?? 'gpt-5'
|
||||
const responseId = msg.responseId ?? ''
|
||||
const dedupKey = `pi:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}`
|
||||
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const toolCalls = (msg.content ?? []).filter(c => c.type === 'toolCall' && c.name)
|
||||
const tools = toolCalls.map(c => toolNameMap[c.name!] ?? c.name!)
|
||||
const bashCommands = toolCalls
|
||||
.filter(c => c.name === 'bash')
|
||||
.flatMap(c => {
|
||||
const cmd = c.arguments?.['command']
|
||||
return typeof cmd === 'string' ? extractBashCommands(cmd) : []
|
||||
})
|
||||
|
||||
const costUSD = calculateCost(model, input, output, cacheWrite, cacheRead, 0)
|
||||
const timestamp = entry.timestamp ?? ''
|
||||
|
||||
yield {
|
||||
provider: 'pi',
|
||||
model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: cacheWrite,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools,
|
||||
bashCommands,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
}
|
||||
|
||||
pendingUserMessage = ''
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPiProvider(sessionsDir?: string): Provider {
|
||||
const dir = getPiSessionsDir(sessionsDir)
|
||||
|
||||
return {
|
||||
name: 'pi',
|
||||
displayName: 'Pi',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
for (const [key, name] of modelDisplayEntries) {
|
||||
if (model.startsWith(key)) return name
|
||||
}
|
||||
return model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInDir(dir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const pi = createPiProvider()
|
||||
|
|
@ -58,6 +58,21 @@ describe('extractBashCommands', () => {
|
|||
it('handles single-quoted separators', () => {
|
||||
expect(extractBashCommands("echo 'hello && world'")).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('skips leading env var assignments', () => {
|
||||
expect(extractBashCommands('NODE_ENV=prod npm test')).toEqual(['npm'])
|
||||
expect(extractBashCommands('FOO=bar BAZ=qux ls -la')).toEqual(['ls'])
|
||||
})
|
||||
|
||||
it('skips standalone true/false', () => {
|
||||
expect(extractBashCommands('true && git status')).toEqual(['git'])
|
||||
expect(extractBashCommands('false || echo done')).toEqual(['echo'])
|
||||
expect(extractBashCommands('true')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles env vars combined with chained commands', () => {
|
||||
expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('BASH_TOOLS', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'pi'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
336
tests/providers/pi.test.ts
Normal file
336
tests/providers/pi.test.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createPiProvider } from '../../src/providers/pi.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: opts.id ?? 'sess-001',
|
||||
timestamp: '2026-04-14T10:00:00.000Z',
|
||||
cwd: opts.cwd ?? '/Users/test/myproject',
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(text: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'msg-user-1',
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
timestamp: 1776023210000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(opts: {
|
||||
id?: string
|
||||
responseId?: string
|
||||
timestamp?: string
|
||||
model?: string
|
||||
input?: number
|
||||
output?: number
|
||||
cacheRead?: number
|
||||
cacheWrite?: number
|
||||
tools?: Array<{ name: string; command?: string }>
|
||||
}) {
|
||||
const content = (opts.tools ?? []).map(t => ({
|
||||
type: 'toolCall',
|
||||
id: `call-${t.name}`,
|
||||
name: t.name,
|
||||
arguments: t.command !== undefined ? { command: t.command } : {},
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: opts.id ?? 'msg-asst-1',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
api: 'openai-codex-responses',
|
||||
provider: 'openai-codex',
|
||||
model: opts.model ?? 'gpt-5.4',
|
||||
responseId: opts.responseId ?? 'resp-001',
|
||||
usage: {
|
||||
input: opts.input ?? 1000,
|
||||
output: opts.output ?? 200,
|
||||
cacheRead: opts.cacheRead ?? 0,
|
||||
cacheWrite: opts.cacheWrite ?? 0,
|
||||
totalTokens: (opts.input ?? 1000) + (opts.output ?? 200) + (opts.cacheRead ?? 0),
|
||||
cost: { input: 0.0025, output: 0.003, cacheRead: 0, cacheWrite: 0, total: 0.0055 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 1776023230000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSession(projectDir: string, filename: string, lines: string[]) {
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, filename)
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('pi provider - session discovery', () => {
|
||||
it('discovers sessions grouped by project directory', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('pi')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain('sess-001.jsonl')
|
||||
})
|
||||
|
||||
it('discovers sessions across multiple project directories', async () => {
|
||||
const dir1 = join(tmpDir, '--Users-test-project-a--')
|
||||
const dir2 = join(tmpDir, '--Users-test-project-b--')
|
||||
await writeSession(dir1, 'session1.jsonl', [sessionMeta({ cwd: '/Users/test/project-a' }), assistantMessage({})])
|
||||
await writeSession(dir2, 'session2.jsonl', [sessionMeta({ cwd: '/Users/test/project-b' }), assistantMessage({})])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
const projects = sessions.map(s => s.project).sort()
|
||||
expect(projects).toEqual(['project-a', 'project-b'])
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createPiProvider('/nonexistent/path/that/does/not/exist')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips files whose first line is not a session entry', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'bad.jsonl', [
|
||||
JSON.stringify({ type: 'message', id: 'x' }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips non-jsonl files', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(join(projectDir, 'notes.txt'), 'not a session')
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi provider - JSONL parsing', () => {
|
||||
it('extracts token usage and metadata from an assistant message', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-abc', cwd: '/Users/test/myproject' }),
|
||||
userMessage('implement the feature'),
|
||||
assistantMessage({
|
||||
responseId: 'resp-abc',
|
||||
timestamp: '2026-04-14T10:00:30.000Z',
|
||||
model: 'gpt-5.4',
|
||||
input: 2000,
|
||||
output: 400,
|
||||
cacheRead: 5000,
|
||||
cacheWrite: 100,
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('pi')
|
||||
expect(call.model).toBe('gpt-5.4')
|
||||
expect(call.inputTokens).toBe(2000)
|
||||
expect(call.outputTokens).toBe(400)
|
||||
expect(call.cacheReadInputTokens).toBe(5000)
|
||||
expect(call.cachedInputTokens).toBe(5000)
|
||||
expect(call.cacheCreationInputTokens).toBe(100)
|
||||
expect(call.sessionId).toBe('sess-abc')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toContain('pi:')
|
||||
expect(call.deduplicationKey).toContain('resp-abc')
|
||||
})
|
||||
|
||||
it('collects tool names from toolCall content items', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'read' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'bash', command: 'git status' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
})
|
||||
|
||||
it('extracts bash commands from bash tool arguments', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'bash', command: 'git status && bun test' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
|
||||
})
|
||||
|
||||
it('skips assistant messages with zero tokens', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ input: 0, output: 0 }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ responseId: 'resp-dup' }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const firstRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
firstRun.push(call)
|
||||
}
|
||||
|
||||
const secondRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
secondRun.push(call)
|
||||
}
|
||||
|
||||
expect(firstRun).toHaveLength(1)
|
||||
expect(secondRun).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message in a multi-turn session', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-multi' }),
|
||||
userMessage('first question'),
|
||||
assistantMessage({ responseId: 'resp-1', timestamp: '2026-04-14T10:00:30.000Z', input: 500, output: 100 }),
|
||||
userMessage('second question'),
|
||||
assistantMessage({ responseId: 'resp-2', timestamp: '2026-04-14T10:01:00.000Z', input: 600, output: 120 }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.inputTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('handles missing session file gracefully', async () => {
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: '/nonexistent/session.jsonl', project: 'test', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi provider - display names', () => {
|
||||
const provider = createPiProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('pi')
|
||||
expect(provider.displayName).toBe('Pi')
|
||||
})
|
||||
|
||||
it('maps known models to readable names', () => {
|
||||
expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4')
|
||||
expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
|
||||
expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5')
|
||||
})
|
||||
|
||||
it('returns raw name for unknown models', () => {
|
||||
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
|
||||
it('normalizes tool names to capitalized form', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('read')).toBe('Read')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue