Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
| **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. |
| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. |
| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; first run on a large database can take a minute. |
| **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. |
| **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` or file-based `~/.local/share/opencode/storage/` (respects `XDG_DATA_HOME`; `OPENCODE_DATA_DIR`/`OPENCODE_DB_PREFIX` for renamed/forked builds) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. |
| **Gemini CLI** | `~/.gemini/tmp/<project>/chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. |
| **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. |
| **GitHub Copilot** | `~/.copilot/session-state/` (legacy CLI) and VS Code/VSCodium `workspaceStorage/*/GitHub.copilot-chat/transcripts/` | Editor transcripts carry no explicit token counts, so tokens are estimated from content length and the model is inferred from tool call ID prefixes. |
Expand Down Expand Up @@ -538,6 +538,8 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
| `OPENCODE_DATA_DIR` | Override the OpenCode-compatible data directory (default: `$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`). Point at a renamed/forked build, e.g. `~/.local/share/mimicode`. Exact directory; no `opencode` suffix is appended. |
| `OPENCODE_DB_PREFIX` | Override the SQLite DB filename prefix for OpenCode discovery (default: `opencode`, matching `opencode*.db`); e.g. `mimicode` discovers `mimicode*.db`. SQLite storage only. |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
| `WARP_DB_PATH` | Override Warp database path (default: Warp Stable, then Warp Preview) |
Expand Down
17 changes: 16 additions & 1 deletion docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,24 @@ OpenCode (sst/opencode).

Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`).

For renamed/forked OpenCode-compatible builds (e.g. MiMoCode writing
`~/.local/share/mimocode/mimicode.db` with the same `session`/`message`/`part`
schema), point CodeBurn at the fork's data directory with two env vars:

- `OPENCODE_DATA_DIR` — the **exact** data directory (no `opencode` suffix is
appended). Example: `OPENCODE_DATA_DIR=$HOME/.local/share/mimocode`. Relocates
both file-based and SQLite storage.
- `OPENCODE_DB_PREFIX` — the SQLite filename prefix (default `opencode`,
matching `opencode*.db`). Example: `OPENCODE_DB_PREFIX=mimicode` discovers
`mimicode*.db`. Affects SQLite discovery only; file-based storage under
`<OPENCODE_DATA_DIR>/storage/` is found regardless.

Precedence when no `dataDir` argument is passed (the production path):
`OPENCODE_DATA_DIR` → `$XDG_DATA_HOME/opencode` → `~/.local/share/opencode`.

## Storage format

SQLite.
SQLite (older builds) or file-based JSON (OpenCode 1.1+, under `storage/`).

## Caching

Expand Down
27 changes: 22 additions & 5 deletions src/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,20 @@ const toolNameMap: Record<string, string> = {
}

function getDataDir(dataDir?: string): string {
const base =
dataDir ??
process.env['XDG_DATA_HOME'] ??
join(homedir(), '.local', 'share')
// Test seam: createOpenCodeProvider(tmpDir) points at a base dir that still
// gets the 'opencode' subdirectory appended, preserving existing fixtures
// (tmpDir/opencode/opencode*.db and tmpDir/opencode/storage/...).
if (dataDir) return join(dataDir, 'opencode')

// Production override for OpenCode-compatible forks/renames (e.g. MiMoCode at
// ~/.local/share/mimocode). This is the EXACT data directory — no 'opencode'
// suffix — so a fork writing <dir>/<prefix>*.db or <dir>/storage/... is found
// instead of silently yielding zero sessions. (issue #617)
const override = process.env['OPENCODE_DATA_DIR']
if (override) return override

// Default: $XDG_DATA_HOME/opencode or ~/.local/share/opencode.
const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share')
return join(base, 'opencode')
}

Expand All @@ -34,7 +44,14 @@ function getSqliteConfig(dataDir?: string): SqliteProviderConfig {
providerName: 'opencode',
displayName: 'OpenCode',
dbDir: getDataDir(dataDir),
dbFilePrefix: 'opencode',
// Truthy check (not `??`): an empty-string `OPENCODE_DB_PREFIX` must fall
// back to 'opencode'. With `??`, '' survives as the prefix and
// `discoverSqliteSessions` matches every '*.db' file (filename.startsWith('')
// is always true), sweeping unrelated DBs into discovery. Aligns with
// `OPENCODE_DATA_DIR`'s truthy handling above, and makes behavior identical
// for unset vs empty — which matches the env fingerprint, since
// `computeEnvFingerprint` collapses both to 'OPENCODE_DB_PREFIX='. (issue #617)
dbFilePrefix: process.env['OPENCODE_DB_PREFIX'] || 'opencode',
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
droid: ['FACTORY_DIR'],
cursor: ['XDG_DATA_HOME'],
'cursor-agent': ['XDG_DATA_HOME'],
opencode: ['XDG_DATA_HOME'],
opencode: ['XDG_DATA_HOME', 'OPENCODE_DATA_DIR', 'OPENCODE_DB_PREFIX'],
goose: ['XDG_DATA_HOME'],
crush: ['XDG_DATA_HOME'],
warp: ['WARP_DB_PATH'],
Expand Down
45 changes: 43 additions & 2 deletions tests/providers/opencode-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ afterEach(async () => {

type Msg = { id: string; data: Record<string, unknown>; parts?: Array<Record<string, unknown>> }

// Mirrors the OpenCode 1.1+ on-disk layout under <dataDir>/storage/.
// Mirrors the OpenCode 1.1+ on-disk layout under <dataDir>/storage/. `root`
// overrides the store root (default `tmpDir/opencode`) so a fork layout that
// lives outside an `opencode` subdir can be exercised via OPENCODE_DATA_DIR.
async function writeSession(opts: {
sessionId?: string
projectId?: string
directory?: string
title?: string
root?: string
messages: Msg[]
}) {
const storage = join(tmpDir, 'opencode', 'storage')
const storage = join(opts.root ?? join(tmpDir, 'opencode'), 'storage')
const sessionId = opts.sessionId ?? 'ses_test1'
const projectId = opts.projectId ?? 'global'

Expand Down Expand Up @@ -241,3 +244,41 @@ describe('opencode file-based provider - parsing', () => {
expect(calls.map((c) => c.sessionId).sort()).toEqual(['ses_one', 'ses_two'])
})
})

describe('opencode file-based provider - env override discovery', () => {
it('honors OPENCODE_DATA_DIR for a store under storage/session/ and parses its messages', async () => {
// A renamed/forked OpenCode-compatible build writes file-based storage
// directly under <forkDir>/storage (NOT under an 'opencode' subdir).
const forkDir = join(tmpDir, 'mimocode')
await writeSession({
root: forkDir,
sessionId: 'ses_mimo',
directory: '/Users/test/mimoproject',
messages: [{
id: 'msg_a',
data: {
role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0,
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1 },
},
parts: [{ type: 'text', text: 'mimo output' }],
}],
})

process.env.OPENCODE_DATA_DIR = forkDir
const provider = createOpenCodeProvider() // no arg — must read env
const sources = await provider.discoverSessions()

expect(sources).toHaveLength(1)
expect(sources[0]!.provider).toBe('opencode')
expect(sources[0]!.path).toBe(join(forkDir, 'storage', 'session', 'global', 'ses_mimo.json'))

const calls: ParsedProviderCall[] = []
for (const source of sources) {
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.sessionId).toBe('ses_mimo')
expect(calls[0]!.deduplicationKey).toBe('opencode:ses_mimo:msg_a')
})
})
87 changes: 87 additions & 0 deletions tests/providers/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,3 +899,90 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls[0]!.tools).toEqual([])
})
})

skipUnlessSqlite('opencode provider - env override discovery', () => {
// Builds a renamed/forked OpenCode-compatible DB at <root>/<subdir>/<prefix>.db
// (NOT under an 'opencode' subdir), mirroring a real fork like MiMoCode writing
// ~/.local/share/mimicode/mimicode.db with the same Drizzle schema.
function createForkDb(dbPath: string, sessionId: string): void {
const { DatabaseSync: Database } = require('node:sqlite')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER,
time_archived INTEGER
)
`)
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(sessionId, 'proj-1', 'slug-1', '/home/user/mimoproject', 'MiMo Project', '1.0', 1700000000000)
db.close()
}

