From 718a2b3a081c1ad36ee14ec120a8fb6898e1d1d8 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:05:25 +0300 Subject: [PATCH] feat(openclaude): OpenClaude CLI provider (#213) OpenClaude is a Claude Code fork routing to any LLM; transcripts are Claude-Code-schema JSONL under ~/.openclaude/projects//.jsonl with replay.json siblings skipped. Only usage-bearing assistant lines become calls; sidechain lines are counted as real spend; costs are always computed (the transcript reports none) through the shared tables. Real local testing: sessions generated with the actual CLI against DeepSeek (deepseek-chat), parsed end to end. --- docs/providers/openclaude.md | 33 +++ src/providers/index.ts | 3 +- src/providers/openclaude.ts | 328 +++++++++++++++++++++++ src/session-cache.ts | 1 + tests/provider-registry.test.ts | 2 +- tests/providers/openclaude.test.ts | 413 +++++++++++++++++++++++++++++ 6 files changed, 778 insertions(+), 2 deletions(-) create mode 100644 docs/providers/openclaude.md create mode 100644 src/providers/openclaude.ts create mode 100644 tests/providers/openclaude.test.ts diff --git a/docs/providers/openclaude.md b/docs/providers/openclaude.md new file mode 100644 index 00000000..d365b793 --- /dev/null +++ b/docs/providers/openclaude.md @@ -0,0 +1,33 @@ +# OpenClaude + +OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the same +agent loop against any LLM backend (DeepSeek, OpenAI-compatible endpoints, +Gemini, Ollama, ...). Because it is a fork, its transcripts are Claude-Code +schema and its tool names are already codeburn-canonical. + +## Storage layout + +``` +~/.openclaude/projects//.jsonl transcript +~/.openclaude/projects//.replay.json replay state (skipped) +``` + +`CODEBURN_OPENCLAUDE_DIR` overrides the root (projects live under +`/projects`). + +## Quirks + +- Only `assistant` lines carrying `message.usage` become calls; the + `queue-operation` / `last-prompt` bookkeeping lines and user lines are + skipped. +- Usage is Anthropic-shaped; there is NO cost field, so every call is priced + through the shared tables and always carries `costIsEstimated: true`. +- `isSidechain: true` lines are subagent traffic inside the same transcript + and are counted: their usage is real spend. +- The model id is whatever the routed backend reports (e.g. `deepseek-chat`), + so pricing accuracy tracks the shared litellm tables. +- Tool attribution is partial by construction: streamed responses can split + tool_use blocks across assistant events, and only usage-bearing events are + parsed. Cost and token accounting are exact; tool breakdowns are a floor. +- The project name prefers the basename of the first `cwd` seen in the + transcript; the project-slug directory is the fallback. diff --git a/src/providers/index.ts b/src/providers/index.ts index 8c538019..bf035f06 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -18,6 +18,7 @@ import { lingtaiTui } from './lingtai-tui.js' import { mistralVibe } from './mistral-vibe.js' import { mux } from './mux.js' import { openclaw } from './openclaw.js' +import { openclaude } from './openclaude.js' import { openDesign } from './open-design.js' import { pi, omp } from './pi.js' import { qwen } from './qwen.js' @@ -191,7 +192,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/providers/openclaude.ts b/src/providers/openclaude.ts new file mode 100644 index 00000000..cfbe251d --- /dev/null +++ b/src/providers/openclaude.ts @@ -0,0 +1,328 @@ +// OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the +// same agent loop against any LLM (DeepSeek, OpenAI, etc.). Each session is a +// Claude-Code-schema JSONL transcript under a project slug: +// +// ~/.openclaude/projects//.jsonl +// +// Every transcript has a sibling `.replay.json` that only carries replay +// state; discovery only picks up `.jsonl` files, so the replay files are +// skipped. Subagent (sidechain) traffic is interleaved in the same transcript +// with `isSidechain: true`; those lines are real spend and are counted like +// any other assistant line. The transcript reports no cost field, so every +// call's cost is computed from token counts via calculateCost and always +// flagged costIsEstimated: true. + +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ToolCall } from '../types.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' + +const PROVIDER_NAME = 'openclaude' +const DISPLAY_NAME = 'OpenClaude' +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 + +// Mirrors the CLI's own resolution chain, each level individually overridable: +// root := CODEBURN_OPENCLAUDE_DIR ?? ~/.openclaude +// projects := /projects +function openClaudeRootDir(): string { + return process.env['CODEBURN_OPENCLAUDE_DIR']?.trim() || join(homedir(), '.openclaude') +} + +export function getOpenClaudeProjectsDir(): string { + return join(openClaudeRootDir(), 'projects') +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function safeNonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +function safeTokenCount(value: unknown): number { + return Math.floor(Math.min(safeNonNegativeNumber(value), Number.MAX_SAFE_INTEGER)) +} + +// The transcript only ever carries ISO timestamps, but keep the epoch-millis +// branch so an exotic export can never silently land in 1970, matching the +// guard cline-cli.ts uses on the same hazard. +function isoTimestamp(value: unknown, fallback: string): string { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + // Promote seconds-resolution values to milliseconds, then reject anything + // that stays implausible. + const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value + const date = new Date(ms) + if (!Number.isNaN(date.getTime()) && date.getTime() >= MIN_REASONABLE_TIMESTAMP_MS) { + return date.toISOString() + } + } + const parsed = nonEmptyString(value) + if (parsed) { + const date = new Date(parsed) + if (!Number.isNaN(date.getTime())) return date.toISOString() + } + return fallback +} + +function firstString(input: unknown, keys: string[]): string | undefined { + if (!isRecord(input)) return undefined + for (const key of keys) { + const value = nonEmptyString(input[key]) + if (value) return value + } + return undefined +} + +type CollectedTools = { + tools: string[] + bashCommands: string[] + toolSequence: ToolCall[][] + skills: string[] + subagentTypes: string[] +} + +// Tool names are already codeburn-canonical (Write, Read, Bash, Grep, Edit, +// Task, Skill, WebFetch, ...) because OpenClaude is a Claude Code fork, so no +// mapping table is needed: the raw name is used as-is. +function collectTools(content: unknown): CollectedTools { + const collected: CollectedTools = { + tools: [], bashCommands: [], toolSequence: [], skills: [], subagentTypes: [], + } + if (!Array.isArray(content)) return collected + + const turnTools: ToolCall[] = [] + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'tool_use') continue + const name = nonEmptyString(block['name']) + if (!name) continue + const input = block['input'] + const toolCall: ToolCall = { tool: name } + + const file = firstString(input, ['path', 'file_path', 'paths', 'file']) + if (file) toolCall.file = file + + if (name === 'Bash') { + const command = firstString(input, ['command']) + if (command) { + toolCall.command = command + collected.bashCommands.push(...extractBashCommands(command)) + } + } + if (name === 'Skill') { + const skill = firstString(input, ['name', 'skill']) + if (skill) collected.skills.push(skill) + } + if (name === 'Task') { + const subagentType = firstString(input, ['subagent_type']) + if (subagentType) collected.subagentTypes.push(subagentType) + } + + collected.tools.push(name) + turnTools.push(toolCall) + } + + if (turnTools.length > 0) collected.toolSequence.push(turnTools) + return collected +} + +function textFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'text') continue + const text = nonEmptyString(block['text']) + if (text) return text + } + return '' +} + +// The session's prompt is the first user line carrying real text. Tool results +// also arrive as user lines but as blocks with no text block, so they are +// skipped here. +function firstUserMessage(lines: unknown[]): string { + for (const line of lines) { + if (!isRecord(line) || line['type'] !== 'user') continue + const message = line['message'] + if (!isRecord(message)) continue + const text = textFromContent(message['content']) + if (text) return text + } + return '' +} + +function projectFromCwd(cwd: string): string | undefined { + const parts = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean) + return parts.at(-1) +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const raw = await readSessionFile(source.path) + if (raw === null) return + + // Decode the file once, skipping blank and corrupt lines, so the + // timestamp, cwd, and user-message scans below share a single parse. + const parsedLines: unknown[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) continue + try { + parsedLines.push(JSON.parse(line) as unknown) + } catch { + // Corrupt line - skip it. + } + } + + // File-level fallback timestamp: the first valid ISO timestamp in the + // file, so a line missing one still lands inside the session's window. + let fileTimestamp = new Date(0).toISOString() + for (const line of parsedLines) { + if (!isRecord(line)) continue + const ts = nonEmptyString(line['timestamp']) + if (!ts) continue + const date = new Date(ts) + if (Number.isNaN(date.getTime())) continue + fileTimestamp = date.toISOString() + break + } + + const userMessage = firstUserMessage(parsedLines) + + // The project is the basename of the first cwd seen in the transcript + // (what the user recognizes); the discovery-time slug is the fallback. + let firstSeenCwd: string | undefined + for (const line of parsedLines) { + if (!isRecord(line)) continue + const cwd = nonEmptyString(line['cwd']) + if (!cwd) continue + firstSeenCwd = cwd + break + } + const project = firstSeenCwd + ? (projectFromCwd(firstSeenCwd) ?? source.project) + : source.project + + for (const [index, line] of parsedLines.entries()) { + if (!isRecord(line) || line['type'] !== 'assistant') continue + const message = line['message'] + if (!isRecord(message)) continue + // Only assistant lines carrying a usage record become calls; the rest + // of the transcript (queue-operation, last-prompt, user lines) is + // skipped silently. + const usage = message['usage'] + if (!isRecord(usage)) continue + + const inputTokens = safeTokenCount(usage['input_tokens']) + const outputTokens = safeTokenCount(usage['output_tokens']) + const cacheWriteTokens = safeTokenCount(usage['cache_creation_input_tokens']) + const cacheReadTokens = safeTokenCount(usage['cache_read_input_tokens']) + const webSearchRequests = safeTokenCount( + isRecord(usage['server_tool_use']) ? usage['server_tool_use']['web_search_requests'] : undefined, + ) + + const model = nonEmptyString(message['model']) ?? 'unknown' + const sessionId = nonEmptyString(line['sessionId']) ?? basename(source.path).replace(/\.jsonl$/, '') + const messageId = nonEmptyString(message['id']) ?? nonEmptyString(line['uuid']) ?? String(index) + const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:${messageId}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const { tools, bashCommands, toolSequence, skills, subagentTypes } = collectTools(message['content']) + + yield { + provider: PROVIDER_NAME, + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests, + costUSD: calculateCost(model, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, 0), + costIsEstimated: true, + tools, + bashCommands, + skills: skills.length > 0 ? skills : undefined, + subagentTypes: subagentTypes.length > 0 ? subagentTypes : undefined, + timestamp: isoTimestamp(line['timestamp'], fileTimestamp), + speed: 'standard', + deduplicationKey, + turnId: `${sessionId}:${messageId}`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage, + sessionId, + project, + projectPath: firstSeenCwd, + workingDirectory: nonEmptyString(line['cwd']), + } + } + }, + } +} + +export function createOpenClaudeProvider(overrideProjectsDir?: string): Provider { + const projectsDir = (): string => overrideProjectsDir ?? getOpenClaudeProjectsDir() + + return { + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + // OpenClaude tool names are already codeburn-canonical - pass through. + return rawTool + }, + + async probeRoots(): Promise { + return [{ path: projectsDir(), label: 'projects' }] + }, + + async discoverSessions(): Promise { + const dir = projectsDir() + const projectEntries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const sources: SessionSource[] = [] + + for (const projectEntry of projectEntries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!projectEntry.isDirectory()) continue + const projectDir = join(dir, projectEntry.name) + const fileEntries = await readdir(projectDir, { withFileTypes: true }).catch(() => []) + for (const fileEntry of fileEntries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!fileEntry.isFile()) continue + // Only transcripts; the sibling `.replay.json` files end in + // `.json` and never match. + if (!fileEntry.name.endsWith('.jsonl')) continue + sources.push({ + path: join(projectDir, fileEntry.name), + // The whole slug is the discovery-time fallback; parse-time + // project naming prefers the basename of the first cwd seen in + // the transcript. + project: projectEntry.name, + provider: PROVIDER_NAME, + }) + } + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const openclaude = createOpenClaudeProvider() diff --git a/src/session-cache.ts b/src/session-cache.ts index 9405ce4c..46e72b7e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -181,6 +181,7 @@ export const PROVIDER_ENV_VARS: Record = { droid: ['FACTORY_DIR'], cursor: ['XDG_DATA_HOME'], 'cursor-agent': ['XDG_DATA_HOME'], + openclaude: ['CODEBURN_OPENCLAUDE_DIR'], opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'], goose: ['XDG_DATA_HOME'], crush: ['XDG_DATA_HOME'], diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 71d23050..23b383e4 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/openclaude.test.ts b/tests/providers/openclaude.test.ts new file mode 100644 index 00000000..96216fc3 --- /dev/null +++ b/tests/providers/openclaude.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { openclaude, createOpenClaudeProvider, getOpenClaudeProjectsDir } from '../../src/providers/openclaude.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' +import { calculateCost, loadPricing } from '../../src/models.js' + +let tmpDir: string +let uuidSeq: number +let msgSeq: number + +function nextUuid(): string { + uuidSeq += 1 + return `00000000-0000-4000-8000-${String(uuidSeq).padStart(12, '0')}` +} + +function nextMessageId(): string { + msgSeq += 1 + return `msg_${msgSeq}` +} + +type UsageSpec = { + inputTokens?: number + outputTokens?: number + cacheCreationInputTokens?: number + cacheReadInputTokens?: number + webSearchRequests?: number +} + +type ContentBlock = Record + +type MessageSpec = { + role: 'user' | 'assistant' + id?: string + model?: string + text?: string + content?: ContentBlock[] + usage?: UsageSpec | null +} + +type EventSpec = { + type: 'user' | 'assistant' | 'queue-operation' | 'last-prompt' + sessionId?: string + timestamp?: string + uuid?: string + cwd?: string + slug?: string + isSidechain?: boolean + message?: MessageSpec + payload?: Record +} + +const DEFAULT_TIMESTAMP = '2026-08-04T00:44:18.625Z' + +function buildMessage(spec: MessageSpec): Record { + const message: Record = { + id: spec.id ?? nextMessageId(), + role: spec.role, + content: [], + } + if (spec.model) message['model'] = spec.model + if (spec.content) { + message['content'] = spec.content + } else if (spec.text !== undefined) { + message['content'] = spec.role === 'user' ? spec.text : [{ type: 'text', text: spec.text }] + } + if (spec.usage !== undefined) { + message['usage'] = spec.usage === null ? null : { + input_tokens: spec.usage?.inputTokens ?? 0, + output_tokens: spec.usage?.outputTokens ?? 0, + cache_creation_input_tokens: spec.usage?.cacheCreationInputTokens ?? 0, + cache_read_input_tokens: spec.usage?.cacheReadInputTokens ?? 0, + server_tool_use: { + web_search_requests: spec.usage?.webSearchRequests ?? 0, + web_fetch_requests: 0, + }, + } + } + return message +} + +function buildEvent(spec: EventSpec, sessionId: string): Record { + const event: Record = { type: spec.type, ...(spec.payload ?? {}) } + event['sessionId'] = spec.sessionId ?? sessionId + if (spec.type !== 'last-prompt') event['timestamp'] = spec.timestamp ?? DEFAULT_TIMESTAMP + if (spec.type === 'user' || spec.type === 'assistant') { + event['uuid'] = spec.uuid ?? nextUuid() + if (spec.cwd) event['cwd'] = spec.cwd + if (spec.slug) event['slug'] = spec.slug + if (spec.isSidechain !== undefined) event['isSidechain'] = spec.isSidechain + if (spec.message) event['message'] = buildMessage(spec.message) + } + return event +} + +/** Write one REAL-schema OpenClaude transcript at //.jsonl. */ +async function writeSession(projectsDir: string, slug: string, fileUuid: string, opts?: { + sessionId?: string + cwd?: string + events?: EventSpec[] +}): Promise { + const dir = join(projectsDir, slug) + await mkdir(dir, { recursive: true }) + const path = join(dir, `${fileUuid}.jsonl`) + const sessionId = opts?.sessionId ?? fileUuid + const events = (opts?.events ?? []).map((spec) => { + const event = buildEvent(spec, sessionId) + if ((spec.type === 'user' || spec.type === 'assistant') && opts?.cwd && event['cwd'] === undefined) { + event['cwd'] = opts.cwd + } + return event + }) + await writeFile(path, events.map((event) => JSON.stringify(event)).join('\n') + '\n') + return path +} + +async function collect(projectsDir: string): Promise { + const provider = createOpenClaudeProvider(projectsDir) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + return calls +} + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'openclaude-test-')) + uuidSeq = 0 + msgSeq = 0 + delete process.env['CODEBURN_OPENCLAUDE_DIR'] +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('openclaude provider - identity', () => { + it('registers under its own provider name', () => { + expect(openclaude.name).toBe('openclaude') + expect(openclaude.displayName).toBe('OpenClaude') + }) + + it('passes tool names through unchanged (Claude Code canonical)', () => { + expect(openclaude.toolDisplayName('Write')).toBe('Write') + expect(openclaude.toolDisplayName('Read')).toBe('Read') + expect(openclaude.toolDisplayName('Bash')).toBe('Bash') + expect(openclaude.toolDisplayName('Grep')).toBe('Grep') + expect(openclaude.toolDisplayName('Edit')).toBe('Edit') + expect(openclaude.toolDisplayName('WebFetch')).toBe('WebFetch') + expect(openclaude.toolDisplayName('future_tool_xyz')).toBe('future_tool_xyz') + }) +}) + +describe('openclaude provider - projects dir resolution', () => { + it('defaults to ~/.openclaude/projects', () => { + expect(getOpenClaudeProjectsDir()).toBe(join(process.env['HOME'] ?? '', '.openclaude', 'projects')) + }) + + it('honors the CODEBURN_OPENCLAUDE_DIR override', () => { + process.env['CODEBURN_OPENCLAUDE_DIR'] = '/tmp/oc-projects' + expect(getOpenClaudeProjectsDir()).toBe(join('/tmp/oc-projects', 'projects')) + }) +}) + +describe('openclaude provider - probe roots', () => { + it('reports the projects dir with the projects label', async () => { + const provider = createOpenClaudeProvider('/tmp/x') + expect(await provider.probeRoots!()).toEqual([{ path: '/tmp/x', label: 'projects' }]) + }) + + it('defaults to the standard projects dir for the exported provider', async () => { + expect(await openclaude.probeRoots!()).toEqual([ + { path: join(process.env['HOME'] ?? '', '.openclaude', 'projects'), label: 'projects' }, + ]) + }) +}) + +describe('openclaude provider - discovery', () => { + it('finds one source per .jsonl transcript, nested under project slugs', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', '11111111-1111-1111-1111-111111111111') + await writeSession(projects, 'demo-proj', '22222222-2222-2222-2222-222222222222') + await writeSession(projects, 'other-proj', '33333333-3333-3333-3333-333333333333') + const provider = createOpenClaudeProvider(projects) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(3) + expect(sources.map((source) => source.project).sort()).toEqual(['demo-proj', 'demo-proj', 'other-proj']) + }) + + it('skips .replay.json siblings, non-jsonl files and non-directories', async () => { + const projects = join(tmpDir, 'projects') + const sessionDir = join(projects, 'demo-proj') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl'), '{"type":"assistant","sessionId":"s1","message":{"id":"m1","role":"assistant","model":"deepseek-chat","usage":{"input_tokens":100,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}\n') + await writeFile(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.replay.json'), '{"replay":true}') + await writeFile(join(sessionDir, 'notes.txt'), 'not a transcript') + await writeFile(join(projects, 'loose-file.jsonl'), '{"type":"assistant"}\n') + const provider = createOpenClaudeProvider(projects) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(1) + expect(sources[0]?.path).toBe(join(sessionDir, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.jsonl')) + }) + + it('returns [] when the projects dir is missing', async () => { + const provider = createOpenClaudeProvider(join(tmpDir, 'does-not-exist')) + expect(await provider.discoverSessions()).toEqual([]) + }) +}) + +describe('openclaude provider - parsing', () => { + it('emits one call per assistant line with usage, mapping token fields, model and web searches', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', 'e317d7f4-file', { + sessionId: 'e317d7f4-7921-400e-9cc5-38fe0b81cd52', + cwd: '/Users/husamsoboh/scratch/openclaude-real/demo-proj', + events: [ + { type: 'queue-operation', payload: { operation: 'enqueue' } }, + { type: 'user', message: { role: 'user', text: 'Write a fibonacci function' } }, + { + type: 'assistant', + message: { + role: 'assistant', + id: 'msg_32979ac8b65848e4903c37cd533475a1', + model: 'deepseek-chat', + usage: { inputTokens: 16217, outputTokens: 125 }, + }, + }, + { + type: 'assistant', + message: { + role: 'assistant', + id: 'msg_0c0dd9d7a75b43b0b2cb3735ebc2cac7', + model: 'deepseek-chat', + content: [{ type: 'tool_use', id: 'call_00_Kd4Zg8NLsX0L9rTnWrwq1798', name: 'Bash', input: { command: 'python3 -c "from fib import fib"' } }], + usage: { inputTokens: 500, outputTokens: 60, webSearchRequests: 2 }, + }, + }, + { type: 'last-prompt', payload: { lastPrompt: 'Write a fibonacci function' } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(2) + const first = calls[0]! + const second = calls[1]! + expect(first.provider).toBe('openclaude') + expect(first.sessionId).toBe('e317d7f4-7921-400e-9cc5-38fe0b81cd52') + expect(first.model).toBe('deepseek-chat') + expect(first.inputTokens).toBe(16217) + expect(first.outputTokens).toBe(125) + expect(first.cacheCreationInputTokens).toBe(0) + expect(first.cacheReadInputTokens).toBe(0) + expect(first.webSearchRequests).toBe(0) + expect(first.userMessage).toBe('Write a fibonacci function') + expect(first.timestamp).toBe(DEFAULT_TIMESTAMP) + expect(first.deduplicationKey).toBe('openclaude:e317d7f4-7921-400e-9cc5-38fe0b81cd52:msg_32979ac8b65848e4903c37cd533475a1') + expect(second.webSearchRequests).toBe(2) + expect(second.tools).toEqual(['Bash']) + expect(second.bashCommands.length).toBeGreaterThan(0) + }) + + it('ignores queue-operation, last-prompt, user and usage-less assistant lines', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', 'file-1', { + events: [ + { type: 'queue-operation', payload: { operation: 'enqueue' } }, + { type: 'user', message: { role: 'user', text: 'hello' } }, + { type: 'assistant', message: { role: 'assistant', id: 'no-usage', text: 'thinking only' } }, + { type: 'assistant', message: { role: 'assistant', id: 'null-usage', usage: null, text: 'hi' } }, + { type: 'last-prompt', payload: { lastPrompt: 'hello' } }, + { type: 'assistant', message: { role: 'assistant', id: 'real', usage: { inputTokens: 100, outputTokens: 10 } } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(1) + expect(calls[0]?.deduplicationKey).toBe('openclaude:file-1:real') + expect(calls[0]?.userMessage).toBe('hello') + }) + + it('skips corrupt lines without killing the file', async () => { + const projects = join(tmpDir, 'projects') + const sessionDir = join(projects, 'demo-proj') + await mkdir(sessionDir, { recursive: true }) + const path = join(sessionDir, 'file-1.jsonl') + await writeFile(path, [ + '{"type":"user","sessionId":"s1","message":{"role":"user","content":"prompt"}}', + '{definitely not json', + '{"type":"assistant","sessionId":"s1","message":{"id":"m1","role":"assistant","model":"deepseek-chat","usage":{"input_tokens":100,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + '{"type":"assistant","sessionId":"s1","message":{"id":"m2","role":"assistant","usage":{"input_tokens":50,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}', + ].join('\n') + '\n') + const calls = await collect(projects) + expect(calls).toHaveLength(2) + expect(calls.map((call) => call.deduplicationKey)).toEqual(['openclaude:s1:m1', 'openclaude:s1:m2']) + expect(calls[0]?.userMessage).toBe('prompt') + expect(calls[1]?.model).toBe('unknown') + }) + + it('takes the first user line with non-empty text as the user message (tool results ignored)', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', 'file-1', { + events: [ + { + type: 'user', + message: { role: 'user', content: [{ tool_use_id: 'call_00_x', type: 'tool_result', content: 'File created' }] }, + }, + { type: 'user', message: { role: 'user', text: 'Write a fibonacci function' } }, + { type: 'user', message: { role: 'user', text: 'Second prompt, ignored' } }, + { type: 'assistant', message: { role: 'assistant', id: 'a', usage: { inputTokens: 100, outputTokens: 10 } } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(1) + expect(calls[0]?.userMessage).toBe('Write a fibonacci function') + }) +}) + +describe('openclaude provider - dedup', () => { + it('counts the same message.id within one sessionId once across files', async () => { + const projects = join(tmpDir, 'projects') + const usage = { inputTokens: 100, outputTokens: 10 } + await writeSession(projects, 'proj-a', 'file-1', { + sessionId: 'shared-session', + events: [{ type: 'assistant', message: { role: 'assistant', id: 'dup-1', usage } }], + }) + await writeSession(projects, 'proj-b', 'file-2', { + sessionId: 'shared-session', + events: [{ type: 'assistant', message: { role: 'assistant', id: 'dup-1', usage } }], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(1) + expect(calls[0]?.deduplicationKey).toBe('openclaude:shared-session:dup-1') + }) + + it('counts repeated message.id inside one file once (multi-block assistant messages)', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'proj-a', 'file-1', { + sessionId: 's1', + events: [ + { type: 'assistant', message: { role: 'assistant', id: 'msg_x', text: 'text first', usage: { inputTokens: 100, outputTokens: 10 } } }, + { type: 'assistant', message: { role: 'assistant', id: 'msg_x', usage: { inputTokens: 40, outputTokens: 5 } } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(1) + }) +}) + +describe('openclaude provider - sidechain', () => { + it('counts isSidechain assistant lines as real spend', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', 'file-1', { + events: [ + { type: 'assistant', isSidechain: true, message: { role: 'assistant', id: 'sub-1', usage: { inputTokens: 100, outputTokens: 10 } } }, + { type: 'assistant', isSidechain: false, message: { role: 'assistant', id: 'main-1', usage: { inputTokens: 100, outputTokens: 10 } } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(2) + expect(calls.map((call) => call.deduplicationKey)).toEqual(['openclaude:file-1:sub-1', 'openclaude:file-1:main-1']) + }) +}) + +describe('openclaude provider - project resolution', () => { + it('uses basename of cwd, else the project slug dir', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'slug-dir', 'file-1', { + cwd: '/Users/dev/work/real-project', + events: [{ type: 'assistant', message: { role: 'assistant', id: 'a', usage: { inputTokens: 100, outputTokens: 10 } } }], + }) + await writeSession(projects, 'slug-dir', 'file-2', { + events: [{ type: 'assistant', slug: 'line-slug', message: { role: 'assistant', id: 'b', usage: { inputTokens: 100, outputTokens: 10 } } }], + }) + await writeSession(projects, 'slug-dir', 'file-3', { + events: [{ type: 'assistant', message: { role: 'assistant', id: 'c', usage: { inputTokens: 100, outputTokens: 10 } } }], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(3) + const byId = new Map(calls.map((call) => [call.deduplicationKey, call.project])) + expect(byId.get('openclaude:file-1:a')).toBe('real-project') + expect(byId.get('openclaude:file-2:b')).toBe('slug-dir') + expect(byId.get('openclaude:file-3:c')).toBe('slug-dir') + }) +}) + +describe('openclaude provider - cost', () => { + it('computes an estimated cost for every call (transcripts carry no cost field)', async () => { + const projects = join(tmpDir, 'projects') + await writeSession(projects, 'demo-proj', 'file-1', { + events: [ + { type: 'assistant', message: { role: 'assistant', id: 'a', model: 'deepseek-chat', usage: { inputTokens: 16217, outputTokens: 125 } } }, + { type: 'assistant', message: { role: 'assistant', id: 'b', usage: { inputTokens: 50, outputTokens: 5 } } }, + { type: 'assistant', isSidechain: true, message: { role: 'assistant', id: 'c', usage: { inputTokens: 10, outputTokens: 2 } } }, + ], + }) + const calls = await collect(projects) + expect(calls).toHaveLength(3) + for (const call of calls) { + expect(call.costIsEstimated).toBe(true) + expect(Number.isFinite(call.costUSD)).toBe(true) + } + // Priced through the shared tables, self-consistent with calculateCost. + expect(calls[0]?.costUSD).toBeGreaterThan(0) + expect(calls[0]?.costUSD).toBeCloseTo(calculateCost('deepseek-chat', 16217, 125, 0, 0, 0), 10) + expect(calls[1]?.costUSD).toBe(0) + }) +})