diff --git a/README.md b/README.md index 7682e14..f45c02b 100644 --- a/README.md +++ b/README.md @@ -350,13 +350,15 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **Pi / OMP** stores sessions as JSONL at `~/.pi/agent/sessions//*.jsonl` (Pi) and `~/.omp/agent/sessions//*.jsonl` (OMP). Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes tool names to the standard set (`bash` to `Bash`, `dispatch_agent` to `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown. +**Codebuff** (formerly Manicode) stores per-chat history as JSON at `~/.config/manicode/projects//chats//chat-messages.json`. Codebuff bills in credits rather than tokens, so CodeBurn records each completed assistant message (via `msg.credits`) and approximates cost at the public pay-as-you-go rate ($0.01 / credit). When Codebuff routes a call through an upstream provider and the stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM-calculated cost take precedence. Codebuff-native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`, etc.) normalize to the canonical set (`Read`, `Edit`, `Bash`, `Agent`). The `manicode-dev` and `manicode-staging` channels are walked automatically when present. Honors `CODEBUFF_DATA_DIR` for a custom root. + **Gemini CLI** stores sessions as single JSON files at `~/.gemini/tmp//chats/session-*.json`. Each session embeds real token counts (input, output, cached, thoughts) per message. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging. **OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields. **Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. -CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn. +CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff), filters by date range per entry, and classifies each turn. ## Environment Variables @@ -364,6 +366,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke |----------|-------------| | `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) | | `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) | +| `CODEBUFF_DATA_DIR` | Override Codebuff data directory (default: `~/.config/manicode`) | | `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) | | `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) | diff --git a/package.json b/package.json index 7bff793..73a7c38 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "codex", "opencode", "pi", + "codebuff", "ai-coding", "token-usage", "cost-tracking", diff --git a/src/providers/codebuff.ts b/src/providers/codebuff.ts new file mode 100644 index 0000000..6ee746c --- /dev/null +++ b/src/providers/codebuff.ts @@ -0,0 +1,460 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, dirname, 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' + +// Codebuff (formerly Manicode) uses a credit-based billing system. The local +// chat-messages.json doesn't record per-call token counts the way Claude Code +// or Codex do -- only `credits` on completed assistant messages. We convert +// credits to USD using Codebuff's retail pay-as-you-go rate so the cost shows +// up in the dashboard even when tokens are absent. The rate intentionally +// rounds up to the public PAYG tier ($0.01 / credit) so we never understate +// spend; users on a subscription plan get a conservative upper bound. +const USD_PER_CREDIT = 0.01 + +// Codebuff's chat history lives under `~/.config/manicode/` (the legacy +// product name is still on disk). Development and staging channels use +// `manicode-dev` and `manicode-staging` -- we walk all three when present. +const CHANNELS = ['manicode', 'manicode-dev', 'manicode-staging'] as const + +const modelDisplayNames: Record = { + codebuff: 'Codebuff', + 'codebuff-base': 'Codebuff Base', + 'codebuff-base2': 'Codebuff Base 2', + 'codebuff-lite': 'Codebuff Lite', + 'codebuff-max': 'Codebuff Max', +} + +// Codebuff's native tool names map to codeburn's canonical tool set so +// classifier heuristics (edit/read/bash/etc.) behave consistently with the +// other providers. +const toolNameMap: Record = { + read_files: 'Read', + read_file: 'Read', + code_search: 'Grep', + glob: 'Glob', + find_files: 'Glob', + str_replace: 'Edit', + edit_file: 'Edit', + write_file: 'Write', + run_terminal_command: 'Bash', + terminal: 'Bash', + spawn_agents: 'Agent', + spawn_agent: 'Agent', + write_todos: 'TodoWrite', + create_plan: 'TodoWrite', + browser_logs: 'WebFetch', + web_search: 'WebSearch', + fetch_url: 'WebFetch', +} + +// Tool names we ignore for classification -- they're not useful signals for +// distinguishing "coding" vs "exploration" vs "planning" work. +const IGNORED_TOOLS = new Set(['suggest_followups', 'end_turn']) + +type CodebuffUsage = { + inputTokens?: number + input_tokens?: number + promptTokens?: number + prompt_tokens?: number + outputTokens?: number + output_tokens?: number + completionTokens?: number + completion_tokens?: number + cacheCreationInputTokens?: number + cache_creation_input_tokens?: number + cacheReadInputTokens?: number + cache_read_input_tokens?: number + promptTokensDetails?: { cachedTokens?: number } + prompt_tokens_details?: { cached_tokens?: number } +} + +type CodebuffBlock = { + type?: string + content?: string + toolName?: string + input?: Record + output?: string + agentName?: string + agentType?: string + status?: string + blocks?: CodebuffBlock[] +} + +type CodebuffHistoryMessage = { + role?: string + providerOptions?: { + codebuff?: { model?: string; usage?: CodebuffUsage } + usage?: CodebuffUsage + } +} + +type CodebuffMetadata = { + model?: string + modelId?: string + timestamp?: string | number + usage?: CodebuffUsage + codebuff?: { model?: string; usage?: CodebuffUsage } + runState?: { + cwd?: string + sessionState?: { + cwd?: string + projectContext?: { cwd?: string } + fileContext?: { cwd?: string } + mainAgentState?: { + agentType?: string + messageHistory?: CodebuffHistoryMessage[] + } + } + } +} + +type CodebuffChatMessage = { + id?: string + variant?: string + role?: string + content?: string + timestamp?: string | number + credits?: number + blocks?: CodebuffBlock[] + metadata?: CodebuffMetadata +} + +function getCodebuffBaseDir(override?: string): string { + if (override && override.trim()) return override + const envPath = process.env['CODEBUFF_DATA_DIR'] + if (envPath && envPath.trim()) return envPath + return join(homedir(), '.config', 'manicode') +} + +function pickNumber(...vals: Array): number | undefined { + for (const v of vals) { + if (typeof v === 'number' && Number.isFinite(v)) return v + } + return undefined +} + +function normalizeUsage(u: CodebuffUsage | undefined): { + input: number + output: number + cacheRead: number + cacheWrite: number +} { + if (!u) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + return { + input: pickNumber(u.inputTokens, u.input_tokens, u.promptTokens, u.prompt_tokens) ?? 0, + output: pickNumber(u.outputTokens, u.output_tokens, u.completionTokens, u.completion_tokens) ?? 0, + cacheRead: + pickNumber( + u.cacheReadInputTokens, + u.cache_read_input_tokens, + u.promptTokensDetails?.cachedTokens, + u.prompt_tokens_details?.cached_tokens, + ) ?? 0, + cacheWrite: pickNumber(u.cacheCreationInputTokens, u.cache_creation_input_tokens) ?? 0, + } +} + +function coerceTimestamp(value: string | number | undefined): string { + if (value == null) return '' + if (typeof value === 'number') { + return Number.isFinite(value) ? new Date(value).toISOString() : '' + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value +} + +function parseChatIdToIso(chatId: string): string { + const iso = chatId.replace(/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})/, '$1:$2:$3') + const parsed = Date.parse(iso) + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : '' +} + +function extractCwd(meta: CodebuffMetadata | undefined): string | null { + const rs = meta?.runState + if (!rs) return null + return ( + rs.sessionState?.projectContext?.cwd ?? + rs.sessionState?.fileContext?.cwd ?? + rs.sessionState?.cwd ?? + rs.cwd ?? + null + ) +} + +function extractAgentType(meta: CodebuffMetadata | undefined): string | null { + return meta?.runState?.sessionState?.mainAgentState?.agentType ?? null +} + +function collectBlockTools(blocks: CodebuffBlock[] | undefined, acc: { tools: string[]; bash: string[] }): void { + if (!Array.isArray(blocks)) return + for (const block of blocks) { + if (!block || typeof block !== 'object') continue + if (block.type === 'tool' && typeof block.toolName === 'string') { + const raw = block.toolName + if (!IGNORED_TOOLS.has(raw)) { + acc.tools.push(toolNameMap[raw] ?? raw) + } + if ((raw === 'run_terminal_command' || raw === 'terminal') && block.input) { + const cmd = block.input['command'] + if (typeof cmd === 'string') { + acc.bash.push(...extractBashCommands(cmd)) + } + } + } + if (block.type === 'agent' && Array.isArray(block.blocks)) { + collectBlockTools(block.blocks, acc) + } + } +} + +function resolveModel(meta: CodebuffMetadata | undefined, stashedModel: string | null): string { + const direct = meta?.model ?? meta?.modelId ?? meta?.codebuff?.model + if (direct) return direct + if (stashedModel) return stashedModel + const agentType = extractAgentType(meta) + if (agentType) return `codebuff-${agentType}` + return 'codebuff' +} + +function usageFromHistory(meta: CodebuffMetadata | undefined): { + model: string | null + input: number + output: number + cacheRead: number + cacheWrite: number +} { + const hist = meta?.runState?.sessionState?.mainAgentState?.messageHistory + if (!Array.isArray(hist)) return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + for (let i = hist.length - 1; i >= 0; i--) { + const entry = hist[i] + if (!entry || entry.role !== 'assistant' || !entry.providerOptions) continue + const u = normalizeUsage(entry.providerOptions.usage ?? entry.providerOptions.codebuff?.usage) + if (u.input > 0 || u.output > 0 || u.cacheRead > 0 || u.cacheWrite > 0) { + return { model: entry.providerOptions.codebuff?.model ?? null, ...u } + } + } + return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } +} + +async function readJson(filePath: string): Promise { + try { + const raw = await readFile(filePath, 'utf-8') + return JSON.parse(raw) as T + } catch { + return null + } +} + +async function discoverChannel(root: string): Promise { + const sources: SessionSource[] = [] + const projectsDir = join(root, 'projects') + + let projectNames: string[] + try { + projectNames = await readdir(projectsDir) + } catch { + return sources + } + + for (const projectName of projectNames) { + const chatsDir = join(projectsDir, projectName, 'chats') + let chatIds: string[] + try { + chatIds = await readdir(chatsDir) + } catch { + continue + } + + for (const chatId of chatIds) { + const chatDir = join(chatsDir, chatId) + const dirStat = await stat(chatDir).catch(() => null) + if (!dirStat?.isDirectory()) continue + + const messagesPath = join(chatDir, 'chat-messages.json') + const messagesStat = await stat(messagesPath).catch(() => null) + if (!messagesStat?.isFile()) continue + + // Resolve the real cwd from run-state.json so sessions group by the + // originating project directory instead of the sanitized chat folder + // name (which is often the same for many users). + const runState = await readJson( + join(chatDir, 'run-state.json'), + ) + const cwd = extractCwd({ runState: runState ?? undefined }) + const project = cwd ? basename(cwd) : projectName + + sources.push({ path: chatDir, project, provider: 'codebuff' }) + } + } + + return sources +} + +async function discoverSessionsInBase(baseDir: string): Promise { + const results: SessionSource[] = [] + + // Honor an explicit override: walk only the provided directory even if it + // matches one of the channel names literally. + if (process.env['CODEBUFF_DATA_DIR'] || baseDir !== join(homedir(), '.config', 'manicode')) { + const rootStat = await stat(baseDir).catch(() => null) + if (!rootStat?.isDirectory()) return results + results.push(...await discoverChannel(baseDir)) + return results + } + + const configDir = join(homedir(), '.config') + for (const channel of CHANNELS) { + const root = join(configDir, channel) + const rootStat = await stat(root).catch(() => null) + if (!rootStat?.isDirectory()) continue + results.push(...await discoverChannel(root)) + } + return results +} + +// Downstream aggregation groups sessions by `(provider, sessionId, project)` +// (see src/parser.ts). Codebuff chat folders are ISO timestamps, which means +// the same `chatId` can legitimately appear under each channel root +// (`manicode`, `manicode-dev`, `manicode-staging`) and even resolve to the +// same project cwd. To keep those sessions distinct we include the channel +// identity in the sessionId. The channel is derived from the fixed path +// structure Codebuff writes on disk: `/projects//chats/`. +// Returns null when the path doesn't match that shape so the caller can fall +// back to a plain chatId. +// +// We use '/' as the channel/chatId separator rather than ':' because +// src/parser.ts builds its session key as `${provider}:${sessionId}:${project}` +// and reconstructs the sessionId with `key.split(':')[1]` -- any colon inside +// sessionId would get truncated to just the channel name downstream. +function extractChannelFromChatDir(chatDir: string): string | null { + const chatsDir = dirname(chatDir) + if (basename(chatsDir) !== 'chats') return null + const projectDir = dirname(chatsDir) + const projectsDir = dirname(projectDir) + if (basename(projectsDir) !== 'projects') return null + const channel = basename(dirname(projectsDir)) + return channel ? channel : null +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const chatDir = source.path + const chatId = basename(chatDir) + const channel = extractChannelFromChatDir(chatDir) + const sessionId = channel ? `${channel}/${chatId}` : chatId + const fallbackTs = parseChatIdToIso(chatId) + + const messages = await readJson( + join(chatDir, 'chat-messages.json'), + ) + if (!Array.isArray(messages)) return + + let pendingUserMessage = '' + + for (const [idx, msg] of messages.entries()) { + if (!msg || typeof msg !== 'object') continue + + const variant = msg.variant ?? msg.role + if (variant === 'user') { + if (typeof msg.content === 'string' && msg.content.length > 0) { + pendingUserMessage = msg.content + } + continue + } + + if (variant !== 'ai' && variant !== 'agent' && variant !== 'assistant') continue + + const credits = typeof msg.credits === 'number' && Number.isFinite(msg.credits) ? msg.credits : 0 + const directUsage = normalizeUsage(msg.metadata?.usage ?? msg.metadata?.codebuff?.usage) + const stashedUsage = usageFromHistory(msg.metadata) + + const hasDirect = + directUsage.input > 0 || + directUsage.output > 0 || + directUsage.cacheRead > 0 || + directUsage.cacheWrite > 0 + const usage = hasDirect ? directUsage : stashedUsage + const stashedModel = stashedUsage.model + + // Skip messages with neither credits nor tokens -- they're typically + // in-progress mode dividers or empty framing blocks. + if (credits === 0 && usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0) { + continue + } + + const model = resolveModel(msg.metadata, stashedModel) + const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs + + const dedupId = msg.id ?? String(idx) + const dedupKey = `codebuff:${chatDir}:${dedupId}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const acc = { tools: [] as string[], bash: [] as string[] } + collectBlockTools(msg.blocks, acc) + + // Prefer calculated cost from tokens when available (multi-provider + // models routed through Codebuff still show up in LiteLLM); otherwise + // fall back to the credit-based approximation. + let costUSD = calculateCost(model, usage.input, usage.output, usage.cacheWrite, usage.cacheRead, 0) + if (costUSD === 0 && credits > 0) { + costUSD = credits * USD_PER_CREDIT + } + + yield { + provider: 'codebuff', + model, + inputTokens: usage.input, + outputTokens: usage.output, + cacheCreationInputTokens: usage.cacheWrite, + cacheReadInputTokens: usage.cacheRead, + cachedInputTokens: usage.cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: acc.tools, + bashCommands: acc.bash, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createCodebuffProvider(baseDir?: string): Provider { + const dir = getCodebuffBaseDir(baseDir) + + return { + name: 'codebuff', + displayName: 'Codebuff', + + modelDisplayName(model: string): string { + return modelDisplayNames[model] ?? model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInBase(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const codebuff = createCodebuffProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index a754ada..eeec08a 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,5 @@ import { claude } from './claude.js' +import { codebuff } from './codebuff.js' import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' @@ -56,7 +57,7 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] +const coreProviders: Provider[] = [claude, codex, codebuff, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode] export async function getAllProviders(): Promise { const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 5780d90..46f1fd3 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,25 @@ 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', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'codebuff', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code']) + }) + + it('codebuff tool display names normalize codebuff-native names to canonical set', () => { + const codebuff = providers.find(p => p.name === 'codebuff')! + expect(codebuff.toolDisplayName('read_files')).toBe('Read') + expect(codebuff.toolDisplayName('code_search')).toBe('Grep') + expect(codebuff.toolDisplayName('str_replace')).toBe('Edit') + expect(codebuff.toolDisplayName('run_terminal_command')).toBe('Bash') + expect(codebuff.toolDisplayName('spawn_agents')).toBe('Agent') + expect(codebuff.toolDisplayName('write_todos')).toBe('TodoWrite') + expect(codebuff.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('codebuff model display names cover known agent tiers', () => { + const codebuff = providers.find(p => p.name === 'codebuff')! + expect(codebuff.modelDisplayName('codebuff')).toBe('Codebuff') + expect(codebuff.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2') + expect(codebuff.modelDisplayName('some-future-model')).toBe('some-future-model') }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/codebuff.test.ts b/tests/providers/codebuff.test.ts new file mode 100644 index 0000000..eb6b234 --- /dev/null +++ b/tests/providers/codebuff.test.ts @@ -0,0 +1,480 @@ +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 { createCodebuffProvider } from '../../src/providers/codebuff.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +type ToolBlock = { + type: 'tool' + toolName: string + input?: Record +} + +type TextBlock = { type: 'text'; content: string } + +type Block = ToolBlock | TextBlock + +type AiOpts = { + id?: string + credits?: number + timestamp?: string + blocks?: Block[] + metadata?: Record +} + +function aiMessage(opts: AiOpts = {}) { + const m: Record = { + id: opts.id ?? 'msg-ai-1', + variant: 'ai', + content: '', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + } + if (opts.blocks !== undefined) m['blocks'] = opts.blocks + if (opts.credits !== undefined) m['credits'] = opts.credits + if (opts.metadata !== undefined) m['metadata'] = opts.metadata + return m +} + +function userMessage(content: string, timestamp?: string) { + return { + id: 'msg-user-1', + variant: 'user', + content, + timestamp: timestamp ?? '2026-04-14T10:00:10.000Z', + } +} + +async function writeChat( + baseDir: string, + projectName: string, + chatId: string, + messages: unknown[], + runState?: unknown, +): Promise { + const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId) + await mkdir(chatDir, { recursive: true }) + await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages)) + if (runState !== undefined) { + await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState)) + } + return chatDir +} + +describe('codebuff provider - session discovery', () => { + it('discovers sessions under projects//chats//', async () => { + await writeChat( + tmpDir, + 'myproject', + '2026-04-14T10-00-00.000Z', + [userMessage('hi'), aiMessage({ credits: 10 })], + { sessionState: { projectContext: { cwd: '/Users/test/myproject' } } }, + ) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('codebuff') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z') + }) + + it('uses the cwd basename from run-state.json when present', async () => { + await writeChat( + tmpDir, + 'sanitized-folder', + '2026-04-14T11-00-00.000Z', + [aiMessage({ credits: 5 })], + { sessionState: { projectContext: { cwd: '/Users/test/real-project' } } }, + ) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('real-project') + }) + + it('falls back to the folder name when run-state.json is missing', async () => { + await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [ + aiMessage({ credits: 3 }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('fallback-project') + }) + + it('discovers sessions across multiple projects', async () => { + await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })]) + await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })]) + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + const projects = sessions.map(s => s.project).sort() + expect(projects).toEqual(['proj-a', 'proj-b']) + }) + + it('returns empty for a non-existent directory', async () => { + const provider = createCodebuffProvider('/nonexistent/codebuff-path') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips chat folders without chat-messages.json', async () => { + const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') + await mkdir(chatDir, { recursive: true }) + // No chat-messages.json created. + + const provider = createCodebuffProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('codebuff provider - JSONL parsing', () => { + it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => { + const chatDir = await writeChat( + tmpDir, + 'proj', + '2026-04-14T10-00-00.000Z', + [ + userMessage('implement the feature'), + aiMessage({ + credits: 42, + metadata: { + runState: { sessionState: { mainAgentState: { agentType: 'base2' } } }, + }, + blocks: [ + { type: 'tool', toolName: 'read_files', input: {} }, + { type: 'tool', toolName: 'str_replace', input: {} }, + { type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } }, + { type: 'tool', toolName: 'suggest_followups', input: {} }, + ], + }), + ], + ) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + 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('codebuff') + expect(call.model).toBe('codebuff-base2') + expect(call.userMessage).toBe('implement the feature') + // `suggest_followups` is intentionally dropped from the tool breakdown. + expect(call.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(call.bashCommands).toContain('npm') + // Credits × $0.01 = $0.42 when token counts are absent. + expect(call.costUSD).toBeCloseTo(0.42, 6) + expect(call.inputTokens).toBe(0) + expect(call.outputTokens).toBe(0) + }) + + it('prefers direct metadata.usage tokens when available and still records credits', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ + credits: 10, + metadata: { + model: 'claude-haiku-4-5-20251001', + usage: { + inputTokens: 5000, + outputTokens: 2000, + cacheCreationInputTokens: 1000, + cacheReadInputTokens: 500, + }, + }, + }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + 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.model).toBe('claude-haiku-4-5-20251001') + expect(call.inputTokens).toBe(5000) + expect(call.outputTokens).toBe(2000) + expect(call.cacheCreationInputTokens).toBe(1000) + expect(call.cacheReadInputTokens).toBe(500) + expect(call.cachedInputTokens).toBe(500) + // With real token counts the calculated cost takes precedence over credits. + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ + credits: 7, + metadata: { + runState: { + sessionState: { + mainAgentState: { + messageHistory: [ + { role: 'user' }, + { + role: 'assistant', + providerOptions: { + codebuff: { + model: 'openai/gpt-4o', + usage: { + prompt_tokens: 2000, + completion_tokens: 800, + prompt_tokens_details: { cached_tokens: 400 }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('openai/gpt-4o') + expect(calls[0]!.inputTokens).toBe(2000) + expect(calls[0]!.outputTokens).toBe(800) + expect(calls[0]!.cacheReadInputTokens).toBe(400) + }) + + it('skips assistant messages with no credits and no tokens', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + 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 chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + aiMessage({ id: 'msg-dup', credits: 3 }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const seenKeys = new Set() + + 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 chat, preserving user messages', async () => { + const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ + userMessage('first question'), + aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }), + userMessage('second question', '2026-04-14T10:01:00.000Z'), + aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }), + ]) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + 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]!.costUSD).toBeCloseTo(0.05, 6) + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6) + }) + + it('handles a missing chat-messages.json gracefully', async () => { + const provider = createCodebuffProvider(tmpDir) + const source = { + path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'), + project: 'missing', + provider: 'codebuff', + } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) + + it('skips a malformed chat-messages.json without throwing', async () => { + const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') + await mkdir(chatDir, { recursive: true }) + await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json') + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) +}) + +describe('codebuff provider - sessionId channel scoping', () => { + it('produces distinct sessionIds for the same chatId across different channel roots', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + const channelA = join(tmpDir, 'manicode') + const channelB = join(tmpDir, 'manicode-dev') + const cwd = '/Users/test/shared-project' + const runState = { sessionState: { projectContext: { cwd } } } + + const chatDirA = await writeChat( + channelA, + 'shared-project', + chatId, + [userMessage('hi'), aiMessage({ credits: 5 })], + runState, + ) + const chatDirB = await writeChat( + channelB, + 'shared-project', + chatId, + [userMessage('hi'), aiMessage({ credits: 5 })], + runState, + ) + + const providerA = createCodebuffProvider(channelA) + const providerB = createCodebuffProvider(channelB) + + const sourceA = { path: chatDirA, project: 'shared-project', provider: 'codebuff' } + const sourceB = { path: chatDirB, project: 'shared-project', provider: 'codebuff' } + + const callsA: ParsedProviderCall[] = [] + for await (const call of providerA.createSessionParser(sourceA, new Set()).parse()) { + callsA.push(call) + } + const callsB: ParsedProviderCall[] = [] + for await (const call of providerB.createSessionParser(sourceB, new Set()).parse()) { + callsB.push(call) + } + + expect(callsA).toHaveLength(1) + expect(callsB).toHaveLength(1) + // The whole point of the fix: same chatId + same project should NOT + // collapse into a single session when the chats live under different + // channel roots. + expect(callsA[0]!.sessionId).not.toBe(callsB[0]!.sessionId) + expect(callsA[0]!.sessionId).toBe(`manicode/${chatId}`) + expect(callsB[0]!.sessionId).toBe(`manicode-dev/${chatId}`) + // The sessionId must not contain ':' -- src/parser.ts keys sessions as + // `${provider}:${sessionId}:${project}` and reconstructs the session via + // `key.split(':')[1]`, so a colon would truncate the id downstream. + expect(callsA[0]!.sessionId).not.toContain(':') + expect(callsB[0]!.sessionId).not.toContain(':') + }) + + it('includes the channel name in the sessionId', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + const channelRoot = join(tmpDir, 'manicode-staging') + const chatDir = await writeChat(channelRoot, 'proj', chatId, [aiMessage({ credits: 3 })]) + + const provider = createCodebuffProvider(channelRoot) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe(`manicode-staging/${chatId}`) + expect(calls[0]!.sessionId).not.toContain(':') + }) + + it('falls back to the chatId when the path does not match the expected structure', async () => { + const chatId = '2026-04-14T10-00-00.000Z' + // Not the canonical /projects//chats/ layout. + const chatDir = join(tmpDir, 'oddly-shaped', chatId) + await mkdir(chatDir, { recursive: true }) + await writeFile( + join(chatDir, 'chat-messages.json'), + JSON.stringify([aiMessage({ credits: 2 })]), + ) + + const provider = createCodebuffProvider(tmpDir) + const source = { path: chatDir, project: 'proj', provider: 'codebuff' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe(chatId) + }) +}) + +describe('codebuff provider - display names', () => { + const provider = createCodebuffProvider('/tmp') + + it('has the correct identifiers', () => { + expect(provider.name).toBe('codebuff') + expect(provider.displayName).toBe('Codebuff') + }) + + it('maps known Codebuff tiers to readable names', () => { + expect(provider.modelDisplayName('codebuff')).toBe('Codebuff') + expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2') + expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite') + }) + + it('returns the raw name for unknown models', () => { + expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') + }) + + it('normalizes tool names to the canonical set', () => { + expect(provider.toolDisplayName('read_files')).toBe('Read') + expect(provider.toolDisplayName('str_replace')).toBe('Edit') + expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +})