Observed 2026-06-11 against a real opencode.db on this machine plus the
sst/opencode source and public parsers (ccusage, tokscale,
opencode-wrapped, opencode-tokenscope). Defensive parsing only.
- SQLite (>= ~1.2, current):
~/.local/share/opencode/opencode.db(respectsXDG_DATA_HOME; same path shape on Windows). WAL mode, so.db-shm/.db-walsiblings exist and the CLI may hold the file open: open strictly read-only (mode=ro+PRAGMA query_only). - JSON tree (pre-1.2):
~/.local/share/opencode/storage/withsession/<projectID>/ses_*.json(info),message/<sessionID>/msg_*.json,part/<sessionID>/<messageID>/prt_*.json. - Oldest:
storage/session/info/ses_*.jsonplusstorage/session/message/<sid>/*.jsonwith parts inline and the assistant payload undermetadata.assistant("message v1").
The migration into SQLite does not always delete the old tree; if the db exists we read only the db, so nothing is counted twice.
session(id, project_id, parent_id, directory, title, time_created, ...,cost, tokens_input, tokens_output, ...)—directoryis the cwd;parent_idset means a subagent (child) session. Aggregate token columns exist but are absent/zero for migrated legacy data, so we fold messages instead.message(id, session_id, time_created, data)—datais the message JSON (see below). All times are unix milliseconds.part(id, message_id, session_id, time_created, data).
User: {"role":"user","time":{"created":ms},...} — the prompt text lives
in text parts, not on the message.
Assistant (verbatim shape from a real db):
{"role":"assistant","mode":"build","agent":"build",
"path":{"cwd":"...","root":"..."},
"cost":0,
"tokens":{"total":4117,"input":4096,"output":21,"reasoning":0,
"cache":{"write":0,"read":0}},
"modelID":"qwen2.5-coder:7b","providerID":"ollama",
"time":{"created":1780398431744,"completed":1780398480810},
"finish":"stop"}costis opencode's own USD figure; often0for subscription or local providers — fall back to the price table then.tokens.reasoningis separate fromoutput(we add it to output).- Aborted turns lack
time.completedand/or carryerror: {"name":"MessageAbortedError"}; we count those as interrupts. - In SQLite rows,
id/sessionIDmay be missing from the JSON (they live in the table columns).
data.type: text, reasoning, tool, step-start, step-finish,
patch. Tool part:
{"type":"tool","tool":"read","callID":"call_...","state":{
"status":"completed","input":{"filePath":"..."},
"output":"...","time":{"start":ms,"end":ms}}}state.status is pending|running|completed|error; errored tools have
state.error text instead of output. Tool names are lowercase
(read, edit, bash, grep, glob, task, ...) and inputs use
filePath; the adapter maps both onto the Claude Code vocabulary so the
metrics layer stays harness-agnostic. Tool results sit on the assistant's
own parts, so the adapter emits a synthetic follow-up event to match the
Claude Code "results arrive as a user message" shape.