From 312605c92104a84faf2cd722cb6e99d99106317e Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:56:07 +0300 Subject: [PATCH] fix(opencode): support custom data dir and db prefix --- README.md | 4 +- docs/providers/opencode.md | 17 +++++- src/providers/opencode.ts | 27 +++++++-- src/session-cache.ts | 2 +- tests/providers/opencode-file.test.ts | 45 +++++++++++++- tests/providers/opencode.test.ts | 87 +++++++++++++++++++++++++++ tests/setup/env-isolation.ts | 2 + 7 files changed, 174 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 81d8d8e0..a8bcb609 100644 --- a/README.md +++ b/README.md @@ -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//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. | @@ -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) | diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 8a04efe5..0236db94 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -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 + `/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 diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index 9e529663..938ea59b 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -22,10 +22,20 @@ const toolNameMap: Record = { } 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 /*.db or /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') } @@ -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', } } diff --git a/src/session-cache.ts b/src/session-cache.ts index abfb41c8..32610de6 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -94,7 +94,7 @@ const PROVIDER_ENV_VARS: Record = { 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'], diff --git a/tests/providers/opencode-file.test.ts b/tests/providers/opencode-file.test.ts index ed3718e2..740ea039 100644 --- a/tests/providers/opencode-file.test.ts +++ b/tests/providers/opencode-file.test.ts @@ -19,15 +19,18 @@ afterEach(async () => { type Msg = { id: string; data: Record; parts?: Array> } -// Mirrors the OpenCode 1.1+ on-disk layout under /storage/. +// Mirrors the OpenCode 1.1+ on-disk layout under /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' @@ -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 /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') + }) +}) diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts index 5dc9070a..553f4571 100644 --- a/tests/providers/opencode.test.ts +++ b/tests/providers/opencode.test.ts @@ -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 //.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`) + }) + +}) diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index 079165db..a4b941ea 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -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',