it('discovers a renamed fork DB via OPENCODE_DATA_DIR + OPENCODE_DB_PREFIX', async () => {
const forkDir = join(tmpDir, 'mimocode')
await mkdir(forkDir, { recursive: true })
const dbPath = join(forkDir, 'mimicode.db')
createForkDb(dbPath, 'sess-mimo')

process.env.OPENCODE_DATA_DIR = forkDir
process.env.OPENCODE_DB_PREFIX = 'mimicode'

const provider = createOpenCodeProvider() // no arg — must read env
const sessions = await provider.discoverSessions()

expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('opencode')
expect(sessions[0]!.path).toBe(`${dbPath}:sess-mimo`)
})

it('default discovery still finds opencode/opencode*.db via XDG_DATA_HOME when override is unset', async () => {
const dbPath = createTestDb(tmpDir) // creates tmpDir/opencode/opencode.db
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-open')
})

delete process.env.OPENCODE_DATA_DIR
delete process.env.OPENCODE_DB_PREFIX
process.env.XDG_DATA_HOME = tmpDir

const provider = createOpenCodeProvider() // no arg
const sessions = await provider.discoverSessions()

expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toBe(`${dbPath}:sess-open`)
})

it('treats an empty OPENCODE_DB_PREFIX as unset, discovering opencode.db but not arbitrary other DBs', async () => {
// Regression for issue #617 follow-up: `OPENCODE_DB_PREFIX=''` (empty
// string, not undefined) must fall back to the default 'opencode' prefix.
// The default DB (matches 'opencode') carries a real session.
const dbPath = createTestDb(tmpDir) // tmpDir/opencode/opencode.db
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-open')
})

// Discriminating fixture: a sibling DB whose name does NOT start with
// 'opencode' but which has a VALID opencode schema + session row. Schema
// validation cannot exclude it (the schema is valid), so only the prefix
// filter can. With the empty-prefix bug, ''.startsWith('') is true for
// every filename, so random.db would be swept into discovery alongside
// opencode.db (length 2). The default 'opencode' prefix must filter it
// out (length 1).
createForkDb(join(tmpDir, 'opencode', 'random.db'), 'sess-random')

process.env.OPENCODE_DB_PREFIX = '' // empty string, NOT undefined
delete process.env.OPENCODE_DATA_DIR
process.env.XDG_DATA_HOME = tmpDir

const provider = createOpenCodeProvider() // no arg — reads env
const sessions = await provider.discoverSessions()

expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toBe(`${dbPath}:sess-open`)
})

})
2 changes: 2 additions & 0 deletions tests/setup/env-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const CLEARED = [
'KIRO_HOME',
'KIMI_SHARE_DIR',
'MUX_ROOT',
'OPENCODE_DATA_DIR',
'OPENCODE_DB_PREFIX',
'QWEN_DATA_DIR',
'VIBE_HOME',
'WARP_DB_PATH',
Expand Down